site: ui/ux refresh, seo pass, find/lists indexes, 25 new finds
- newspaper-style masthead (stats | "Unique" | About/Blog) on every page with per-page italic subheader + last-updated date on listing pages; sitewide 42rem reading width - pinterest home (hero + masonry, "load more"); old list view at /index3/ - new pages: /about, /blog, /legal, /find/, /finds/, custom 404 - footer collection links now clickable; "founded" stat replaces "updated" in the masthead - centralised SEO component with per-page json-ld (Review, BlogPosting, Product, Article, CollectionPage, ItemList, BreadcrumbList, etc.); @astrojs/sitemap + @astrojs/rss; robots.txt; promoted finds canonical to their review - memoize getSiteStats() so the masthead/footer counts don't multiply per-page build cost - 25 new daily-finds captures
This commit is contained in:
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
## May 2026
|
## May 2026
|
||||||
|
|
||||||
|
- Comprehensive SEO pass: new `<SEO>` component centralising every head-level tag (canonical, Open Graph, Twitter Card, theme-color, RSS alternate, JSON-LD); page-aware structured data — `Review` + `BreadcrumbList` on review pages, `BlogPosting` on posts, `Product` + `BreadcrumbList` on finds, `Article` + `ItemList` on finds superposts, `CollectionPage` + `ItemList` on category/tag/sources/blog pages, `WebSite` + `Organization` + `ItemList` on the home page, `AboutPage` on /about
|
||||||
|
- Add `@astrojs/sitemap` (auto sitemap-index.xml excluding `/grid/`, `/index3/`, `/404/`) and `@astrojs/rss` (feed at `/rss.xml` covering reviews + posts + finds superposts); add `public/robots.txt` pointing at the sitemap
|
||||||
|
- Add custom 404 page with recent reviews + nav fallback (noindex)
|
||||||
|
- Wire promoted finds to canonical to their review (via `promotedTo`); other finds self-canonical
|
||||||
|
- Noindex `/grid/` and `/index3/` (kept linked but absent from sitemap and search)
|
||||||
|
- Per-page descriptions + OG image alt text for every page; count-bearing titles and descriptions on category/tag pages (`{category} ({n})`); hero image upgraded with real alt text, `fetchpriority="high"`, `loading="eager"`, `decoding="sync"`; static OG fallback tiers in `/public/og/` (default/reviews/finds/posts — PNGs to be supplied)
|
||||||
|
- Set `trailingSlash: 'always'` and `build.format: 'directory'` explicitly to keep canonical URLs stable
|
||||||
|
- Memoize `getSiteStats()` so per-page SEO additions don't multiply build cost
|
||||||
|
- Site-wide UI/UX refresh: newspaper-style masthead on every page (entry-count + last-updated on the left, large serif "Unique" wordmark centered between thin rules, About · Blog nav on the right), per-page italic subheader with last-updated date on listing pages, and an SEO-friendly footer with Browse / Collections / About link columns
|
||||||
|
- Home page swapped to a Pinterest-style hero + masonry grid; the plain chronological list view moves to `/index3/`
|
||||||
|
- New `/about` stub and `/blog` index (Astro page over the `posts` collection with an empty-state when no posts exist)
|
||||||
|
- Single 76rem page width across the entire site (replaces the 42rem default + `wide` prop split); article prose is constrained to a 42rem reading measure inside the wider layout so reviews/finds stay readable
|
||||||
|
- New `getSiteStats()` helper in `src/lib/entries.ts` powers the masthead counts and footer Collections column live
|
||||||
|
- Amazon affiliate handling: outbound links to `amazon.com`, `amzn.to`, `a.co`, and `smile.amazon.com` get the configured tracking ID appended at render time, are marked with `rel="sponsored noopener noreferrer"`, and display a clearly-signposted "Affiliate" badge plus a one-line FTC-style disclosure on review and find pages. Tracking IDs live in `src/lib/affiliate.ts` keyed by marketplace; non-US Amazon hostnames are recognized but left untagged until a marketplace-specific ID is added
|
||||||
- Restructure into a four-collection content pipeline: `find` (captured items, hidden from indexes), `finds` (editorial superposts that reference find slugs in a Pinterest-style grid), `reviews` (existing — gains optional `source` and `fromFind` fields for graduations), and `posts` (existing, generic essays)
|
- Restructure into a four-collection content pipeline: `find` (captured items, hidden from indexes), `finds` (editorial superposts that reference find slugs in a Pinterest-style grid), `reviews` (existing — gains optional `source` and `fromFind` fields for graduations), and `posts` (existing, generic essays)
|
||||||
- New routes `/find/<slug>/` (direct URL only — finds don't appear on home, tag, or category pages) and `/finds/<slug>/` (superpost detail with intro + grid)
|
- New routes `/find/<slug>/` (direct URL only — finds don't appear on home, tag, or category pages) and `/finds/<slug>/` (superpost detail with intro + grid)
|
||||||
- New components: `FindCard`, `FindsGrid`, `Backlinks`, `SourceBadge`
|
- New components: `FindCard`, `FindsGrid`, `Backlinks`, `SourceBadge`
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ Built with [Astro](https://astro.build) + MDX.
|
|||||||
- **Sources catalog** at `sources.json` with per-source fetch strategy (`webfetch` / `curl` / `skip`) and the `/sources` page rendering a 3-column card stream with a "Recent finds" sub-list per card
|
- **Sources catalog** at `sources.json` with per-source fetch strategy (`webfetch` / `curl` / `skip`) and the `/sources` page rendering a 3-column card stream with a "Recent finds" sub-list per card
|
||||||
- **Strict find visibility**: find items are reachable only via the superposts that reference them, the `/sources` cross-reference, or direct URL — they don't crowd the home feed, tag pages, or category pages
|
- **Strict find visibility**: find items are reachable only via the superposts that reference them, the `/sources` cross-reference, or direct URL — they don't crowd the home feed, tag pages, or category pages
|
||||||
- **Backlinks** computed at build time: each find page lists every superpost that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
|
- **Backlinks** computed at build time: each find page lists every superpost that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
|
||||||
- **Unified chronological feed** on the home page across reviews + finds (superposts) + posts
|
- **Newspaper-style masthead** on every page: live entry-count + last-updated stats on the left, large serif "Unique" wordmark centered between thin rules, About/Blog nav on the right; per-page italic subheader ("A small log of delightful things.") with last-updated date on listing pages
|
||||||
|
- **Pinterest-style home page**: hero card for the latest review plus a 3-column masonry grid of remaining reviews with a "Load more" reveal; old plain-list home preserved at `/index3/`
|
||||||
|
- **Unified chronological feed** at `/index3/` across reviews + finds (superposts) + posts
|
||||||
|
- **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout)
|
||||||
- Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*)
|
- Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*)
|
||||||
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages — finds excluded by design
|
- Auto-generated `/categories/<name>/` and `/tags/<name>/` listing pages — finds excluded by design
|
||||||
|
- **Amazon affiliate handling** in `src/lib/affiliate.ts`: outbound links to Amazon (`amazon.com`, `amzn.to`, `a.co`, `smile.amazon.com`) get the configured tracking ID appended automatically; the link gets `rel="sponsored"`, an "Affiliate" badge appears next to it, and a one-line disclosure is shown beneath. Add additional marketplace IDs (e.g. `amazon.co.uk`) to the `AMAZON_TAGS` map when needed
|
||||||
|
- **SEO surface**: single `<SEO>` component owning canonical, Open Graph, Twitter Card, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); RSS feed at `/rss.xml`; `robots.txt`; static OG image fallbacks under `/public/og/`; promoted finds canonical to their review
|
||||||
|
- Custom 404 page with recent-reviews and navigation fallback
|
||||||
- Light/dark mode via `prefers-color-scheme`
|
- Light/dark mode via `prefers-color-scheme`
|
||||||
- No JavaScript shipped to the client
|
- No JavaScript shipped to the client
|
||||||
- Static build, deployable anywhere
|
- Static build, deployable anywhere
|
||||||
@@ -93,7 +99,12 @@ src/
|
|||||||
layouts/
|
layouts/
|
||||||
BaseLayout.astro
|
BaseLayout.astro
|
||||||
pages/
|
pages/
|
||||||
index.astro # unified feed
|
index.astro # Pinterest-style home (hero + masonry)
|
||||||
|
index3.astro # plain chronological list view
|
||||||
|
about.astro # About page
|
||||||
|
blog.astro # /posts index (essays)
|
||||||
|
grid.astro # full grid of every review
|
||||||
|
sources.astro # source catalog
|
||||||
reviews/[...slug].astro
|
reviews/[...slug].astro
|
||||||
posts/[...slug].astro
|
posts/[...slug].astro
|
||||||
categories/[category].astro
|
categories/[category].astro
|
||||||
|
|||||||
+16
-1
@@ -1,7 +1,22 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import mdx from '@astrojs/mdx';
|
import mdx from '@astrojs/mdx';
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
const EXCLUDED_PATHS = new Set([
|
||||||
|
'https://unique.rzen.dev/grid/',
|
||||||
|
'https://unique.rzen.dev/index3/',
|
||||||
|
'https://unique.rzen.dev/404/',
|
||||||
|
]);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://unique.rzen.dev',
|
site: 'https://unique.rzen.dev',
|
||||||
integrations: [mdx()],
|
trailingSlash: 'always',
|
||||||
|
build: { format: 'directory' },
|
||||||
|
integrations: [
|
||||||
|
mdx(),
|
||||||
|
sitemap({
|
||||||
|
filter: (page) => !EXCLUDED_PATHS.has(page),
|
||||||
|
changefreq: 'weekly',
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,3 +5,34 @@ Newly nominated sources awaiting review before promotion to `sources.json`. Each
|
|||||||
## 2026-05-03
|
## 2026-05-03
|
||||||
|
|
||||||
- **Robin Good's "Best Curated Newsletters"** — https://robingood.substack.com/ — surfaced via WebSearch. Annual list of curated newsletters; less a daily-find source than a yearly source-of-sources to scan once and harvest. Worth checking the most recent edition for newsletters in our brief that we don't yet track.
|
- **Robin Good's "Best Curated Newsletters"** — https://robingood.substack.com/ — surfaced via WebSearch. Annual list of curated newsletters; less a daily-find source than a yearly source-of-sources to scan once and harvest. Worth checking the most recent edition for newsletters in our brief that we don't yet track.
|
||||||
|
|
||||||
|
### Gift-focused round (Amazon-leaning)
|
||||||
|
|
||||||
|
Goal of this round: expand `gifts` coverage with sources that lean Amazon-purchasable, and probe whether Amazon's own surfaces (Idea Lists, Influencer Storefronts) are usable as feeds. Note up front: Amazon does not run a unified consumer discovery blog — the closest native equivalents are user-curated Idea Lists (`amazon.com/ideas/...`) and Influencer Storefronts (`amazon.com/shop/{handle}`), neither of which has a central "browse new" feed. Discovery there is per-curator. All US-locale; `src/lib/affiliate.ts` only tags `amazon.com` today.
|
||||||
|
|
||||||
|
**Editorial curators (highest signal-to-noise, vetted gift voice):**
|
||||||
|
|
||||||
|
- **Wirecutter — Gifts** — https://www.nytimes.com/wirecutter/gifts/ — surfaced via WebSearch. NYT-owned product testing publication; the gifts hub is a perennial source of considered, taste-driven recommendations, and most picks have an Amazon purchase path. Likely needs `curl` (NYT blocks WebFetch) or `skip` if paywalled — vet fetchability first.
|
||||||
|
- **The Strategist — Gifts** (New York Magazine) — https://nymag.com/strategist/tags/gifts/ — surfaced via WebSearch. Long-running gift-recommendations vertical with strong editorial voice; many picks Amazon-purchasable. Same publisher anti-bot risks as NYT may apply.
|
||||||
|
- **Apartment Therapy — The Bazaar** — https://www.apartmenttherapy.com/the-bazaar — surfaced via WebSearch. Home-and-living shopping vertical; lots of Amazon picks framed by design taste. Likely WebFetch-friendly.
|
||||||
|
- **Girls' Night In — "The Cute List"** (Alisha Ramos) — https://girlsnightin.substack.com/ — surfaced via WebSearch. Taste-driven curation of "delightful discoveries" (style and culture); gift-adjacent with regular Amazon links. Substack feed available, so the existing `curl` pattern used for Recomendo should work directly.
|
||||||
|
- **Wirecutter — Deals** — https://www.nytimes.com/wirecutter/deals/ — sister hub to Gifts; surfaces Amazon-discounted versions of already-vetted picks, useful as a "gift on sale" signal. Same fetch caveat as Wirecutter — Gifts.
|
||||||
|
|
||||||
|
**Reddit communities (cheap to add — existing curl + JSON path supports them via `top/.json?t=week&limit=25`):**
|
||||||
|
|
||||||
|
- **r/AmazonFinds** — https://www.reddit.com/r/AmazonFinds/ — community-tagged Amazon discoveries. Mixed quality (some TikTok-style slop), but the top-of-week filter should surface the genuinely upvoted finds. Vet for delightfulness vs. cheap-gadget noise before promoting.
|
||||||
|
- **r/Gifts** — https://www.reddit.com/r/Gifts/ — request-and-recommendation community; many resolved threads point to Amazon. Useful for "what to give X" patterns rather than discrete products. Vet noise level — requests outnumber recommendations.
|
||||||
|
- **r/HelpMeFind** — https://www.reddit.com/r/HelpMeFind/ — people hunting for specific items; resolutions often land at Amazon. Lower volume of pure gift content but high signal when a thread closes with an answer.
|
||||||
|
- **r/INEEEEDIT** — https://www.reddit.com/r/INEEEEDIT/ — community sharing surprising/delightful product finds, very gift-shaped. Strong overlap with the "delightful and distinctive" brief; many items are Amazon-buyable.
|
||||||
|
|
||||||
|
**Amazon-native surfaces (experimental — fetchability uncertain, per-curator quality varies):**
|
||||||
|
|
||||||
|
- **Amazon Influencer Storefront — Alix Earle** — https://www.amazon.com/shop/alixearle — surfaced via WebSearch (AmraAndElma roundup). Trial example of a storefront with unusual categories like "Gift Guides for a Partner's Parents" and "Amazon Luxury." Vet for taste fit — fashion/beauty leaning may be off-brief. Storefronts are SPA-heavy; both `webfetch` and `curl` may fail.
|
||||||
|
- **Amazon Influencer Storefront — Julie Capozziello** — https://www.amazon.com/shop/juliecapozziello — surfaced via WebSearch. 64+ curated Idea Lists across lifestyle, gifts, books, snacks. Trial example of breadth. Same fetch and taste caveats as above.
|
||||||
|
- **Amazon Gift Finder hub** — https://www.amazon.com/gift-finder — Amazon's own editorial gift surface. Expect heavy algorithmic + sponsored content; included as a baseline to evaluate whether Amazon-native editorial is worth tracking at all. Likely fails the brief on its own; useful as a control.
|
||||||
|
- **Amazon Idea Lists search** — https://www.amazon.com/gift-ideas-list/s?k=gift+ideas+list — there is no native "browse all Idea Lists" page, so a search-results URL is the closest thing to a discovery feed. Probably noisy; flagged for evaluation rather than confident inclusion.
|
||||||
|
|
||||||
|
**Sources-of-sources (one-shot scan, not daily fetches — same pattern as Robin Good above):**
|
||||||
|
|
||||||
|
- **AmraAndElma — "25 Top Influencers with Amazon Storefronts" (2026)** — https://www.amraandelma.com/top-influencers-with-amazon-storefronts/ — yearly roundup; scan once to harvest more storefronts that match the brief, then drop.
|
||||||
|
- **The Influence Agency — "Top Amazon Storefront Creators"** — https://theinfluenceagency.com/blog/amazon-storefront-top-creators-2025 — second curator-of-curators reference; harvest names, then drop.
|
||||||
|
|||||||
@@ -2,6 +2,52 @@
|
|||||||
|
|
||||||
Running record of every candidate surfaced by `/daily-finds`. The chosen pick of the day is prepended with `[chosen]`. Source attribution per candidate lets us track which sources produce winners over time.
|
Running record of every candidate surfaced by `/daily-finds`. The chosen pick of the day is prepended with `[chosen]`. Source attribution per candidate lets us track which sources produce winners over time.
|
||||||
|
|
||||||
|
## 2026-05-03 — capture run (Amazon-focused sweep)
|
||||||
|
|
||||||
|
Sources scanned: 47 total — 8 hits, ~21 empty, 9 blocked/broken (WebFetch 403/500/empty body, redirect-only), 9 skipped (per sources.json `fetch: skip`).
|
||||||
|
Productive sources: Tools and Toys (4), Six Colors (1), Recomendo (7), Cool Tools (1), One Thing Well (3), Web Curios (3), Kottke.org (1), r/macapps (4).
|
||||||
|
Unproductive but live: MacStories (Apple Arcade week), Daring Fireball, Hacker News — Show HN, Product Hunt, Werd.com (listicles only), Carryology (insights essays, no products), Kikkerland (Juicy Jingles already captured; rest didn't qualify), Things Magazine (link roundups, no products), A Continuous Lean (newsletter/collab announcements), Put This On (eBay roundups), Standard & Strange Journal (Field Guides), Outlier (have URLs, skipped this run), Roadtrippers Magazine, The Outbound Collective, AFAR, Wallpaper* Travel, Boutique Homes, Uses This, Open Culture, Messy Nessy Chic, r/BuyItForLife (anecdotes, no clean product picks), Cool Material, StyleForum, Kevin Kelly thetechnium.
|
||||||
|
Blocked or broken this run: Atlas Obscura (WebFetch 403; curl returned content but anchors were nav-only), This Is Why I'm Broke (WebFetch 404 on /new), Unusual Hotels of the World (empty body via curl), Snow Peak (nav-only via WebFetch), Uncommon Goods (empty fetch), MoMA Design Store (curl 1.2MB but anchor extraction got nav only).
|
||||||
|
|
||||||
|
### Captured finds
|
||||||
|
|
||||||
|
- felo-ergonic-screwdriver-set — Felo "Ergonic" Screwdriver Set — source: Tools and Toys
|
||||||
|
- umoven-pop-up-bifold-wallet — umoven Pop-Up Bifold Wallet — source: Tools and Toys
|
||||||
|
- ugreen-uno-magsafe-power-bank — UGREEN Uno MagSafe Power Bank — source: Tools and Toys
|
||||||
|
- espro-p7-french-press — Espro P7 French Press — source: Tools and Toys
|
||||||
|
- anker-nano-travel-adapter — Anker Nano Universal Travel Adapter — source: Six Colors
|
||||||
|
- aootek-solar-fence-lights — Aootek Motion-Sensing Solar Fence Lights — source: Recomendo
|
||||||
|
- birdfy-smart-bird-feeder — Birdfy AI Smart Bird Feeder — source: Recomendo
|
||||||
|
- casabrews-cm5418-espresso-machine — Casabrews CM5418 Espresso Machine — source: Recomendo
|
||||||
|
- skull-bluffing-card-game — Skull (Card Game) — source: Recomendo
|
||||||
|
- toms-keys-spare-fob — Tom's Keys — source: Recomendo
|
||||||
|
- stillgram — Stillgram — source: Recomendo
|
||||||
|
- magnifiedsand — Magnified Sand — source: Recomendo
|
||||||
|
- embassy-of-the-free-mind — Embassy of the Free Mind — source: Cool Tools
|
||||||
|
- frogfind — FrogFind — source: One Thing Well
|
||||||
|
- cilicon-ephemeral-vms — Cilicon — source: One Thing Well
|
||||||
|
- companion-apps-for-apple-music — Companion Apps for Apple Music — source: One Thing Well
|
||||||
|
- roll-disposable-camera — Roll: Disposable Camera — source: Web Curios
|
||||||
|
- damnlines — Damnlines — source: Web Curios
|
||||||
|
- magic-postcards — Penumbra Magic Postcards — source: Web Curios
|
||||||
|
- unruly-play-archive — Unruly Play — source: Kottke.org
|
||||||
|
- dockpops — DockPops — source: r/macapps
|
||||||
|
- fluttertime — FlutterTime — source: r/macapps
|
||||||
|
- refine-grammarly-alternative — Refine — source: r/macapps
|
||||||
|
- notepad-plus-plus-mac — Notepad++ for Mac — source: r/macapps
|
||||||
|
|
||||||
|
New source nominees: 0 this run — the standout finds linked to single products / single-developer GitHubs (Stillgram, Refine, Cilicon, Mario A. Guzman's iTunes recreations) rather than to publications worth tracking. Existing candidate-sources.md still has the gift-focused Amazon round queued for vetting.
|
||||||
|
|
||||||
|
### Source maintenance for next pruning pass
|
||||||
|
|
||||||
|
- **MoMA Design Store**: curl returns 1.2MB of HTML but the product grid is rendered via JS — only nav anchors are extractable. Would need a headless browser, or the storefront's collection JSON endpoint if Shopify exposes one. Same root cause as Snow Peak's "nav only" result.
|
||||||
|
- **Atlas Obscura**: curl returns 503KB body and HTTP 200, but the anchor parser only extracted the global nav (Places/Foods/Stories) — likely because the Recently Added cards are rendered client-side too. Try fetching `/places/recent` via curl with the Safari UA, or the regional sitemap.
|
||||||
|
- **This Is Why I'm Broke**: WebFetch on `/new` 404'd and the homepage HTML uses `{{entry.title}}` Mustache placeholders — the product feed is loaded client-side. Skip-mark for now or find their JSON endpoint.
|
||||||
|
- **Unusual Hotels of the World**: curl returned 4KB and `<title>Uhotw</title>` — the site appears to be a thin SPA shell. Probably needs `fetch: skip` or a different fetchUrl.
|
||||||
|
- **Uncommon Goods**: WebFetch returned empty for both `/new-arrivals` URLs tried — needs a fetchUrl override.
|
||||||
|
- **Snow Peak**: nav-only response on `/collections/new-arrivals` — likely Shopify SPA. Look for a JSON product feed URL.
|
||||||
|
- **r/BuyItForLife**: top-of-week is heavy on personal-anecdote photo posts ("My N64 still works after 30 years") rather than product picks. The signal-to-noise ratio is lower than the macapps subreddit; may want to filter on text posts only or pin a sub-filter URL.
|
||||||
|
|
||||||
## 2026-05-03 — theme: single-virtue tools
|
## 2026-05-03 — theme: single-virtue tools
|
||||||
|
|
||||||
Sources scanned: 46 total — 23 hits, 5 empty, 17 blocked (Cloudflare 403 / 404 / timeout / dead domain), 1 skipped (paywalled).
|
Sources scanned: 46 total — 23 hits, 5 empty, 17 blocked (Cloudflare 403 / 404 / timeout / dead domain), 1 skipped (paywalled).
|
||||||
|
|||||||
Generated
+172
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@astrojs/mdx": "^4.3.14",
|
"@astrojs/mdx": "^4.3.14",
|
||||||
|
"@astrojs/rss": "^4.0.12",
|
||||||
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"astro": "^5.1.1",
|
"astro": "^5.1.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,46 @@
|
|||||||
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
|
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@astrojs/rss": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "^5.5.7",
|
||||||
|
"piccolore": "^0.1.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@astrojs/rss/node_modules/zod": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@astrojs/sitemap": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sitemap": "^9.0.0",
|
||||||
|
"stream-replace-string": "^2.0.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@astrojs/sitemap/node_modules/zod": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@astrojs/telemetry": {
|
"node_modules/@astrojs/telemetry": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
|
||||||
@@ -1228,6 +1270,18 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@oslojs/encoding": {
|
"node_modules/@oslojs/encoding": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||||
@@ -1717,6 +1771,24 @@
|
|||||||
"@types/unist": "*"
|
"@types/unist": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||||
|
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/sax": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@@ -1969,6 +2041,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/arg": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
@@ -2963,6 +3041,42 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-builder": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz",
|
||||||
|
"integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^2.1.0",
|
||||||
|
"fast-xml-builder": "^1.1.5",
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"strnum": "^2.2.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -4817,6 +4931,21 @@
|
|||||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/piccolore": {
|
"node_modules/piccolore": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
||||||
@@ -5423,6 +5552,25 @@
|
|||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sitemap": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^24.9.2",
|
||||||
|
"@types/sax": "^1.2.1",
|
||||||
|
"arg": "^5.0.0",
|
||||||
|
"sax": "^1.4.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sitemap": "dist/esm/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.5",
|
||||||
|
"npm": ">=10.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/smol-toml": {
|
"node_modules/smol-toml": {
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||||
@@ -5463,6 +5611,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-replace-string": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@@ -5509,6 +5663,18 @@
|
|||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/style-to-js": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||||
@@ -5688,6 +5854,12 @@
|
|||||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@astrojs/mdx": "^4.3.14",
|
"@astrojs/mdx": "^4.3.14",
|
||||||
|
"@astrojs/rss": "^4.0.12",
|
||||||
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"astro": "^5.1.1",
|
"astro": "^5.1.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://unique.rzen.dev/sitemap-index.xml
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
export interface OgImage {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alt: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
canonicalPath?: string;
|
||||||
|
canonicalOverride?: string;
|
||||||
|
ogType?: 'website' | 'article';
|
||||||
|
ogImage?: OgImage;
|
||||||
|
noindex?: boolean;
|
||||||
|
publishedTime?: Date;
|
||||||
|
modifiedTime?: Date;
|
||||||
|
articleTags?: string[];
|
||||||
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
canonicalPath,
|
||||||
|
canonicalOverride,
|
||||||
|
ogType = 'website',
|
||||||
|
ogImage,
|
||||||
|
noindex = false,
|
||||||
|
publishedTime,
|
||||||
|
modifiedTime,
|
||||||
|
articleTags = [],
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const SITE_NAME = 'Unique';
|
||||||
|
const titleTag = title === SITE_NAME ? SITE_NAME : `${title} — ${SITE_NAME}`;
|
||||||
|
|
||||||
|
const canonicalUrl =
|
||||||
|
canonicalOverride ??
|
||||||
|
new URL(canonicalPath ?? Astro.url.pathname, Astro.site).href;
|
||||||
|
|
||||||
|
const jsonLdBlocks = jsonLd
|
||||||
|
? Array.isArray(jsonLd)
|
||||||
|
? jsonLd
|
||||||
|
: [jsonLd]
|
||||||
|
: [];
|
||||||
|
---
|
||||||
|
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{titleTag}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
{noindex && <meta name="robots" content="noindex,nofollow" />}
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<meta name="theme-color" content="#fafaf7" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#161613" media="(prefers-color-scheme: dark)" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title={`${SITE_NAME} — RSS`} href="/rss.xml" />
|
||||||
|
|
||||||
|
<meta property="og:type" content={ogType} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:site_name" content={SITE_NAME} />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
{ogImage && (
|
||||||
|
<>
|
||||||
|
<meta property="og:image" content={ogImage.url} />
|
||||||
|
<meta property="og:image:width" content={String(ogImage.width)} />
|
||||||
|
<meta property="og:image:height" content={String(ogImage.height)} />
|
||||||
|
<meta property="og:image:alt" content={ogImage.alt} />
|
||||||
|
{ogImage.type && <meta property="og:image:type" content={ogImage.type} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ogType === 'article' && publishedTime && (
|
||||||
|
<meta property="article:published_time" content={publishedTime.toISOString()} />
|
||||||
|
)}
|
||||||
|
{ogType === 'article' && modifiedTime && (
|
||||||
|
<meta property="article:modified_time" content={modifiedTime.toISOString()} />
|
||||||
|
)}
|
||||||
|
{ogType === 'article' &&
|
||||||
|
articleTags.map((t) => <meta property="article:tag" content={t} />)}
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
{ogImage && (
|
||||||
|
<>
|
||||||
|
<meta name="twitter:image" content={ogImage.url} />
|
||||||
|
<meta name="twitter:image:alt" content={ogImage.alt} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jsonLdBlocks.map((block) => (
|
||||||
|
<script type="application/ld+json" is:inline set:html={JSON.stringify(block)} />
|
||||||
|
))}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Anker Nano Universal Travel Adapter"
|
||||||
|
subtitle: "All four plug standards plus four USB ports in something the size of a deck of cards"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/dp/B0DHVNW1CN
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Six Colors"
|
||||||
|
topics: [tools, travel]
|
||||||
|
tags: [travel, charging, edc]
|
||||||
|
---
|
||||||
|
|
||||||
|
The smallest universal travel adapter we've seen that still includes all four common plug regions (US/EU/UK/AU) plus two USB-C and two USB-A outputs at a useful 30W combined. Roughly an inch thick and three inches long, it replaces a tangle of region-specific dongles with a single brick that lives permanently in the toiletry bag.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Aootek Motion-Sensing Solar Fence Lights"
|
||||||
|
subtitle: "Dim until something moves, then bright enough to actually see by"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/Aootek-Motion-Sensor-Lights-Daylight/dp/B0GFM7PS73
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [tools, gifts]
|
||||||
|
tags: [outdoor, solar, motion-sensor]
|
||||||
|
---
|
||||||
|
|
||||||
|
A six-pack of fence-mountable solar lights for around twenty dollars that solves the long-standing problem with cheap solar lights — they hold a real charge and they're bright enough to matter. The smart mode dims them to ambient at night and ramps them up the moment a person or animal triggers the PIR sensor, so you get useful light without leaving full brightness on for hours.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Birdfy AI Smart Bird Feeder"
|
||||||
|
subtitle: "Camera feeder that names every visiting species and clips the good takeoffs"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/NETVUE-Birdfy-Watching-Capture-Detection/dp/B0B56BVWBM
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [gifts, general-delight]
|
||||||
|
tags: [outdoor, birds, ai]
|
||||||
|
---
|
||||||
|
|
||||||
|
A bird feeder with a small camera in the eaves that auto-identifies the species visiting your yard and saves a clip every time one lands. The device is the gateway to a hidden category of attention you weren't paying — it turns out a backyard feeder gets dozens of distinct species through it in a season, and seeing the names attached changes how you notice the rest of the day.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Casabrews CM5418 Espresso Machine"
|
||||||
|
subtitle: "Sub-$200 espresso machine that punches well above its price"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/Espresso-Professional-Removable-Cappuccino-Macchiato/dp/B09X3WGJ3R
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [tools, gifts]
|
||||||
|
tags: [coffee, kitchen, espresso]
|
||||||
|
---
|
||||||
|
|
||||||
|
A 20-bar pump espresso machine with a real steam wand for under two hundred dollars — the rare cheap espresso maker whose pulls aren't visibly thin and whose milk frothing actually works. The point isn't to compete with a Breville; it's to be the machine that makes the daily Nespresso-pod habit feel embarrassing within a week.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Cilicon"
|
||||||
|
subtitle: "Throwaway macOS virtual machines via Apple's Virtualization framework"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://github.com/traderepublic/Cilicon
|
||||||
|
source: "One Thing Well"
|
||||||
|
topics: [macos-apps, tools]
|
||||||
|
tags: [virtualization, ci, open-source]
|
||||||
|
---
|
||||||
|
|
||||||
|
A small open-source macOS app from Trade Republic that spins up a fresh, ephemeral VM from a template, runs whatever script you point at it, and tears the whole thing down on completion. It was built as a self-hosted CI runner for iOS apps, but it's quietly useful any time you want a clean Mac for ten minutes without polluting your real one — testing an installer, opening a sketchy DMG, profiling a build from scratch.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Companion Apps for Apple Music"
|
||||||
|
subtitle: "Faithful re-creations of the classic iTunes interfaces, riding on top of modern Music"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://marioaguzman.github.io/music/
|
||||||
|
source: "One Thing Well"
|
||||||
|
topics: [macos-apps, general-delight]
|
||||||
|
tags: [retro, music, apple]
|
||||||
|
---
|
||||||
|
|
||||||
|
A small set of macOS apps from designer Mario A. Guzman that wrap Apple Music in pixel-accurate recreations of the classic iTunes 1, iTunes 4, and early iPod-era interfaces — brushed-metal title bar and all. They're not skins; they're standalone players that actually drive your library, so the nostalgia comes with the convenience of streaming you already pay for.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Damnlines"
|
||||||
|
subtitle: "Live webcams of the queues outside viral New York food spots"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://damnlines.com/
|
||||||
|
source: "Web Curios"
|
||||||
|
topics: [general-delight, travel]
|
||||||
|
tags: [nyc, webcam, food]
|
||||||
|
---
|
||||||
|
|
||||||
|
A grid of street-level webcams pointed at the lines outside whichever bagel shop, soft-serve window, or pop-up bakery TikTok decided to ruin this week. Useful as a planning tool ("is the wait at Levain actually two hours right now?") and even better as ambient TV — a quiet meditation on what New Yorkers will, in fact, stand outside in the rain for.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "DockPops"
|
||||||
|
subtitle: "iPhone-style app folders for the macOS Dock"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://apps.apple.com/us/app/dockpops/id6759999009
|
||||||
|
linkText: "Visit on App Store"
|
||||||
|
source: "r/macapps"
|
||||||
|
topics: [macos-apps]
|
||||||
|
tags: [dock, productivity]
|
||||||
|
---
|
||||||
|
|
||||||
|
A Mac app that gives the Dock the one feature it has never had — collapsible app folders that work like iOS's home-screen groups. Unlike macOS Stacks, the folders aren't tied to real directories on disk, so you can group apps by intent ("design tools", "writing"), give each folder its own Dock icon, and get a live preview of what's inside on hover.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Embassy of the Free Mind"
|
||||||
|
subtitle: "Amsterdam museum and rare-book library for Hermetic and occult manuscripts"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://embassyofthefreemind.com/en/
|
||||||
|
source: "Cool Tools"
|
||||||
|
topics: [travel, general-delight]
|
||||||
|
tags: [museum, amsterdam, books]
|
||||||
|
---
|
||||||
|
|
||||||
|
One of the world's largest collections of Hermetic, alchemical, and esoteric texts, housed in a 17th-century canal house in Amsterdam and open to the public via themed guided tours (alchemy, magic, witchcraft, Rosicrucianism). Thousands of manuscripts have been digitized and are free to read online, but the visit is the point — you don't get to touch the books, but you can smell ones that are five centuries old.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Espro P7 French Press"
|
||||||
|
subtitle: "Double micro-mesh filter that actually keeps grit out of the cup"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/dp/B079T44WPZ
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Tools and Toys"
|
||||||
|
topics: [tools, gifts]
|
||||||
|
tags: [coffee, kitchen, stainless-steel]
|
||||||
|
---
|
||||||
|
|
||||||
|
A vacuum-insulated stainless French press whose filter is two stacked layers of woven micro-mesh — fine enough that the cup pours clean, with none of the silt that makes most French press coffee feel like sand at the bottom. The double wall keeps the brew warm for the second and third cups, which is when the standard glass press has already gone tepid.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: 'Felo "Ergonic" Screwdriver Set'
|
||||||
|
subtitle: "Soft handles that deform under load to deliver more torque"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/dp/B002YNQVPE
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Tools and Toys"
|
||||||
|
topics: [tools, gifts]
|
||||||
|
tags: [hand-tools, ergonomic, made-in-germany]
|
||||||
|
---
|
||||||
|
|
||||||
|
A German-made screwdriver set whose handles use a softer outer compound than the usual hard plastic — the grip squashes against your palm under twisting load, putting more skin in contact with the tool and reducing the pressure points that make long screw-driving sessions hurt. The shafts are chrome-vanadium with magnetized tips. It's the rare case of a household tool that's quietly nicer to hold than its peers without costing five times as much.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "FlutterTime"
|
||||||
|
subtitle: "Menu-bar timezone converter with a draggable timeline"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://apps.apple.com/us/app/fluttertime-timezone-converter/id6740316879
|
||||||
|
linkText: "Visit on App Store"
|
||||||
|
source: "r/macapps"
|
||||||
|
topics: [macos-apps]
|
||||||
|
tags: [timezone, menu-bar, free]
|
||||||
|
---
|
||||||
|
|
||||||
|
A free, ad-free, no-tracking menu-bar app that does one thing — convert times across the cities you care about by dragging a single horizontal timeline forward and back. The visual model is the right one for the problem (you immediately *see* whether 9am Berlin is before or after your Tokyo colleague's lunch), and unlike most timezone converters it stays where you can glance at it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "FrogFind"
|
||||||
|
subtitle: "Search engine designed to render in browsers from 1995"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: http://www.frogfind.com
|
||||||
|
source: "One Thing Well"
|
||||||
|
topics: [tools, general-delight]
|
||||||
|
tags: [retro-computing, search, web]
|
||||||
|
---
|
||||||
|
|
||||||
|
A DuckDuckGo-powered search front end that strips every page through a readability filter and serves it as plain HTML so it loads on a Power Mac, a System 7 emulator, a vintage PalmPilot, or any browser too old for TLS 1.2. The genuinely useful side effect is that even modern browsers get a clean, ad-free, JavaScript-free version of any page they visit through it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Penumbra Magic Postcards"
|
||||||
|
subtitle: "Paper postcards that play a video when you scan the QR"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.penumbraprint.com/shop/
|
||||||
|
source: "Web Curios"
|
||||||
|
topics: [gifts, general-delight]
|
||||||
|
tags: [print, ar, gift]
|
||||||
|
---
|
||||||
|
|
||||||
|
Postcards printed with an unobtrusive QR that links to a private hosted video or photo set, so the recipient gets the physical card *and* a thirty-second clip from the trip. The hybrid solves the long-running paradox of postcards — the format is wonderful, but a still photo of a beach has lost most of what made you want to send it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Magnified Sand"
|
||||||
|
subtitle: "One-person archive of sand from around the world, photographed under a microscope"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://magnifiedsand.com/
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [general-delight]
|
||||||
|
tags: [website, science, archive]
|
||||||
|
---
|
||||||
|
|
||||||
|
A labor-of-love site by a single collector who photographs sand samples from beaches all over the world at high magnification. What looks like a uniform tan blanket from a meter up turns out, in the microscope, to be a constantly varying mix of crushed quartz, pulverized shells, volcanic glass, and tiny fossils — every beach has a different signature, and the gallery makes that legible.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Notepad++ for Mac"
|
||||||
|
subtitle: "Native macOS port of the Windows-only text editor people refuse to give up"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://notepad-plus-plus-mac.org/
|
||||||
|
source: "r/macapps"
|
||||||
|
topics: [macos-apps]
|
||||||
|
tags: [editor, open-source, native]
|
||||||
|
---
|
||||||
|
|
||||||
|
A community-led, Apple-notarized port of Notepad++ written in Objective-C++ against the official source — not a Wine wrapper, not an Electron shell, an actual Universal Binary that runs natively on Apple Silicon. It exists for the population of switchers who never found a Mac editor with the same combination of zero-config, multi-document, regex-everywhere muscle memory.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Refine"
|
||||||
|
subtitle: "Local-first writing assistant that runs entirely on-device"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://refine.sh
|
||||||
|
source: "r/macapps"
|
||||||
|
topics: [macos-apps]
|
||||||
|
tags: [writing, privacy, local-ai]
|
||||||
|
---
|
||||||
|
|
||||||
|
A Grammarly-shaped writing assistant for the Mac that does grammar, tone, and style suggestions without sending a word over the wire — every model runs locally on Apple Silicon. Nine months of community feedback have sanded down the rough edges, and unlike most "local AI" apps it works in any text field system-wide, not just inside its own editor.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Roll: Disposable Camera"
|
||||||
|
subtitle: "Camera app that hides your photos for three days to a year"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://apps.apple.com/us/app/roll-disposable-camera/id6759272118
|
||||||
|
linkText: "Visit on App Store"
|
||||||
|
source: "Web Curios"
|
||||||
|
topics: [general-delight]
|
||||||
|
tags: [ios, photography, slow]
|
||||||
|
---
|
||||||
|
|
||||||
|
An iPhone camera that takes a photo and then refuses to show it to you — for three days, three weeks, three months, or up to a year, your choice. The trick of it is the gap: by the time the picture surfaces, your memory has dimmed enough that the image lands like a postcard from someone else, and the throwaway shots feel earned in a way the camera roll never quite does.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Skull (Card Game)"
|
||||||
|
subtitle: "Three roses, one skull, and the pile in the middle that everyone is lying about"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/Bluffing-Strategy-Players-Average-Playtime/dp/B00GYDLY8E
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [gifts, general-delight]
|
||||||
|
tags: [board-game, party, bluffing]
|
||||||
|
---
|
||||||
|
|
||||||
|
A bluffing game with the simplest possible rule sheet — each player gets three rose cards and one skull, plays a card face-down, and at any point can challenge to flip a chosen number of cards from the stacks without hitting a skull. It plays in twenty minutes, the table tension is immediate, and unlike most party games it works just as well with three players as with six.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Stillgram"
|
||||||
|
subtitle: "iPhone camera that erases the crowd around the landmark"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://stillgram.io/
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [tools, travel]
|
||||||
|
tags: [ios, photography, ai]
|
||||||
|
---
|
||||||
|
|
||||||
|
An iPhone camera app that runs on-device AI to remove other people from your travel photos in real time — point it at the Trevi Fountain or Shibuya Crossing, snap, and you get the landmark uncluttered. The Pro tier ($14.99) lets you tap to keep specific people in the frame, so it doubles as the "everyone except my kid in front of the Eiffel Tower" mode that no other app does well.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Tom's Keys"
|
||||||
|
subtitle: "Third-party car key fobs you program yourself for a fraction of dealer prices"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://tomskey.com/
|
||||||
|
source: "Recomendo"
|
||||||
|
topics: [tools]
|
||||||
|
tags: [auto, diy]
|
||||||
|
---
|
||||||
|
|
||||||
|
A small operation selling aftermarket key fobs for most US-market cars at roughly a quarter of what the dealer charges, with online instructions that walk you through the manufacturer's bizarre programming sequence (open the door six times, insert key twice, press both buttons, repeat). The dance is the dealer's fault, not Tom's — and the spare fob you've been putting off for two years suddenly costs $98 instead of $400.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "UGREEN Uno MagSafe Power Bank"
|
||||||
|
subtitle: "Tiny face on the screen reacts to your phone's battery level"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/dp/B0D228PXC7
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Tools and Toys"
|
||||||
|
topics: [tools, gifts]
|
||||||
|
tags: [charging, magsafe, delight]
|
||||||
|
---
|
||||||
|
|
||||||
|
A 5,000mAh MagSafe-compatible power bank with a small front-facing screen that shows a cartoon face whose expression changes with the iPhone's charge state — sleepy when low, beaming when topped up. It's a charger that earns a glance instead of being silently buried in a bag, and it's the kind of small humanizing touch you don't expect from a USB-C accessory.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "umoven Pop-Up Bifold Wallet"
|
||||||
|
subtitle: "Push the slider and the cards fan out for you"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://www.amazon.com/dp/B0CFV6FYGV
|
||||||
|
linkText: "Buy on Amazon"
|
||||||
|
source: "Tools and Toys"
|
||||||
|
topics: [tools, gifts]
|
||||||
|
tags: [edc, wallet, rfid]
|
||||||
|
---
|
||||||
|
|
||||||
|
An aluminum-framed wallet with a thumb slider on the spine that ejects the card stack in a fanned arc, so the card you reach for is the card you see. RFID-blocked, holds 8–10 cards plus folded bills under an external strap. It's the closest a wallet gets to feeling mechanical — there's a small satisfying *click* every time you use it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "Unruly Play"
|
||||||
|
subtitle: "Archive of 169 unconventional games for unconventional places"
|
||||||
|
date: 2026-05-03
|
||||||
|
link: https://unrulyplay.com
|
||||||
|
source: "Kottke.org"
|
||||||
|
topics: [general-delight]
|
||||||
|
tags: [games, archive, public-space]
|
||||||
|
---
|
||||||
|
|
||||||
|
A digital archive of 169 games, performances, and playful provocations designed to be staged in unexpected public locations — escalators, parking lots, hotel hallways, library stacks. Most are explained in a paragraph and a diagram and are free to use; the value is in the implicit argument that you can manufacture delight in pretty much any built environment if you bring the rules with you.
|
||||||
+248
-31
@@ -1,33 +1,136 @@
|
|||||||
---
|
---
|
||||||
|
import { getSiteStats } from '../lib/entries';
|
||||||
|
import SEO from '../components/SEO.astro';
|
||||||
|
import type { OgImage } from '../components/SEO.astro';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
wide?: boolean;
|
subheader?: string;
|
||||||
|
lastUpdated?: Date;
|
||||||
|
canonicalPath?: string;
|
||||||
|
canonicalOverride?: string;
|
||||||
|
ogType?: 'website' | 'article';
|
||||||
|
ogImage?: OgImage;
|
||||||
|
noindex?: boolean;
|
||||||
|
publishedTime?: Date;
|
||||||
|
modifiedTime?: Date;
|
||||||
|
articleTags?: string[];
|
||||||
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description = 'Unique — a blog about delightful things.', wide = false } = Astro.props;
|
const {
|
||||||
|
title,
|
||||||
|
description = 'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||||
|
subheader,
|
||||||
|
lastUpdated,
|
||||||
|
canonicalPath,
|
||||||
|
canonicalOverride,
|
||||||
|
ogType,
|
||||||
|
ogImage,
|
||||||
|
noindex,
|
||||||
|
publishedTime,
|
||||||
|
modifiedTime,
|
||||||
|
articleTags,
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
const siteName = 'Unique';
|
const siteName = 'Unique';
|
||||||
|
const SITE_FOUNDED = new Date(2026, 4, 3);
|
||||||
|
const stats = await getSiteStats();
|
||||||
|
|
||||||
|
const fmtLong = new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
const fmtShort = new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedDate = lastUpdated ?? stats.lastUpdated;
|
||||||
|
const year = new Date().getFullYear();
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<SEO
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
title={title}
|
||||||
<meta name="description" content={description} />
|
description={description}
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
canonicalPath={canonicalPath}
|
||||||
<title>{title === siteName ? siteName : `${title} — ${siteName}`}</title>
|
canonicalOverride={canonicalOverride}
|
||||||
|
ogType={ogType}
|
||||||
|
ogImage={ogImage}
|
||||||
|
noindex={noindex}
|
||||||
|
publishedTime={publishedTime}
|
||||||
|
modifiedTime={modifiedTime}
|
||||||
|
articleTags={articleTags}
|
||||||
|
jsonLd={jsonLd}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body class:list={[{ wide }]}>
|
<body>
|
||||||
<header>
|
<header class="masthead">
|
||||||
<a href="/" class="brand">{siteName}</a>
|
<div class="masthead-row">
|
||||||
<p class="tagline">A small log of delightful things.</p>
|
<div class="masthead-stats">
|
||||||
|
<span>{stats.total} entries</span>
|
||||||
|
<span>founded {fmtShort.format(SITE_FOUNDED)}</span>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="masthead-brand">{siteName}</a>
|
||||||
|
<nav class="masthead-nav" aria-label="Primary">
|
||||||
|
<a href="/about/">About</a>
|
||||||
|
<a href="/blog/">Blog</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{subheader && (
|
||||||
|
<div class="subheader">
|
||||||
|
<p class="subheader-line">{subheader}</p>
|
||||||
|
{updatedDate && (
|
||||||
|
<p class="subheader-date">
|
||||||
|
Updated <time datetime={updatedDate.toISOString()}>{fmtLong.format(updatedDate)}</time>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer class="site-footer">
|
||||||
<p>© {new Date().getFullYear()} Unique</p>
|
<div class="site-footer-cols">
|
||||||
|
<section>
|
||||||
|
<h4>Browse</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/grid/">Grid</a></li>
|
||||||
|
<li><a href="/sources/">Sources</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>Collections</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/grid/">Reviews ({stats.reviews})</a></li>
|
||||||
|
<li><a href="/find/">Finds ({stats.finds})</a></li>
|
||||||
|
<li><a href="/finds/">Lists ({stats.lists})</a></li>
|
||||||
|
<li><a href="/blog/">Posts ({stats.posts})</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>About</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about/">About</a></li>
|
||||||
|
<li><a href="/blog/">Blog</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="site-footer-legal">
|
||||||
|
<p>
|
||||||
|
© {year} Unique
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<a href="/legal/">Legal</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<style is:global>
|
<style is:global>
|
||||||
:root {
|
:root {
|
||||||
@@ -61,38 +164,152 @@ const siteName = 'Unique';
|
|||||||
body {
|
body {
|
||||||
max-width: var(--max);
|
max-width: var(--max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.25rem 4rem;
|
padding: 1.5rem 1.25rem 2rem;
|
||||||
}
|
}
|
||||||
body.wide { max-width: 76rem; }
|
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
header {
|
|
||||||
margin-bottom: 2.5rem;
|
/* Masthead */
|
||||||
|
.masthead {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
border-bottom: 1px solid var(--rule);
|
border-bottom: 1px solid var(--rule);
|
||||||
padding-bottom: 1.25rem;
|
|
||||||
}
|
}
|
||||||
header .brand {
|
.masthead-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.1rem 0;
|
||||||
|
}
|
||||||
|
.masthead-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
font-weight: 700;
|
font-size: 0.72rem;
|
||||||
font-size: 1.5rem;
|
text-transform: uppercase;
|
||||||
color: var(--fg);
|
letter-spacing: 0.08em;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
header .tagline {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
.masthead-brand {
|
||||||
|
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(2.4rem, 6vw, 4rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--fg);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.masthead-brand:hover { text-decoration: none; color: var(--accent); }
|
||||||
|
.masthead-nav {
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.masthead-nav a { color: var(--muted); }
|
||||||
|
.masthead-nav a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.subheader {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.85rem 0 1rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.subheader-line {
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.subheader-date {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.masthead-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.masthead-stats {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.masthead-brand { order: 1; }
|
||||||
|
.masthead-nav {
|
||||||
|
justify-self: center;
|
||||||
|
align-items: center;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main { min-height: 60vh; }
|
main { min-height: 60vh; }
|
||||||
footer {
|
|
||||||
|
/* Site footer */
|
||||||
|
.site-footer {
|
||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
padding-top: 1.25rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px solid var(--rule);
|
border-top: 1px solid var(--rule);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
footer p { margin: 0; }
|
.site-footer-cols {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.site-footer-cols section {
|
||||||
|
min-width: 7rem;
|
||||||
|
}
|
||||||
|
.site-footer ul { align-items: center; }
|
||||||
|
.site-footer h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.site-footer ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.site-footer ul a { color: var(--muted); }
|
||||||
|
.site-footer ul a:hover { color: var(--accent); }
|
||||||
|
.site-footer-legal {
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.site-footer-legal p { margin: 0; }
|
||||||
|
.site-footer-legal a { color: var(--muted); }
|
||||||
|
.site-footer-legal a:hover { color: var(--accent); }
|
||||||
|
.site-footer-legal span { margin: 0 0.4rem; }
|
||||||
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Amazon Associates tracking IDs, keyed by Amazon hostname.
|
||||||
|
// Add additional marketplaces here when applicable (each marketplace issues its own ID).
|
||||||
|
const AMAZON_TAGS: Record<string, string> = {
|
||||||
|
'amazon.com': 'unique.rzen.dev-20',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hostnames that always belong to the US marketplace (short links resolve there).
|
||||||
|
const AMAZON_US_SHORTLINKS = new Set(['amzn.to', 'a.co']);
|
||||||
|
|
||||||
|
function normalizeHost(host: string): string {
|
||||||
|
return host.toLowerCase().replace(/^www\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function amazonHostFor(host: string): string | null {
|
||||||
|
const h = normalizeHost(host);
|
||||||
|
if (AMAZON_US_SHORTLINKS.has(h)) return 'amazon.com';
|
||||||
|
if (h === 'smile.amazon.com') return 'amazon.com';
|
||||||
|
if (h === 'amazon.com' || h.endsWith('.amazon.com')) return 'amazon.com';
|
||||||
|
// Other regional marketplaces (amazon.co.uk, amazon.de, etc.). Only mark as
|
||||||
|
// affiliate when we have a tag configured for that marketplace.
|
||||||
|
const match = h.match(/^(?:[\w-]+\.)?(amazon\.(?:[a-z.]+))$/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmazonUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return amazonHostFor(new URL(url).hostname) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalLink {
|
||||||
|
href: string;
|
||||||
|
isAffiliate: boolean;
|
||||||
|
network?: 'amazon';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the link to render externally. If the URL points at an Amazon
|
||||||
|
* marketplace we have a tag for, append the tag and flag it as affiliate.
|
||||||
|
* Otherwise return the URL unchanged.
|
||||||
|
*/
|
||||||
|
export function resolveExternalLink(rawUrl: string): ExternalLink {
|
||||||
|
try {
|
||||||
|
const u = new URL(rawUrl);
|
||||||
|
const marketplace = amazonHostFor(u.hostname);
|
||||||
|
if (!marketplace) return { href: rawUrl, isAffiliate: false };
|
||||||
|
const tag = AMAZON_TAGS[marketplace];
|
||||||
|
if (!tag) return { href: rawUrl, isAffiliate: false, network: 'amazon' };
|
||||||
|
u.searchParams.set('tag', tag);
|
||||||
|
return { href: u.toString(), isAffiliate: true, network: 'amazon' };
|
||||||
|
} catch {
|
||||||
|
return { href: rawUrl, isAffiliate: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AFFILIATE_REL = 'sponsored noopener noreferrer';
|
||||||
|
export const AFFILIATE_DISCLOSURE =
|
||||||
|
'Affiliate link — we may earn a commission from qualifying Amazon purchases at no extra cost to you.';
|
||||||
@@ -50,3 +50,40 @@ export async function getAllEntries(): Promise<Entry[]> {
|
|||||||
|
|
||||||
return [...r, ...p, ...f].sort((a, b) => b.date.valueOf() - a.date.valueOf());
|
return [...r, ...p, ...f].sort((a, b) => b.date.valueOf() - a.date.valueOf());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SiteStats = {
|
||||||
|
total: number;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
reviews: number;
|
||||||
|
finds: number;
|
||||||
|
posts: number;
|
||||||
|
lists: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let statsCache: Promise<SiteStats> | null = null;
|
||||||
|
|
||||||
|
export function getSiteStats(): Promise<SiteStats> {
|
||||||
|
if (!statsCache) statsCache = computeSiteStats();
|
||||||
|
return statsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeSiteStats(): Promise<SiteStats> {
|
||||||
|
const entries = await getAllEntries();
|
||||||
|
const findItems = await getCollection('find');
|
||||||
|
const reviews = entries.filter((e) => e.type === 'review').length;
|
||||||
|
const finds = findItems.length;
|
||||||
|
const posts = entries.filter((e) => e.type === 'post').length;
|
||||||
|
const lists = entries.filter((e) => e.type === 'finds').length;
|
||||||
|
const allDates = [
|
||||||
|
...entries.map((e) => e.date),
|
||||||
|
...findItems.map((f) => f.data.date),
|
||||||
|
].sort((a, b) => b.valueOf() - a.valueOf());
|
||||||
|
return {
|
||||||
|
total: reviews + finds + posts + lists,
|
||||||
|
lastUpdated: allDates[0] ?? null,
|
||||||
|
reviews,
|
||||||
|
finds,
|
||||||
|
posts,
|
||||||
|
lists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
import { heroes } from './hero';
|
||||||
|
import type { OgImage } from '../components/SEO.astro';
|
||||||
|
|
||||||
|
export type OgFallback = 'reviews' | 'finds' | 'posts' | 'site';
|
||||||
|
|
||||||
|
const SITE_URL = 'https://unique.rzen.dev';
|
||||||
|
|
||||||
|
const FALLBACK_PATHS: Record<OgFallback, string> = {
|
||||||
|
reviews: '/og/reviews.png',
|
||||||
|
finds: '/og/finds.png',
|
||||||
|
posts: '/og/posts.png',
|
||||||
|
site: '/og/default.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_DIMS = { width: 1200, height: 630, type: 'image/png' };
|
||||||
|
|
||||||
|
export function buildOgImage(
|
||||||
|
heroId: string | undefined,
|
||||||
|
fallback: OgFallback,
|
||||||
|
alt: string
|
||||||
|
): OgImage {
|
||||||
|
const hero = heroId ? heroes[heroId] : undefined;
|
||||||
|
if (hero?.kind === 'image') {
|
||||||
|
const fmt = hero.src.format;
|
||||||
|
const mime = fmt
|
||||||
|
? `image/${fmt === 'jpg' ? 'jpeg' : fmt}`
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
url: new URL(hero.src.src, SITE_URL).href,
|
||||||
|
width: hero.src.width,
|
||||||
|
height: hero.src.height,
|
||||||
|
alt,
|
||||||
|
type: mime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: new URL(FALLBACK_PATHS[fallback], SITE_URL).href,
|
||||||
|
width: FALLBACK_DIMS.width,
|
||||||
|
height: FALLBACK_DIMS.height,
|
||||||
|
alt,
|
||||||
|
type: FALLBACK_DIMS.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteUrl(path: string): string {
|
||||||
|
return new URL(path, SITE_URL).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Crumb = { name: string; url: string };
|
||||||
|
|
||||||
|
export function buildBreadcrumbs(crumbs: Crumb[]): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: crumbs.map((c, i) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: i + 1,
|
||||||
|
name: c.name,
|
||||||
|
item: absoluteUrl(c.url),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListItem = { url: string; name: string };
|
||||||
|
|
||||||
|
export function buildItemList(
|
||||||
|
items: ListItem[],
|
||||||
|
name?: string
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
numberOfItems: items.length,
|
||||||
|
itemListElement: items.map((it, i) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: i + 1,
|
||||||
|
name: it.name,
|
||||||
|
url: absoluteUrl(it.url),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSON_AUTHOR = {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: 'rzen',
|
||||||
|
url: `${SITE_URL}/about/`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORGANIZATION = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
'@id': `${SITE_URL}/#org`,
|
||||||
|
name: 'Unique',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: `${SITE_URL}/favicon.svg`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEBSITE = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${SITE_URL}/#site`,
|
||||||
|
name: 'Unique',
|
||||||
|
url: SITE_URL,
|
||||||
|
description:
|
||||||
|
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||||
|
publisher: { '@id': `${SITE_URL}/#org` },
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOFTWARE_CATEGORIES = new Set([
|
||||||
|
'apps',
|
||||||
|
'app',
|
||||||
|
'software',
|
||||||
|
'macos-apps',
|
||||||
|
'macos',
|
||||||
|
'ios-apps',
|
||||||
|
'ios',
|
||||||
|
'tools',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function reviewedItemType(category: string): 'SoftwareApplication' | 'Product' {
|
||||||
|
return SOFTWARE_CATEGORIES.has(category.toLowerCase())
|
||||||
|
? 'SoftwareApplication'
|
||||||
|
: 'Product';
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
|
||||||
|
const reviews = (await getCollection('reviews')).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
const recent = reviews.slice(0, 6);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Not found"
|
||||||
|
description="That page doesn't exist on Unique."
|
||||||
|
subheader="That page doesn't exist."
|
||||||
|
noindex
|
||||||
|
>
|
||||||
|
<article>
|
||||||
|
<p>The page you were looking for isn't here. Maybe one of these will do:</p>
|
||||||
|
|
||||||
|
<h2>Recent reviews</h2>
|
||||||
|
<ul class="recent">
|
||||||
|
{recent.map((r) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/reviews/${r.id}/`}>{r.data.name}</a>
|
||||||
|
{r.data.subtitle && <span class="subtitle"> — {r.data.subtitle}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Browse</h2>
|
||||||
|
<ul class="nav">
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/grid/">Grid</a></li>
|
||||||
|
<li><a href="/sources/">Sources</a></li>
|
||||||
|
<li><a href="/blog/">Blog</a></li>
|
||||||
|
<li><a href="/about/">About</a></li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
article h2 {
|
||||||
|
margin: 2rem 0 0.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.recent, .nav {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.recent li, .nav li {
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.recent .subtitle { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../lib/seo';
|
||||||
|
|
||||||
|
const description =
|
||||||
|
'About Unique — a small, one-person blog cataloguing delightful, unusual, well-made things.';
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(undefined, 'site', 'About Unique');
|
||||||
|
|
||||||
|
const aboutPage: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'AboutPage',
|
||||||
|
'@id': absoluteUrl('/about/#about'),
|
||||||
|
name: 'About — Unique',
|
||||||
|
description,
|
||||||
|
url: absoluteUrl('/about/'),
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
about: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: 'rzen',
|
||||||
|
url: absoluteUrl('/about/'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'About', url: '/about/' },
|
||||||
|
]);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="About"
|
||||||
|
description={description}
|
||||||
|
subheader="A small log of delightful things, kept for our own amusement."
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={[aboutPage, breadcrumbs]}
|
||||||
|
>
|
||||||
|
<article>
|
||||||
|
<p>
|
||||||
|
<strong>Unique</strong> is a quiet little blog that collects the small,
|
||||||
|
well-made things that make ordinary days a bit better — the kind of
|
||||||
|
object, app, or tool you find yourself recommending to a friend without
|
||||||
|
thinking twice.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It's run by one person, updated when there's something worth sharing,
|
||||||
|
and indexed by topic so you can browse by interest. Nothing here is
|
||||||
|
sponsored; some outbound links are affiliate links, which costs you
|
||||||
|
nothing extra and helps keep the lights on.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Start with the <a href="/">home page</a>, scan the
|
||||||
|
<a href="/grid/">grid</a>, or peek at the
|
||||||
|
<a href="/sources/">sources</a> we draw from.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import EntryList from '../components/EntryList.astro';
|
||||||
|
import { getAllEntries } from '../lib/entries';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
buildItemList,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../lib/seo';
|
||||||
|
|
||||||
|
const posts = (await getAllEntries()).filter((e) => e.type === 'post');
|
||||||
|
const lastUpdated = posts[0]?.date;
|
||||||
|
|
||||||
|
const description = 'Longer-form notes from Unique — essays and meta about the project.';
|
||||||
|
const ogImage = buildOgImage(undefined, 'posts', 'Unique blog');
|
||||||
|
|
||||||
|
const itemList = buildItemList(
|
||||||
|
posts.map((p) => ({ url: p.href, name: p.primary })),
|
||||||
|
'Posts on Unique'
|
||||||
|
);
|
||||||
|
|
||||||
|
const blogSchema: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Blog',
|
||||||
|
'@id': absoluteUrl('/blog/#blog'),
|
||||||
|
name: 'Unique — Blog',
|
||||||
|
description,
|
||||||
|
url: absoluteUrl('/blog/'),
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Blog', url: '/blog/' },
|
||||||
|
]);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Blog"
|
||||||
|
description={description}
|
||||||
|
subheader="Longer notes, when something deserves more than a card."
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={[blogSchema, itemList, breadcrumbs]}
|
||||||
|
>
|
||||||
|
{posts.length > 0 ? (
|
||||||
|
<EntryList entries={posts} />
|
||||||
|
) : (
|
||||||
|
<p class="empty">Nothing yet — check back soon.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.empty {
|
||||||
|
margin: 3rem 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</BaseLayout>
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import EntryList from '../../components/EntryList.astro';
|
import EntryList from '../../components/EntryList.astro';
|
||||||
import { getAllEntries } from '../../lib/entries';
|
import { getAllEntries } from '../../lib/entries';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
buildItemList,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const entries = await getAllEntries();
|
const entries = await getAllEntries();
|
||||||
@@ -16,15 +22,49 @@ export async function getStaticPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { category, entries } = Astro.props;
|
const { category, entries } = Astro.props;
|
||||||
|
const lastUpdated = entries[0]?.date;
|
||||||
|
const count = entries.length;
|
||||||
|
const topNames = entries.slice(0, 3).map((e) => e.primary).join(', ');
|
||||||
|
const description =
|
||||||
|
topNames.length > 0
|
||||||
|
? `All ${count} entries in ${category}: ${topNames}${count > 3 ? '…' : '.'}`
|
||||||
|
: `All entries in ${category}.`;
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(undefined, 'site', `${category} on Unique`);
|
||||||
|
|
||||||
|
const itemList = buildItemList(
|
||||||
|
entries.map((e) => ({ url: e.href, name: e.primary })),
|
||||||
|
`${category} on Unique`
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectionPage: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
'@id': absoluteUrl(`/categories/${category}/#collection`),
|
||||||
|
name: `${category} — Unique`,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(`/categories/${category}/`),
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: category, url: `/categories/${category}/` },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`Category: ${category}`}>
|
<BaseLayout
|
||||||
<h1>Category: {category}</h1>
|
title={`${category} (${count})`}
|
||||||
|
description={description}
|
||||||
|
subheader={`Delightful ${category}.`}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||||
|
>
|
||||||
<EntryList entries={entries} />
|
<EntryList entries={entries} />
|
||||||
<p class="back"><a href="/">← back</a></p>
|
<p class="back"><a href="/">← back</a></p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h1 { margin-bottom: 1rem; }
|
|
||||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||||
</style>
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
import SourceBadge from '../../components/SourceBadge.astro';
|
import SourceBadge from '../../components/SourceBadge.astro';
|
||||||
import Backlinks from '../../components/Backlinks.astro';
|
import Backlinks from '../../components/Backlinks.astro';
|
||||||
import { findsReferencing } from '../../lib/backlinks';
|
import { findsReferencing } from '../../lib/backlinks';
|
||||||
|
import {
|
||||||
|
resolveExternalLink,
|
||||||
|
AFFILIATE_REL,
|
||||||
|
AFFILIATE_DISCLOSURE,
|
||||||
|
} from '../../lib/affiliate';
|
||||||
|
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const items = await getCollection('find');
|
const items = await getCollection('find');
|
||||||
@@ -25,11 +31,53 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
|
|
||||||
const linkLabel =
|
const linkLabel =
|
||||||
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
|
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
|
||||||
|
|
||||||
|
const external = resolveExternalLink(item.data.link);
|
||||||
|
|
||||||
|
const description =
|
||||||
|
item.data.description ?? item.data.subtitle ?? `A find: ${item.data.name}.`;
|
||||||
|
|
||||||
|
const canonicalOverride = item.data.promotedTo
|
||||||
|
? absoluteUrl(`/reviews/${item.data.promotedTo}/`)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(
|
||||||
|
item.data.promotedTo,
|
||||||
|
'finds',
|
||||||
|
`${item.data.name} preview image`
|
||||||
|
);
|
||||||
|
|
||||||
|
const productSchema: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Product',
|
||||||
|
'@id': absoluteUrl(`/find/${item.id}/#product`),
|
||||||
|
name: item.data.name,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(`/find/${item.id}/`),
|
||||||
|
image: ogImage.url,
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
url: item.data.link,
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Sources', url: '/sources/' },
|
||||||
|
{ name: item.data.name, url: `/find/${item.id}/` },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title={item.data.name}
|
title={item.data.name}
|
||||||
description={item.data.description ?? item.data.subtitle ?? item.data.name}
|
description={description}
|
||||||
|
ogType="article"
|
||||||
|
ogImage={ogImage}
|
||||||
|
canonicalOverride={canonicalOverride}
|
||||||
|
publishedTime={item.data.date}
|
||||||
|
articleTags={item.data.tags}
|
||||||
|
jsonLd={[productSchema, breadcrumbs]}
|
||||||
>
|
>
|
||||||
<article>
|
<article>
|
||||||
<p class="source-line"><SourceBadge source={item.data.source} /></p>
|
<p class="source-line"><SourceBadge source={item.data.source} /></p>
|
||||||
@@ -49,11 +97,20 @@ const linkLabel =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="external">
|
<p class="external">
|
||||||
<a href={item.data.link} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={external.href}
|
||||||
|
target="_blank"
|
||||||
|
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
||||||
|
>
|
||||||
{linkLabel} ↗
|
{linkLabel} ↗
|
||||||
</a>
|
</a>
|
||||||
|
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{external.isAffiliate && (
|
||||||
|
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{
|
{
|
||||||
item.data.promotedTo && (
|
item.data.promotedTo && (
|
||||||
<p class="promoted">
|
<p class="promoted">
|
||||||
@@ -112,6 +169,26 @@ const linkLabel =
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
.external a:hover { text-decoration: none; opacity: 0.9; }
|
.external a:hover { text-decoration: none; opacity: 0.9; }
|
||||||
|
.aff-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--rule);
|
||||||
|
border-radius: 999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.aff-disclosure {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.promoted {
|
.promoted {
|
||||||
margin: 1.5rem 0 0;
|
margin: 1.5rem 0 0;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { getAllFinds } from '../../lib/find-items';
|
||||||
|
|
||||||
|
const finds = await getAllFinds();
|
||||||
|
const lastUpdated = finds[0]?.data.date;
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Finds"
|
||||||
|
description={`All ${finds.length} captured finds on Unique.`}
|
||||||
|
subheader={`All ${finds.length} captured finds.`}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
noindex
|
||||||
|
>
|
||||||
|
<ul class="find-index">
|
||||||
|
{finds.map((f) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/find/${f.id}/`}>{f.data.name}</a>
|
||||||
|
<time datetime={f.data.date.toISOString()}>{fmt.format(f.data.date)}</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.find-index {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.find-index li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.find-index li:last-child { border-bottom: 0; }
|
||||||
|
.find-index time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</BaseLayout>
|
||||||
@@ -3,6 +3,13 @@ import { getCollection, render } from 'astro:content';
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import FindsGrid from '../../components/FindsGrid.astro';
|
import FindsGrid from '../../components/FindsGrid.astro';
|
||||||
import { resolveFinds } from '../../lib/find-items';
|
import { resolveFinds } from '../../lib/find-items';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
buildItemList,
|
||||||
|
PERSON_AUTHOR,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const finds = await getCollection('finds');
|
const finds = await getCollection('finds');
|
||||||
@@ -21,12 +28,48 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const description =
|
||||||
|
post.data.description ?? post.data.blurb ?? `A collection: ${post.data.title}.`;
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(
|
||||||
|
items[0]?.data.promotedTo,
|
||||||
|
'finds',
|
||||||
|
`${post.data.title} cover image`
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleSchema: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
'@id': absoluteUrl(`/finds/${post.id}/#article`),
|
||||||
|
headline: post.data.title,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(`/finds/${post.id}/`),
|
||||||
|
datePublished: post.data.date.toISOString(),
|
||||||
|
author: PERSON_AUTHOR,
|
||||||
|
image: ogImage.url,
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemList = buildItemList(
|
||||||
|
items.map((it) => ({ url: `/find/${it.id}/`, name: it.data.name })),
|
||||||
|
post.data.title
|
||||||
|
);
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: post.data.title, url: `/finds/${post.id}/` },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title={post.data.title}
|
title={post.data.title}
|
||||||
description={post.data.description ?? post.data.blurb}
|
description={description}
|
||||||
wide
|
ogType="article"
|
||||||
|
ogImage={ogImage}
|
||||||
|
publishedTime={post.data.date}
|
||||||
|
articleTags={post.data.tags}
|
||||||
|
jsonLd={[articleSchema, itemList, breadcrumbs]}
|
||||||
>
|
>
|
||||||
<article>
|
<article>
|
||||||
<header class="finds-header">
|
<header class="finds-header">
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
const lists = (await getCollection('finds')).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
const lastUpdated = lists[0]?.data.date;
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Lists"
|
||||||
|
description={`All ${lists.length} themed lists on Unique.`}
|
||||||
|
subheader={`All ${lists.length} themed lists.`}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
noindex
|
||||||
|
>
|
||||||
|
<ul class="lists-index">
|
||||||
|
{lists.map((l) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/finds/${l.id}/`}>{l.data.title}</a>
|
||||||
|
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lists-index {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.lists-index li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.lists-index li:last-child { border-bottom: 0; }
|
||||||
|
.lists-index time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</BaseLayout>
|
||||||
+11
-17
@@ -13,14 +13,17 @@ const tiles = reviews.map((review) => ({
|
|||||||
subtitle: review.data.subtitle,
|
subtitle: review.data.subtitle,
|
||||||
hero: heroes[review.id],
|
hero: heroes[review.id],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const lastUpdated = reviews[0]?.data.date;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Grid" wide>
|
<BaseLayout
|
||||||
<div class="intro">
|
title="Grid"
|
||||||
<h1>Grid view</h1>
|
description="Every review on Unique, tiled in a single grid."
|
||||||
<p><a href="/">← list view</a></p>
|
subheader="Every review as a tile."
|
||||||
</div>
|
lastUpdated={lastUpdated}
|
||||||
|
noindex
|
||||||
|
>
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{
|
{
|
||||||
tiles.map((tile) => (
|
tiles.map((tile) => (
|
||||||
@@ -34,7 +37,7 @@ const tiles = reviews.map((review) => ({
|
|||||||
height={tile.hero.src.height}
|
height={tile.hero.src.height}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
alt=""
|
alt={tile.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tile.hero?.kind === 'video' && (
|
{tile.hero?.kind === 'video' && (
|
||||||
@@ -46,6 +49,7 @@ const tiles = reviews.map((review) => ({
|
|||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
aria-label={tile.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
@@ -59,16 +63,6 @@ const tiles = reviews.map((review) => ({
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.intro {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.intro h1 { margin: 0; font-size: 1.5rem; }
|
|
||||||
.intro p { margin: 0; font-size: 0.9rem; color: var(--muted); }
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
column-count: 3;
|
column-count: 3;
|
||||||
column-gap: 1rem;
|
column-gap: 1rem;
|
||||||
|
|||||||
+388
-18
@@ -1,28 +1,398 @@
|
|||||||
---
|
---
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import EntryList from '../components/EntryList.astro';
|
import { heroes } from '../lib/hero';
|
||||||
import { getAllEntries } from '../lib/entries';
|
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
||||||
|
|
||||||
const entries = (await getAllEntries()).slice(0, 12);
|
const HERO_SLUG = 'alt-tab';
|
||||||
|
const INITIAL_GRID = 7;
|
||||||
|
const BATCH_SIZE = 7;
|
||||||
|
|
||||||
|
const reviews = (await getCollection('reviews')).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
const tiles = reviews.map((review) => ({
|
||||||
|
href: `/reviews/${review.id}/`,
|
||||||
|
id: review.id,
|
||||||
|
name: review.data.name,
|
||||||
|
subtitle: review.data.subtitle,
|
||||||
|
hero: heroes[review.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const heroIndex = tiles.findIndex((t) => t.href === `/reviews/${HERO_SLUG}/`);
|
||||||
|
const hero = heroIndex >= 0 ? tiles[heroIndex] : tiles[0];
|
||||||
|
const rest = tiles.filter((_, i) => i !== (heroIndex >= 0 ? heroIndex : 0));
|
||||||
|
|
||||||
|
const lastUpdated = reviews[0]?.data.date;
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(hero?.id, 'reviews', `${hero?.name ?? 'Unique'} hero image`);
|
||||||
|
const itemList = buildItemList(
|
||||||
|
tiles.slice(0, INITIAL_GRID).map((t) => ({ url: t.href, name: t.name })),
|
||||||
|
'Latest reviews'
|
||||||
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Unique">
|
<BaseLayout
|
||||||
<EntryList entries={entries} />
|
title="Unique"
|
||||||
<p class="view-toggle">
|
description="A small log of delightful, unique things — products, apps, phenomena, oddities."
|
||||||
<a href="/sources/">sources</a>
|
subheader="A small log of delightful things."
|
||||||
<span aria-hidden="true">·</span>
|
lastUpdated={lastUpdated}
|
||||||
<a href="/grid/">grid view →</a>
|
ogImage={ogImage}
|
||||||
</p>
|
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
|
||||||
|
>
|
||||||
|
{hero && (
|
||||||
|
<a href={hero.href} class="hero">
|
||||||
|
{hero.hero?.kind === 'image' && (
|
||||||
|
<img
|
||||||
|
class="hero-media"
|
||||||
|
src={hero.hero.src.src}
|
||||||
|
width={hero.hero.src.width}
|
||||||
|
height={hero.hero.src.height}
|
||||||
|
fetchpriority="high"
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
alt={hero.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hero.hero?.kind === 'video' && (
|
||||||
|
<video
|
||||||
|
class="hero-media"
|
||||||
|
src={hero.hero.src}
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
aria-label={hero.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div class="hero-text">
|
||||||
|
<span class="hero-eyebrow">Today's pick</span>
|
||||||
|
<h1 class="hero-name">{hero.name}</h1>
|
||||||
|
{hero.subtitle && <p class="hero-subtitle">{hero.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul class="grid" data-initial={INITIAL_GRID} data-batch={BATCH_SIZE}>
|
||||||
|
{
|
||||||
|
rest.map((tile, i) => (
|
||||||
|
<li class:list={[{ hidden: i >= INITIAL_GRID }]} data-index={i}>
|
||||||
|
<a href={tile.href} class:list={['tile', { 'text-only': !tile.hero }]}>
|
||||||
|
{tile.hero?.kind === 'image' && (
|
||||||
|
<img
|
||||||
|
class="media"
|
||||||
|
src={tile.hero.src.src}
|
||||||
|
width={tile.hero.src.width}
|
||||||
|
height={tile.hero.src.height}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt={tile.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tile.hero?.kind === 'video' && (
|
||||||
|
<video
|
||||||
|
class="media"
|
||||||
|
src={tile.hero.src}
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
aria-label={tile.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div class="text">
|
||||||
|
<span class="name">{tile.name}</span>
|
||||||
|
<span class="subtitle">{tile.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{rest.length > INITIAL_GRID && (
|
||||||
|
<div class="more-wrap">
|
||||||
|
<button id="load-more" class="load-more" type="button">
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const grid = document.querySelector<HTMLUListElement>('.grid');
|
||||||
|
const button = document.querySelector<HTMLButtonElement>('#load-more');
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
function easeInOutCubic(t: number): number {
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long, gentle programmatic scroll. Native smooth-scroll is too quick and
|
||||||
|
// jumps to a fixed target; we want to drift continuously as cards appear.
|
||||||
|
function softScrollBy(distance: number, duration: number): Promise<void> {
|
||||||
|
if (prefersReducedMotion || distance <= 0) {
|
||||||
|
window.scrollBy(0, distance);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startY = window.scrollY;
|
||||||
|
const start = performance.now();
|
||||||
|
function step(now: number) {
|
||||||
|
const t = Math.min(1, (now - start) / duration);
|
||||||
|
window.scrollTo(0, startY + distance * easeInOutCubic(t));
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid && button) {
|
||||||
|
const batch = Number(grid.dataset.batch ?? '7');
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
const hidden = grid.querySelectorAll<HTMLLIElement>('li.hidden');
|
||||||
|
const toReveal = Array.from(hidden).slice(0, batch);
|
||||||
|
|
||||||
|
// Reveal one at a time, with the page drifting smoothly to keep new
|
||||||
|
// cards in view as they slide in.
|
||||||
|
const REVEAL_DURATION = 520;
|
||||||
|
const GAP = 220;
|
||||||
|
const SCROLL_AMOUNT = 110;
|
||||||
|
const SCROLL_DURATION = REVEAL_DURATION + GAP;
|
||||||
|
|
||||||
|
for (let i = 0; i < toReveal.length; i++) {
|
||||||
|
const li = toReveal[i];
|
||||||
|
li.classList.remove('hidden');
|
||||||
|
li.classList.add('revealing');
|
||||||
|
li.addEventListener(
|
||||||
|
'animationend',
|
||||||
|
() => li.classList.remove('revealing'),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start a soft scroll alongside each card (except the first, so the
|
||||||
|
// user sees the initial reveal in place).
|
||||||
|
if (i > 0 && !prefersReducedMotion) {
|
||||||
|
softScrollBy(SCROLL_AMOUNT, SCROLL_DURATION);
|
||||||
|
}
|
||||||
|
await wait(GAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final settle: ensure the button (or the last card) is comfortably
|
||||||
|
// in view.
|
||||||
|
if (!prefersReducedMotion) {
|
||||||
|
const target = button.getBoundingClientRect();
|
||||||
|
const overflow = target.bottom - window.innerHeight + 32;
|
||||||
|
if (overflow > 0) await softScrollBy(overflow, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.querySelectorAll('li.hidden').length === 0) {
|
||||||
|
button.classList.add('fading');
|
||||||
|
button.addEventListener('transitionend', () => button.remove(), { once: true });
|
||||||
|
} else {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.view-toggle {
|
.hero {
|
||||||
margin-top: 1.75rem;
|
display: block;
|
||||||
text-align: right;
|
position: relative;
|
||||||
font-size: 0.85rem;
|
border-radius: 14px;
|
||||||
color: var(--muted);
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--bg);
|
||||||
|
color: inherit;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
.view-toggle a { color: var(--muted); }
|
.hero:hover {
|
||||||
.view-toggle a:hover { color: var(--accent); }
|
text-decoration: none;
|
||||||
.view-toggle span { margin: 0 0.4rem; color: var(--muted); }
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
|
||||||
|
box-shadow: 0 18px 48px -18px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
.hero:hover .hero-name { color: var(--accent); }
|
||||||
|
|
||||||
|
.hero-media {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 62vh;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--rule);
|
||||||
|
}
|
||||||
|
.hero-text {
|
||||||
|
padding: 1.6rem 1.75rem 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.hero-eyebrow {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hero-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.4rem);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
.hero-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: clamp(1rem, 1.4vw, 1.15rem);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
column-count: 3;
|
||||||
|
column-gap: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.grid { column-count: 2; }
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.grid { column-count: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > li {
|
||||||
|
break-inside: avoid;
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.grid > li.hidden { display: none; }
|
||||||
|
.grid > li.revealing {
|
||||||
|
animation: tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
@keyframes tile-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(22px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grid > li.revealing { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--bg);
|
||||||
|
color: inherit;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.tile:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
|
||||||
|
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.tile:hover .name { color: var(--accent); }
|
||||||
|
|
||||||
|
.media {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg);
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.text-only {
|
||||||
|
background: linear-gradient(155deg, var(--rule), transparent);
|
||||||
|
}
|
||||||
|
.tile.text-only .text {
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
}
|
||||||
|
.tile.text-only .name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.tile.text-only .subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 2rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.load-more {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.7rem 1.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.load-more:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.load-more.fading {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import EntryList from '../components/EntryList.astro';
|
||||||
|
import { getAllEntries } from '../lib/entries';
|
||||||
|
|
||||||
|
const allEntries = await getAllEntries();
|
||||||
|
const entries = allEntries.slice(0, 12);
|
||||||
|
const lastUpdated = allEntries[0]?.date;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="List view"
|
||||||
|
description="The latest entries on Unique, in a plain chronological list."
|
||||||
|
subheader="The latest, in plain text."
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
noindex
|
||||||
|
>
|
||||||
|
<EntryList entries={entries} />
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import { buildOgImage, buildBreadcrumbs } from '../lib/seo';
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(undefined, 'site', 'Unique legal page');
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Legal', url: '/legal/' },
|
||||||
|
]);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Legal"
|
||||||
|
description="Privacy, terms, affiliate disclosure, and trademark notes for Unique."
|
||||||
|
subheader="The legal mumbo jumbo, kept short and honest."
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={breadcrumbs}
|
||||||
|
>
|
||||||
|
<article>
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
<p>
|
||||||
|
Unique doesn't run analytics, ad pixels, or third-party trackers. The
|
||||||
|
site sets no cookies of its own. Your visit produces a normal web-server
|
||||||
|
access log entry (IP, user-agent, referrer, requested path) at our
|
||||||
|
hosting provider, kept only for the rolling window needed to investigate
|
||||||
|
abuse and outages, and never sold or shared.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Outbound links go to third-party sites that have their own privacy
|
||||||
|
practices; we have no control over those.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Affiliate disclosure</h2>
|
||||||
|
<p>
|
||||||
|
Some outbound links — most often to Amazon — are affiliate links. If you
|
||||||
|
buy something after clicking one, we may earn a small commission at no
|
||||||
|
additional cost to you. Affiliate links are marked with an
|
||||||
|
<strong>Affiliate</strong> badge and a one-line disclosure on each page
|
||||||
|
where they appear. Affiliate revenue does not influence what we cover or
|
||||||
|
how we cover it; nothing on this site is sponsored or paid placement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Trademarks & copyright</h2>
|
||||||
|
<p>
|
||||||
|
All product names, logos, screenshots, and brand marks belong to their
|
||||||
|
respective owners and appear here under fair use for editorial review
|
||||||
|
and commentary. If you're a rights-holder and would like an image
|
||||||
|
removed or attribution corrected, please get in touch.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Original writing on Unique is © {new Date().getFullYear()} Unique, all
|
||||||
|
rights reserved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Use of the site</h2>
|
||||||
|
<p>
|
||||||
|
Content is provided as-is for informational purposes; we make no
|
||||||
|
warranty that any product covered here will suit your particular
|
||||||
|
situation. Use your own judgement before buying or installing anything.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions, corrections, or takedown requests: reach out via the address
|
||||||
|
on the <a href="/about/">About</a> page.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
article h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
article h2:first-of-type { margin-top: 0; }
|
||||||
|
article p { margin: 0.7rem 0; }
|
||||||
|
</style>
|
||||||
|
</BaseLayout>
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
---
|
---
|
||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
PERSON_AUTHOR,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getCollection('posts');
|
const posts = await getCollection('posts');
|
||||||
@@ -18,9 +24,40 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const description = post.data.description ?? `Notes from Unique: ${post.data.title}.`;
|
||||||
|
const ogImage = buildOgImage(undefined, 'posts', `${post.data.title} cover image`);
|
||||||
|
|
||||||
|
const blogPosting: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
'@id': absoluteUrl(`/posts/${post.id}/#post`),
|
||||||
|
headline: post.data.title,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(`/posts/${post.id}/`),
|
||||||
|
datePublished: post.data.date.toISOString(),
|
||||||
|
author: PERSON_AUTHOR,
|
||||||
|
image: ogImage.url,
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
mainEntityOfPage: absoluteUrl(`/posts/${post.id}/`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Blog', url: '/blog/' },
|
||||||
|
{ name: post.data.title, url: `/posts/${post.id}/` },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={post.data.title} description={post.data.description}>
|
<BaseLayout
|
||||||
|
title={post.data.title}
|
||||||
|
description={description}
|
||||||
|
ogType="article"
|
||||||
|
ogImage={ogImage}
|
||||||
|
publishedTime={post.data.date}
|
||||||
|
articleTags={post.data.tags}
|
||||||
|
jsonLd={[blogPosting, breadcrumbs]}
|
||||||
|
>
|
||||||
<article>
|
<article>
|
||||||
<header class="post-header">
|
<header class="post-header">
|
||||||
<h1>{post.data.title}</h1>
|
<h1>{post.data.title}</h1>
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import SourceBadge from '../../components/SourceBadge.astro';
|
import SourceBadge from '../../components/SourceBadge.astro';
|
||||||
|
import {
|
||||||
|
resolveExternalLink,
|
||||||
|
AFFILIATE_REL,
|
||||||
|
AFFILIATE_DISCLOSURE,
|
||||||
|
} from '../../lib/affiliate';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
reviewedItemType,
|
||||||
|
PERSON_AUTHOR,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const reviews = await getCollection('reviews');
|
const reviews = await getCollection('reviews');
|
||||||
@@ -24,14 +36,53 @@ const linkLabel =
|
|||||||
review.data.link &&
|
review.data.link &&
|
||||||
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
|
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
|
||||||
|
|
||||||
|
const external = review.data.link ? resolveExternalLink(review.data.link) : undefined;
|
||||||
|
|
||||||
const fromFindItem = review.data.fromFind
|
const fromFindItem = review.data.fromFind
|
||||||
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
|
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const description =
|
||||||
|
review.data.description ?? review.data.subtitle ?? `A review of ${review.data.name}.`;
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(review.id, 'reviews', `${review.data.name} hero image`);
|
||||||
|
|
||||||
|
const itemReviewed: Record<string, unknown> = {
|
||||||
|
'@type': reviewedItemType(review.data.category),
|
||||||
|
name: review.data.name,
|
||||||
|
};
|
||||||
|
if (review.data.link) itemReviewed.url = review.data.link;
|
||||||
|
if (ogImage) itemReviewed.image = ogImage.url;
|
||||||
|
|
||||||
|
const reviewSchema: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Review',
|
||||||
|
'@id': absoluteUrl(`/reviews/${review.id}/#review`),
|
||||||
|
url: absoluteUrl(`/reviews/${review.id}/`),
|
||||||
|
name: review.data.name,
|
||||||
|
headline: review.data.subtitle || review.data.name,
|
||||||
|
description,
|
||||||
|
datePublished: review.data.date.toISOString(),
|
||||||
|
author: PERSON_AUTHOR,
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
itemReviewed,
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: review.data.category, url: `/categories/${review.data.category}/` },
|
||||||
|
{ name: review.data.name, url: `/reviews/${review.id}/` },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title={review.data.name}
|
title={review.data.name}
|
||||||
description={review.data.description ?? review.data.subtitle}
|
description={description}
|
||||||
|
ogType="article"
|
||||||
|
ogImage={ogImage}
|
||||||
|
publishedTime={review.data.date}
|
||||||
|
articleTags={review.data.tags}
|
||||||
|
jsonLd={[reviewSchema, breadcrumbs]}
|
||||||
>
|
>
|
||||||
<article>
|
<article>
|
||||||
{review.data.source && (
|
{review.data.source && (
|
||||||
@@ -50,16 +101,24 @@ const fromFindItem = review.data.fromFind
|
|||||||
{review.data.category}
|
{review.data.category}
|
||||||
</a>
|
</a>
|
||||||
{
|
{
|
||||||
review.data.link && (
|
external && (
|
||||||
<>
|
<>
|
||||||
<span class="dot">·</span>
|
<span class="dot">·</span>
|
||||||
<a href={review.data.link} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={external.href}
|
||||||
|
target="_blank"
|
||||||
|
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
||||||
|
>
|
||||||
{linkLabel} ↗
|
{linkLabel} ↗
|
||||||
</a>
|
</a>
|
||||||
|
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
{external?.isAffiliate && (
|
||||||
|
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{fromFindItem && (
|
{fromFindItem && (
|
||||||
@@ -120,6 +179,26 @@ const fromFindItem = review.data.fromFind
|
|||||||
.meta .dot { margin: 0 0.4rem; }
|
.meta .dot { margin: 0 0.4rem; }
|
||||||
.meta .category { color: var(--muted); }
|
.meta .category { color: var(--muted); }
|
||||||
.meta .category:hover { color: var(--accent); }
|
.meta .category:hover { color: var(--accent); }
|
||||||
|
.aff-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.45rem;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--rule);
|
||||||
|
border-radius: 999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.aff-disclosure {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.body :global(p) { margin: 0 0 1rem; }
|
.body :global(p) { margin: 0 0 1rem; }
|
||||||
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
||||||
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import rss from '@astrojs/rss';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
export async function GET(context) {
|
||||||
|
const [reviews, posts, finds] = await Promise.all([
|
||||||
|
getCollection('reviews'),
|
||||||
|
getCollection('posts'),
|
||||||
|
getCollection('finds'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...reviews.map((r) => ({
|
||||||
|
title: r.data.name,
|
||||||
|
description: r.data.description ?? r.data.subtitle ?? '',
|
||||||
|
pubDate: r.data.date,
|
||||||
|
link: `/reviews/${r.id}/`,
|
||||||
|
categories: [r.data.category, ...r.data.tags],
|
||||||
|
})),
|
||||||
|
...posts.map((p) => ({
|
||||||
|
title: p.data.title,
|
||||||
|
description: p.data.description ?? '',
|
||||||
|
pubDate: p.data.date,
|
||||||
|
link: `/posts/${p.id}/`,
|
||||||
|
categories: [p.data.category, ...p.data.tags],
|
||||||
|
})),
|
||||||
|
...finds.map((f) => ({
|
||||||
|
title: f.data.title,
|
||||||
|
description: f.data.description ?? f.data.blurb ?? '',
|
||||||
|
pubDate: f.data.date,
|
||||||
|
link: `/finds/${f.id}/`,
|
||||||
|
categories: [f.data.category, ...f.data.tags],
|
||||||
|
})),
|
||||||
|
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
|
||||||
|
|
||||||
|
return rss({
|
||||||
|
title: 'Unique',
|
||||||
|
description:
|
||||||
|
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||||
|
site: context.site,
|
||||||
|
items,
|
||||||
|
customData: '<language>en-us</language>',
|
||||||
|
});
|
||||||
|
}
|
||||||
+41
-12
@@ -2,6 +2,12 @@
|
|||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import sourcesData from '../../sources.json';
|
import sourcesData from '../../sources.json';
|
||||||
import { getAllFinds } from '../lib/find-items';
|
import { getAllFinds } from '../lib/find-items';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
buildItemList,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../lib/seo';
|
||||||
|
|
||||||
type Source = {
|
type Source = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,14 +46,43 @@ for (const f of allFinds) {
|
|||||||
arr.push(f);
|
arr.push(f);
|
||||||
findsBySource.set(f.data.source, arr);
|
findsBySource.set(f.data.source, arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastUpdated = allFinds.sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
)[0]?.data.date;
|
||||||
|
|
||||||
|
const description = `The ${sources.length} feeds, shops, and forums Unique draws daily picks from.`;
|
||||||
|
const ogImage = buildOgImage(undefined, 'site', 'Unique sources catalog');
|
||||||
|
|
||||||
|
const itemList = buildItemList(
|
||||||
|
sources.map((s) => ({ url: s.url, name: s.name })),
|
||||||
|
'Sources used by Unique'
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectionPage: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
'@id': absoluteUrl('/sources/#collection'),
|
||||||
|
name: 'Sources — Unique',
|
||||||
|
description,
|
||||||
|
url: absoluteUrl('/sources/'),
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'Sources', url: '/sources/' },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Sources" wide>
|
<BaseLayout
|
||||||
<div class="intro">
|
title="Sources"
|
||||||
<h1>Sources</h1>
|
description={description}
|
||||||
<p>Where the daily picks come from. {sources.length} feeds, shops, and forums.</p>
|
subheader={`Where the daily picks come from. ${sources.length} feeds, shops, and forums.`}
|
||||||
</div>
|
lastUpdated={lastUpdated}
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||||
|
>
|
||||||
<ul class="grid">
|
<ul class="grid">
|
||||||
{
|
{
|
||||||
sources.map((source) => {
|
sources.map((source) => {
|
||||||
@@ -93,12 +128,6 @@ for (const f of allFinds) {
|
|||||||
<p class="back"><a href="/">← back</a></p>
|
<p class="back"><a href="/">← back</a></p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.intro {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.intro h1 { margin: 0 0 0.25rem; font-size: 1.85rem; }
|
|
||||||
.intro p { margin: 0; color: var(--muted); }
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
column-count: 3;
|
column-count: 3;
|
||||||
column-gap: 1rem;
|
column-gap: 1rem;
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import EntryList from '../../components/EntryList.astro';
|
import EntryList from '../../components/EntryList.astro';
|
||||||
import { getAllEntries } from '../../lib/entries';
|
import { getAllEntries } from '../../lib/entries';
|
||||||
|
import {
|
||||||
|
buildOgImage,
|
||||||
|
buildBreadcrumbs,
|
||||||
|
buildItemList,
|
||||||
|
absoluteUrl,
|
||||||
|
} from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const entries = await getAllEntries();
|
const entries = await getAllEntries();
|
||||||
@@ -16,15 +22,49 @@ export async function getStaticPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { tag, entries } = Astro.props;
|
const { tag, entries } = Astro.props;
|
||||||
|
const lastUpdated = entries[0]?.date;
|
||||||
|
const count = entries.length;
|
||||||
|
const topNames = entries.slice(0, 3).map((e) => e.primary).join(', ');
|
||||||
|
const description =
|
||||||
|
topNames.length > 0
|
||||||
|
? `All ${count} entries tagged #${tag}: ${topNames}${count > 3 ? '…' : '.'}`
|
||||||
|
: `All entries tagged #${tag}.`;
|
||||||
|
|
||||||
|
const ogImage = buildOgImage(undefined, 'site', `#${tag} on Unique`);
|
||||||
|
|
||||||
|
const itemList = buildItemList(
|
||||||
|
entries.map((e) => ({ url: e.href, name: e.primary })),
|
||||||
|
`#${tag} on Unique`
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectionPage: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
'@id': absoluteUrl(`/tags/${tag}/#collection`),
|
||||||
|
name: `#${tag} — Unique`,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(`/tags/${tag}/`),
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: `#${tag}`, url: `/tags/${tag}/` },
|
||||||
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`Tag: ${tag}`}>
|
<BaseLayout
|
||||||
<h1>#{tag}</h1>
|
title={`#${tag} (${count})`}
|
||||||
|
description={description}
|
||||||
|
subheader={`Tagged: ${tag}.`}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
ogImage={ogImage}
|
||||||
|
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||||
|
>
|
||||||
<EntryList entries={entries} />
|
<EntryList entries={entries} />
|
||||||
<p class="back"><a href="/">← back</a></p>
|
<p class="back"><a href="/">← back</a></p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h1 { margin-bottom: 1rem; }
|
|
||||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||||
</style>
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user