site: media + colocation — chunk 1/4 (code, configs, docs)

Code, configs, README, CHANGELOG, sources/log files, .claude.
Subsequent chunks land the renamed media files.
This commit is contained in:
2026-05-04 17:54:14 -04:00
parent e873460465
commit 5dcd99361f
25 changed files with 1138 additions and 243 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"87abedef-67c7-43ce-984d-6586f084558f","pid":85715,"procStart":"Sun May 3 13:48:07 2026","acquiredAt":1777883275428}
+13
View File
@@ -58,6 +58,19 @@ For every survivor, write one file at `src/content/find/<slug>.mdx`:
If `link` for a Reddit-discovered item points at a v.redd.it / i.redd.it URL, fetch the post's selftext or comments to find the actual product URL before writing the file.
#### Supporting media
For every find, try to retrieve a single representative image from the linked page so the find has visual support if it later graduates to a review (or gets surfaced on a tile).
- **Where to look (in priority order):** `og:image` meta tag → JSON-LD `image` field → main product photo / hero image markup → first article image.
- **Where to save:** `src/assets/finds/<slug>/hero.<ext>`. Preserve the original extension (`.jpg`, `.png`, `.webp`, `.gif`). One file per find — `hero` is the canonical name.
- **What to skip:** images with marketing/sales overlay text (Amazon-style "BEST PRESS / NO GRIT" tags), tiny thumbnails (<400px on the long edge), generic site logos, paywalled CDN URLs that 403 to curl. If the only available shots are marketing collages, skip rather than save a noisy image — a missing hero is fine, a bad one isn't.
- **For Mac/iOS apps:** prefer the App Store hero or developer-site screenshot. For physical products: prefer a clean studio shot or lifestyle photo without overlay copy. For travel destinations: prefer a Wikimedia Commons / Wikipedia image (free-to-use only). For text-only finds (quotes, dad jokes, essays): no media expected — skip.
- **Fetch strategy:** `curl -sL --max-time 25 -A "Mozilla/5.0 (...Safari...)" "<image-url>" -o <path>`. WebFetch is for HTML, not binaries.
- **Don't register heroes in `src/lib/hero.ts` at this stage.** That's a review-time decision (see `build-review`). The asset just needs to exist on disk so it's there if/when the find graduates.
Log image-fetch failures (404, 403, marketing-only) as a one-line note in the capture-run section of `daily-finds.log.md` so we can revisit broken media-source patterns later — same way we log fetch issues for source pages.
### 4. Nominate new sources
Watch for new sources organically and via search. No cap on nominees per run — if a day's reading surfaces five plausible new sources, capture all five.
+4
View File
@@ -2,6 +2,10 @@
## May 2026
- Colocate every collection's articles with their media: each entry is now a folder `<slug>/index.mdx` with sibling media files. Applied to `reviews/`, `find/` (104 articles + 89 heroes auto-resolved by `src/lib/find-hero.ts` via `import.meta.glob('../content/find/*/hero.{...}')`), `finds/`, and `posts/`. Slug derivation handled by a shared `stripIndex` helper on each glob loader; URLs unchanged across the entire site. MDX paths shortened from `../../assets/reviews/<slug>/<file>` to `./<file>`. `src/assets/` removed entirely
- Media optimization pass: re-encode the 13 heaviest assets (h264 CRF 28, scale ≤1280px, audio stripped) and convert the two animated GIFs (cotypist, flux-markdown) to MP4 — `dist/` drops from 53 MB to 31 MB (~42% smaller); biggest single wins are `homerow/search.mp4` (6.7 MB → 1.6 MB), `antinote/links.mp4` (2.9 MB → 193 KB), `flux-markdown/demo.gif` (3.9 MB → 1.3 MB MP4). Originals preserved at `src/assets/reviews/<slug>/source/` and never bundled
- Tile videos on `/` and `/grid/` switched from `preload="metadata"` to `preload="none"` so below-the-fold demos don't fetch on page load
- `src/lib/hero.ts` and the cotypist/flux-markdown review MDX updated to render the new MP4 heroes as inline autoplay-loop muted `<video>` instead of `<img>`
- 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)
+50 -13
View File
@@ -35,7 +35,18 @@ npm run preview # serve the built site
## Adding a review
Create an MDX file under `src/content/reviews/<slug>.mdx`:
Each review is a folder with `index.mdx` plus its media. The folder name is the slug:
```
src/content/reviews/cotypist/
├── index.mdx # the article + frontmatter
├── hero.mp4 # web-ready variants live here, alongside the article
├── screenshot.png
└── source/ # heavy originals (archival; never bundled)
└── hero.gif
```
`index.mdx` frontmatter:
```mdx
---
@@ -52,21 +63,42 @@ description: "Optional <meta> override; defaults to subtitle."
Body in markdown / MDX. You can import and use components here.
```
The slug becomes the URL at `/reviews/<slug>/`.
The folder name becomes the URL at `/reviews/<slug>/`. Media is referenced relatively:
```mdx
![alt](./screenshot.png)
import demo from './hero.mp4';
<video src={demo} autoplay loop muted playsinline preload="metadata" />
```
If the review needs a hero on the home/grid pages, add it to `src/lib/hero.ts`.
## Media
Visuals for a review live at `src/assets/reviews/<slug>/`, mirroring the slug. Reference them from MDX with relative paths so Astro's image pipeline picks them up (responsive variants, lazy loading, modern formats):
Prefer real screenshots, GIFs, or short clips from the app's own site (downloaded locally — don't hotlink). Pair with a short italic caption that names what's being shown. Visuals should illustrate the virtue of the product, not decorate the page.
```mdx
![alt text](../../assets/reviews/<slug>/feature.png)
**Source vs published media.** Heavy originals live in `<slug>/source/`; web-ready variants live at `<slug>/<file>` and are what the site actually ships. Files inside `source/` aren't imported anywhere, so Astro doesn't bundle them into `dist/`. To add or replace a video:
```bash
# 1. drop the original into source/
mv ~/Downloads/demo.mp4 src/content/reviews/<slug>/source/
# 2. encode a smaller web variant alongside (h264 CRF 28, ≤1280px, no audio)
ffmpeg -i src/content/reviews/<slug>/source/demo.mp4 \
-c:v libx264 -preset slow -crf 28 \
-movflags +faststart -pix_fmt yuv420p \
-vf "scale='min(1280,iw)':-2,crop=trunc(iw/2)*2:trunc(ih/2)*2" \
-an src/content/reviews/<slug>/demo.mp4
# 3. reference ./demo.mp4 from index.mdx or src/lib/hero.ts
```
Prefer real screenshots, GIFs, or short clips from the app's own site (downloaded locally — don't hotlink). Pair with a short italic caption that names what's being shown. Visuals should illustrate the virtue of the product, not decorate the page.
For animated GIFs, use the same recipe with `crf 26` and source `.gif` — the resulting MP4 is typically 515× smaller.
## Adding a post (essay)
Create an MDX file under `src/content/posts/<slug>.mdx`:
Create a folder + `index.mdx` under `src/content/posts/<slug>/`:
```mdx
---
@@ -80,18 +112,23 @@ description: "Optional <meta> tag override."
Body in markdown / MDX.
```
The slug becomes the URL at `/posts/<slug>/`.
The folder name becomes the URL at `/posts/<slug>/`. Drop any media (images, screenshots) next to `index.mdx` and reference them as `./image.png`. Same convention applies to the `find/` and `finds/` collections — each entry is a folder with `index.mdx` and any sibling media.
## Project layout
```
src/
content.config.ts # collection schemas (reviews, posts)
assets/
reviews/<slug>/ # hero images and screenshots per review
content.config.ts # collection schemas (reviews, posts, find, finds)
content/
reviews/*.mdx # product reviews
posts/*.mdx # essays (empty for now)
reviews/<slug>/
index.mdx # article + frontmatter
<media files> # web-ready variants (hero, screenshots, demos)
source/ # archival originals (not bundled)
find/<slug>/
index.mdx
hero.{jpg,png,gif,webp} # auto-resolved by src/lib/find-hero.ts
finds/<slug>/index.mdx # editorial superposts (text-only today)
posts/<slug>/index.mdx # essays
lib/
entries.ts # getAllEntries() — unified feed across collections
components/
+10
View File
@@ -6,6 +6,16 @@ Newly nominated sources awaiting review before promotion to `sources.json`. Each
- **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.
### Full-sweep capture round (apple-tips, quotes, dad-jokes new topics)
- **Simon Willison — simonwillison.net** — https://simonwillison.net/ — surfaced via Kottke's link to the Talkie LLM post. Daily-cadence link blog focused on developer tools, LLMs, and small clever software. Strong overlap with the macos-apps and tools brief; technical but consistently surfaces underrated open-source projects. Probably WebFetch-friendly.
- **This Is Colossal** — https://www.thisiscolossal.com/ — surfaced as the originating credit on Kottke's Unruly Play post ("via thisiscolossal.com"). Long-running art and visual-culture publication; would expand the general-delight bucket toward artists / installations / oddities. WebFetch-friendly historically.
- **Daily Dad Jokes podcast / newsletter** — https://www.iheart.com/podcast/867-daily-dad-jokes-89110830/ — surfaced via WebSearch. Weekly roundup curated from r/dadjokes; might just duplicate our existing r/dadjokes feed but the editorial pruning could give a higher-signal alternative if the JSON is accessible.
- **Dad Says Jokes (Beehiiv)** — https://dadsaysjokes.beehiiv.com/ — surfaced via WebSearch. Newsletter dedicated to dad jokes; Beehiiv's RSS feed should be curl-friendly via the standard `/feed` pattern. Vet for actual originality vs. recycled Reddit content.
- **The Mac Power Users podcast (Relay FM)** — https://www.relay.fm/mpu — surfaced via WebSearch for apple-tips. Long-running Apple-platform podcast; show notes pages list the apps and tips discussed each week — those notes are the actual source-of-sources value, not the audio. Probably WebFetch-friendly.
- **TheMacU** — https://themacu.com/ — surfaced via WebSearch. Tutorial-heavy macOS site with weekly newsletter; seems tutorial-thicker than our taste demands but worth a single vetting visit. May overlap too heavily with OSXDaily and 9to5Mac.
- **Selkie Design** — https://selkie.design/ — surfaced as the Hour by Hour developer site. Single-developer / single-app site, not a feed source. **Skip nomination** — flagged here only to keep the trail.
### 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.
+160
View File
@@ -2,6 +2,104 @@
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 (full sweep, expanded sources)
Sources scanned: 61 total — 24 hits, ~17 empty/unproductive, 11 blocked/broken, 9 skipped (per sources.json `fetch: skip`).
Productive sources: r/macapps (15), MacStories (4), Tools and Toys (2), One Thing Well (5), Daring Fireball — Linked List (3), Cool Tools (3), Hacker News — Show HN (1), Recomendo (2), Brett Terpstra (5), The Eclectic Light Company (1), Dr. Drang — leancrew.com (1), OSXDaily (3), The Marginalian (2), Quote Investigator (2), A.Word.A.Day (1), Wikiquote (1), r/dadjokes (3), icanhazdadjoke (3), r/shortcuts (1), Uncommon Goods (4), Kikkerland (3), Cool Material (2), Wallpaper* Travel (3), Boutique Homes (2), AFAR (1), The Outbound Collective (1), Roadtrippers Magazine (1), Open Culture (1).
Unproductive but live: Six Colors (travel-tips post / Apple-finance posts only), Carryology (essays only — Bellroy collab already known), Werd.com (best-of listicles, no specific picks rising above the bar), A Continuous Lean (newsletter announcement), Put This On (eBay roundups), Standard & Strange Journal (Field Guides this week), Outlier (have material URLs but item descriptions too thin to write virtues), Snow Peak (homepage nav only — same SPA issue as last sweep), Things Magazine (link roundups), Messy Nessy Chic (vol. 771 too sparse to extract individual items), Kevin Kelly thetechnium (philosophical post on AI uncertainty, no products linked), 9to5Mac Guides (homepage exposed only category nav), Product Hunt (mostly B2B SaaS this run).
Blocked or broken this run: Atlas Obscura (initial curl returned content but place detail pages now hit Cloudflare; WebFetch on slugs returned 403; place titles harvested from index but bodies inaccessible — bar requires specific virtue, so skipped writing finds), This Is Why I'm Broke (curl returns Mustache `{{entry.title}}` placeholders — content still client-rendered), MoMA Design Store (1.2MB body, but only 1 product slug `/products/moma-e-gift-card` extractable — full grid is JS-rendered, same as previous run), Web Curios (`/all-curios/` returned only 36KB nav shell), Reader's Digest Jokes (WebFetch 403), TidBITS (WebFetch 403), Uncommon Goods (WebFetch on `/new` returned 5 items — usable, but the URL pattern `/product/<slug>` may not be canonical), Unusual Hotels of the World (still a 4KB SPA shell — `/blogs/journal` 404 last sweep too; mark `fetch: skip` next pruning).
Skipped (per sources.json): The Sweet Setup, Indie App Sunday (Mastodon), Gear Patrol, The Awesomer, Permanent Style, Heddels, Mr Porter Journal, Spotted by Locals, The Browser.
### Captured finds
- sentient-os — Sentient OS — source: r/macapps
- parall — Parall — source: r/macapps
- curflow — Curflow — source: r/macapps
- betteraudio — BetterAudio — source: r/macapps
- walld — WallD — source: r/macapps
- pingplace — PingPlace — source: r/macapps
- transcribex — TranscribeX — source: r/macapps
- rad-weather — R.A.D. Weather — source: r/macapps
- lattix — Lattix — source: r/macapps
- lapser-studio — Lapser Studio — source: r/macapps
- hidemydata — HideMyData — source: r/macapps
- resurf — Resurf — source: r/macapps
- melo — Melo — source: r/macapps
- pedometer-plus-plus — Pedometer++ — source: MacStories
- remodex — Remodex — source: MacStories
- hour-by-hour — Hour by Hour — source: MacStories
- apple-frames-4 — Apple Frames 4 — source: MacStories
- ooni-koda-2-max — Ooni Koda 2 Max — source: Tools and Toys
- marshall-emberton-iii — Marshall Emberton III — source: Tools and Toys
- linky — Linky — source: One Thing Well
- one-thing-menu-bar — One Thing — source: One Thing Well
- heatwave-git — Heatwave — source: One Thing Well
- nom-rss — nom — source: One Thing Well
- gum-shell — gum — source: One Thing Well
- smol-pub — Smol Pub — source: One Thing Well
- rec-league — Rec League — source: Daring Fireball — Linked List
- 1d-chess — 1D Chess — source: Daring Fireball — Linked List
- finalist — Finalist — source: Daring Fireball — Linked List
- annas-archive — Anna's Archive — source: Cool Tools
- circle-to-search — Circle to Search — source: Cool Tools
- talkie-llm — Talkie — source: Hacker News — Show HN
- sunmory-led-floor-lamp — Sunmory 69-inch LED Floor Lamp — source: Recomendo
- start-with-nothing — Start With Nothing — source: Recomendo
- superkey — Superkey — source: Brett Terpstra
- wooshy — Wooshy — source: Brett Terpstra
- paletro — Paletro — source: Brett Terpstra
- scrolla — Scrolla — source: Brett Terpstra
- leaderkey — LeaderKey — source: Brett Terpstra
- timelined — Timelined — source: Brett Terpstra
- finder-tags-one-to-one — Finder Tags as Categories — source: The Eclectic Light Company
- keyboard-maestro-launchers — Keyboard Maestro File Launchers — source: Dr. Drang — leancrew.com
- reduce-bright-effects-ios — Reduce Bright Effects (iOS 26) — source: OSXDaily
- memory-pressure-mac — Memory Pressure as a RAM Indicator — source: OSXDaily
- calendar-inbox-detected-events — Calendar Inbox for Detected Events — source: OSXDaily
- mary-oliver-fourth-sign — Mary Oliver — The Fourth Sign of the Zodiac — source: The Marginalian
- arendt-pariah-humanity — Hannah Arendt — Men in Dark Times — source: The Marginalian
- stendhal-gods-excuse — Stendhal — "God's only excuse is that he doesn't exist" — source: Quote Investigator
- wayne-dyer-change-look — Wayne Dyer — "Change the way you look at things" — source: Quote Investigator
- heller-the-enemy-quote — Joseph Heller — "The enemy is anybody who's going to get you killed" — source: A.Word.A.Day
- machiavelli-force-fraud — Machiavelli — "Force or fraud" — source: Wikiquote
- joke-pi-arthritis — The Pi Arthritis Joke — source: r/dadjokes
- joke-knock-yourself-out — The Self-Anesthesia Joke — source: r/dadjokes
- joke-paranormal-jeans — The Paranormal Jeans Joke — source: r/dadjokes
- joke-step-ladder — The Step Ladder Joke — source: icanhazdadjoke
- joke-velcro-rip-off — The Velcro Rip-Off Joke — source: icanhazdadjoke
- joke-beaver-tree — The Beaver and the Tree — source: icanhazdadjoke
- wayback-multi-search-shortcut — Wayback Multi-Archive Search — source: r/shortcuts
- spinning-decider-bookmark — Read or Rest Spinning Decider Bookmark — source: Uncommon Goods
- wearable-retro-mini-camera — Wearable Retro Mini Camera — source: Uncommon Goods
- handblown-glass-bird-suncatcher — Handblown Glass Bird Suncatcher — source: Uncommon Goods
- diy-hydraulic-cyborg-hand — DIY Wearable Hydraulic Cyborg Hand — source: Uncommon Goods
- kikkerland-crab-multi-tool — Crab Multi-Tool — source: Kikkerland
- kikkerland-pill-pod — Pill Pod — source: Kikkerland
- kikkerland-hedgehog-nail-brush — Hedgehog Nail Brush — source: Kikkerland
- lamy-studio-rollerball — LAMY Studio Rollerball — source: Cool Material
- kaweco-classic-sport — Kaweco Classic Sport Fountain Pen — source: Cool Material
- capella-kyoto — Capella Kyoto — source: Wallpaper* Travel
- hotel-villa-colette-cap-ferret — Hôtel Villa Colette, Cap Ferret — source: Wallpaper* Travel
- azuma-farm-koiwai — Azuma Farm Koiwai — source: Wallpaper* Travel
- casa-nano-tokyo — Casa Nano 2.0 — source: Boutique Homes
- honu-villa-joshua-tree — The Honu Villa, Joshua Tree — source: Boutique Homes
- atacama-superblooms — Atacama Desert Superblooms — source: AFAR
- diamond-fork-hot-springs — Diamond Fork (Fifth Water) Hot Springs — source: The Outbound Collective
- blue-whale-of-catoosa — Blue Whale of Catoosa — source: Roadtrippers Magazine
- why-some-homes-feel-good — Why Some Homes Feel Good (and Most Don't) — source: Open Culture
New source nominees: 6 this run (see candidate-sources.md) — Simon Willison's blog, This Is Colossal, Daily Dad Jokes podcast, Dad Says Jokes (Beehiiv), Mac Power Users (Relay FM), TheMacU.
### Source maintenance for next pruning pass
- **Atlas Obscura** regression: the previous sweep noted curl returned content; this sweep, the index curl still returns content (so we extracted place slugs/titles), but individual `/places/<slug>` pages now hit Cloudflare's "Attention Required!" challenge for both curl-with-Safari-UA and WebFetch. That means we can see *what's* recently added but not extract the lead paragraph that supplies the specific virtue. Either fetch via `places/recent.rss` if it exists, or fall back to writing finds only when the slug itself is unambiguous (rarely safe).
- **Uncommon Goods** product URL pattern: WebFetch returned items with `/product/<slug>` paths — verify this is canonical before next run, since the storefront may also use `/products/<slug>` (plural). Saved finds use the singular form Uncommon Goods returned.
- **MoMA Design Store**: same SPA issue as the previous sweep — 1.2MB curl body, only 1 product slug extractable. Worth one experiment with `/collections/new.json` (Shopify default) before giving up.
- **This Is Why I'm Broke**: still Mustache placeholders in the curl response — content is API-driven. Inspect their network traffic in a browser to identify the actual JSON endpoint, or mark `fetch: skip`.
- **Unusual Hotels of the World**: still a 4KB SPA shell (`<title>Uhotw</title>` is the entire content). Switching to `fetch: skip` is overdue.
- **TidBITS, Reader's Digest Jokes, Permanent Style, Heddels, Mr Porter, The Sweet Setup, Gear Patrol, The Awesomer, Spotted by Locals**: WebFetch 403s, persistent across runs. The pattern is consistent enough that adding them as `fetch: skip` would clean up the run output. Real fix is a headless-browser fetcher, separate workstream.
- **Web Curios `/all-curios/`**: this sweep returned 36KB nav-shell only. The newsletter content lives on Substack archives — point `fetchUrl` at the most recent issue's Substack URL or the RSS feed instead.
## 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`).
@@ -86,3 +184,65 @@ Posts created:
- 2026-05-03-backyard-small-upgrades: Small upgrades for the backyard (3 items)
- 2026-05-03-travel-as-attention: Travel as a kind of attention (5 items)
- 2026-05-03-manufactured-delight: Manufactured delight (4 items)
## 2026-05-03 — review: espro-p7-french-press
Graduated from find captured 2026-05-03, source: Tools and Toys.
## 2026-05-04 — media backfill
One-shot bulk backfill of supporting media for every existing find. Saved one image per find at `src/assets/finds/<slug>/hero.<ext>`.
Counts:
- Total finds processed: 104
- Skipped (text-only quotes/dad-jokes): 12
- Downloaded: 85
- Rejected (logo-only / icon-too-small): 2
- Fetch-failed: 5
Fetch-failed slugs (one-line reason):
- apple-frames-4 — iCloud shortcut URL has no usable og:image; MacStories OG resolves to generic site logo.
- smol-pub — minimalist text-only homepage, no images at all.
- start-with-nothing — source URL (herman.bearblog.dev/nothing-is-the-secret-to-structuring-your-work) returns 404; post may have moved or been retitled.
- sunmory-led-floor-lamp — Amazon ASIN `B0BTFG5YJK` returns 404 even via mobile UA; product likely delisted.
- unruly-play-archive — og:image points at `playindex.imagination.ooo` which fails DNS (NXDOMAIN); broken external CDN.
Rejected slugs (one-line reason):
- frogfind — site only exposes a 96x96 brand gif logo; not a hero image.
- paletro — only the 128x128 product icon was available; no screenshot or hero.
Notable patterns / source tuning notes:
- **Amazon product pages require iPhone Safari UA** — desktop UA (and even desktop Mac Safari UA) get redirected to a CAPTCHA stub page. Mobile UA returns the full product HTML with `m.media-amazon.com/images/I/<id>.jpg` references. Default to picking the 7-prefix (largest) variant; 3-prefix entries are 320px thumbnails and fail the 400px minimum.
- **Reddit JSON `.json` endpoint is reliable for recovering external product URLs.** Best strategy: walk `selftext` for non-reddit, non-linkedin links; prefer `.app/.io/.so/.dev/.studio/` plus `apps.apple.com/github.com`; fall back to the post's `preview.images[0].source.url` only if no external link is present.
- **GitHub `opengraph.githubassets.com` rate-limits the standard Mac Safari UA** but serves freely with no UA or with a unique cache-busting path segment. Worth considering a UA-less fallback retry on 429.
- **Many Mac/iOS app sites have only a 256x256 or smaller icon** as their largest visual asset (Wooshy, Scrolla, Paletro). For these, the GitHub repo's auto-generated `opengraph.githubassets.com/<sha>/owner/repo` provides a serviceable 1200x600 fallback.
- **Some og:image meta tags use compact markup with no whitespace between attributes** (`property="og:image"content="..."`). Regex extractors must use `[^>]*` (zero-or-more) rather than `[^>]+` (one-or-more) between attributes.
- **Wikimedia / Wikipedia articles are a great fallback for proper-noun finds** (places, brand-name products) when the canonical site 404s — used for blue-whale-of-catoosa, lamy-studio-rollerball, annas-archive, embassy-of-the-free-mind.
- **App Store og:image URLs use a deceptive path** (`/Placeholder.mill/1200x630wa.jpg`) but actually return real app screenshots. Don't reject by URL pattern — check the binary.
## 2026-05-04 — media backfill, retry pass
Re-attempt of the 7 finds that didn't get a hero in yesterday's bulk pass. Strategy per slug listed below.
- apple-frames-4 → downloaded src/assets/finds/apple-frames-4/hero.png (2048x1079, MacStories long-form article og:image at `cdn.macstories.net/sunday-12-apr-2026-...png`; the AppStories+ URL the previous pass tried was a different episode page with no hero). 395 KB.
- smol-pub → skipped: text-only by design (gemtext markup, "no JavaScript, ads, or tracking technology" — service intentionally has no images; homepage HTML contains zero `<img>` tags; `/about` 404s; `/manual` has none either).
- start-with-nothing → skipped: source bearblog post permanently 404s, Wayback has no snapshot, no ricomendo.com archive entry mentions it. Bear Blog posts have no og:image by default. No retrievable hero.
- sunmory-led-floor-lamp → downloaded src/assets/finds/sunmory-led-floor-lamp/hero.jpg (800x1084 JPEG served as `.webp` — renamed). Source: sunmory.com product page (`/products/led-torchiere-floor-lamp-with-remote`). The original Amazon ASIN B0BTFG5YJK still serves CAPTCHA stub even with iPhone Safari UA — fallback to brand site worked. 19 KB.
- unruly-play-archive → downloaded src/assets/finds/unruly-play-archive/hero.jpg (2000x1279). Source: thisiscolossal.com article hero (`/wp-content/uploads/2026/04/unruly.jpg`). The site's own og:image points at `playindex.imagination.ooo` which still NXDOMAINs; a press writeup with a real photograph of the archive UI made a better hero anyway. 335 KB.
- frogfind → skipped: confirmed by design only a 174x80 frog logo gif. The site is a 1KB HTML page (literally one form, one image, two `<small>` lines) — no screenshot to capture. Accepting as text-only.
- paletro → downloaded src/assets/finds/paletro/hero.jpg (1200x750, JPEG via `.png` URL — renamed). Source: Setapp's app page (`store.setapp.com/cdn-cgi/image/width=1200,quality=75,format=auto/app/465/screenshots/...`). The dev's own appmakes.io page only has the 128x128 icon; Setapp has full-size product screenshots through their CF image proxy. 42 KB.
Counts:
- Retried: 7
- Downloaded this pass: 4 (apple-frames-4, sunmory-led-floor-lamp, unruly-play-archive, paletro)
- Accepted as text-only / unrecoverable: 3 (smol-pub, start-with-nothing, frogfind)
- Disk added: 780 KB total
Notable patterns / SKILL.md update candidates:
- **Setapp screenshots CDN is a strong fallback for Mac apps that aren't on the App Store** (`store.setapp.com/cdn-cgi/image/width=1200,quality=75,format=auto/app/<id>/screenshots/<file>`). When a dev's own site only ships a 128x128 or 256x256 icon, Setapp's app page (`setapp.com/apps/<slug>`) often has 5+ proper screenshots accessible via their CF Images proxy. Worth slotting in *between* the dev-site icon check and the GitHub-OG fallback.
- **Press / blog write-ups are a viable hero source when a brand's own og:image is broken** — Colossal, MacStories, The Verge, etc. all serve large, well-cropped article heroes via standard og:image. For finds where the canonical site is dead-link-on-image, search for a recent press writeup of *the project itself* (not just the topic) and use its og:image. Worked for unruly-play-archive (Colossal) where the site's own CDN (`playindex.imagination.ooo`) still NXDOMAINs.
- **Some Shopify product sites serve JPEG bytes via `.webp` URLs** (sunmory.com — `Content-Type: image/jpeg`, magic bytes `FF D8 FF E0`, but URL extension is `.webp`). The fetcher should branch on `file` magic, not URL extension, when picking the saved extension.
- **Setapp's `.png` URLs likewise serve JPEG**. Same recommendation: trust `file` over the URL.
- **Amazon CAPTCHA is now triggered even by iPhone Safari UA** for some delisted ASINs (B0BTFG5YJK responded with the same `opfcaptcha.amazon.com` stub on mobile UA as on desktop). The mobile-UA workaround still works for *live* products but not for delisted ones — for delisted, fall straight through to brand-site / Wayback / press.
- **Bear Blog and Smol Pub are text-only platforms that systematically lack og:image**. Worth maintaining a small allowlist of "skip image search, mark text-only" hosts (`*.bearblog.dev`, `smol.pub`, `m15o.smol.pub`, etc.) so the fetcher doesn't waste a Wayback round trip.
- **Wayback Machine 404s are quick to detect**: the response is a full 152KB `Wayback Machine` HTML shell containing the literal string `404`. A grep for `<title>Wayback Machine</title>` plus `404` in body short-circuits the fetch.
+119
View File
@@ -2,10 +2,13 @@
"updated": "2026-05-03",
"topics": [
"macos-apps",
"apple-tips",
"tools",
"gifts",
"clothing",
"travel",
"quotes",
"dad-jokes",
"general-delight"
],
"sources": [
@@ -419,6 +422,122 @@
"fetch": "curl",
"fetchUrl": "https://kk.org/thetechnium/",
"notes": "Kelly's main site — the '68 Bits of Advice' / Excellent Advice posts often link out to delightful tools and books. Posts live at /thetechnium/; the homepage is a static about page."
},
{
"name": "TidBITS",
"url": "https://tidbits.com",
"type": "magazine",
"topics": ["apple-tips", "macos-apps"],
"cadence": "weekly",
"notes": "Adam Engst's long-running Apple-platform publication. Tip-rich, technically careful, never reckless — a steady source of considered advice."
},
{
"name": "The Eclectic Light Company",
"url": "https://eclecticlight.co",
"type": "blog",
"topics": ["apple-tips"],
"cadence": "daily",
"notes": "Howard Oakley's deep dives into macOS internals, system maintenance, and forgotten corners of the OS — endlessly delightful for anyone who likes pulling threads."
},
{
"name": "Brett Terpstra",
"url": "https://brettterpstra.com",
"type": "blog",
"topics": ["apple-tips", "macos-apps"],
"cadence": "weekly",
"notes": "Power-user Mac scripts, automation, and small utilities from the author of TextExpander/Marked — generous with workflow snippets and 'web excursions' link roundups."
},
{
"name": "Dr. Drang — leancrew.com",
"url": "https://leancrew.com",
"type": "blog",
"topics": ["apple-tips"],
"cadence": "weekly",
"notes": "And now it's all this — engineer's perspective on macOS automation, command-line tricks, and Shortcuts. Quiet authority."
},
{
"name": "9to5Mac — Guides",
"url": "https://9to5mac.com/guides/",
"type": "blog",
"topics": ["apple-tips"],
"cadence": "daily",
"notes": "Tip and how-to guides across iOS/macOS/iPadOS. Volume is high; scan for the discovery-flavored posts rather than the SEO-bait ones."
},
{
"name": "r/shortcuts",
"url": "https://www.reddit.com/r/shortcuts/",
"type": "subreddit",
"topics": ["apple-tips"],
"cadence": "daily",
"fetch": "curl",
"fetchUrl": "https://www.reddit.com/r/shortcuts/top/.json?t=week&limit=25",
"notes": "iOS/macOS Shortcuts community — sort by Top of the week to surface the cleverest automations. Reddit blocks WebFetch; use the JSON endpoint via curl with a UA header."
},
{
"name": "OSXDaily",
"url": "https://osxdaily.com",
"type": "blog",
"topics": ["apple-tips"],
"cadence": "daily",
"notes": "Long-running daily Mac/iOS tip site. Signal is mixed but it surfaces obscure things others miss — good for the occasional gem."
},
{
"name": "Quote Investigator",
"url": "https://quoteinvestigator.com",
"type": "blog",
"topics": ["quotes"],
"cadence": "weekly",
"notes": "Garson O'Toole's research-driven investigation of quote provenance — debunks misattributions and lands on the actual source. The gold standard."
},
{
"name": "Wikiquote",
"url": "https://en.wikiquote.org",
"type": "wiki",
"topics": ["quotes"],
"cadence": "as-needed",
"notes": "Wikipedia's sister project. Citation-disciplined and wide-ranging — the place to verify a half-remembered line before passing it on."
},
{
"name": "The Marginalian",
"url": "https://www.themarginalian.org",
"type": "blog",
"topics": ["quotes", "general-delight"],
"cadence": "weekly",
"notes": "Maria Popova's literary essays, dense with sourced quotations from books worth tracking down. Formerly Brain Pickings."
},
{
"name": "A.Word.A.Day",
"url": "https://wordsmith.org/awad/",
"type": "newsletter",
"topics": ["quotes"],
"cadence": "daily",
"notes": "Anu Garg's daily word arrives with a 'Thought for Today' quotation — a reliable trickle of well-chosen lines."
},
{
"name": "r/dadjokes",
"url": "https://www.reddit.com/r/dadjokes/",
"type": "subreddit",
"topics": ["dad-jokes"],
"cadence": "daily",
"fetch": "curl",
"fetchUrl": "https://www.reddit.com/r/dadjokes/top/.json?t=week&limit=25",
"notes": "Heavily moderated for original groaners. Sort by Top of the week to filter out the recycled ones."
},
{
"name": "icanhazdadjoke",
"url": "https://icanhazdadjoke.com",
"type": "api",
"topics": ["dad-jokes"],
"cadence": "daily",
"notes": "Curated dad-joke service with a clean JSON API (Accept: application/json). Quality is consistent; jokes do recycle, so dedupe by text."
},
{
"name": "Reader's Digest — Jokes",
"url": "https://www.rd.com/jokes/",
"type": "site",
"topics": ["dad-jokes"],
"cadence": "weekly",
"notes": "Listicle-format joke roundups; the dad-joke pages are reliable for fresh, family-friendly batches."
}
]
}
+1
View File
@@ -11,6 +11,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
---
+87 -14
View File
@@ -1,11 +1,14 @@
---
import { Image } from 'astro:assets';
import type { FindItem } from '../lib/find-items';
import { findHero } from '../lib/find-hero';
import SourceBadge from './SourceBadge.astro';
interface Props {
item: FindItem;
}
const { item } = Astro.props;
const hero = findHero(item.id);
function host(url: string) {
try {
@@ -16,36 +19,64 @@ function host(url: string) {
}
---
<a href={`/find/${item.id}/`} class="find-card">
<div class="head">
<span class="name">{item.data.name}</span>
<SourceBadge source={item.data.source} />
<article class:list={['find-card', { 'has-hero': !!hero }]}>
{import.meta.env.DEV && (
<label class="edit-checkbox" aria-label={`Select ${item.data.name}`}>
<input type="checkbox" data-find-checkbox data-slug={item.id} />
</label>
)}
{hero && (
<Image
class="media"
src={hero}
widths={[320, 480, 640]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 240px"
loading="lazy"
decoding="async"
alt={item.data.name}
/>
)}
<div class="content">
<div class="head">
<a href={`/find/${item.id}/`} class="name">{item.data.name}</a>
<SourceBadge source={item.data.source} />
</div>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="external">
<span class="ext-label">→</span>
<span class="host">{host(item.data.link)}</span>
</p>
</div>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="external">
<span class="ext-label">→</span>
<span class="host">{host(item.data.link)}</span>
</p>
</a>
</article>
<style>
.find-card {
display: block;
position: relative;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--bg);
padding: 0.95rem 1.05rem 1rem;
color: inherit;
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.find-card: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);
}
.find-card:hover .name { color: var(--accent); }
.media {
display: block;
width: 100%;
height: auto;
background: var(--card-fill);
border-bottom: 1px solid var(--rule);
}
.content {
padding: 0.95rem 1.05rem 1rem;
}
.head {
display: flex;
flex-direction: column;
@@ -58,8 +89,23 @@ function host(url: string) {
font-size: 1.05rem;
color: var(--fg);
letter-spacing: -0.01em;
text-decoration: none;
transition: color 0.18s ease;
}
.name:hover { text-decoration: none; }
/* Stretched click target — covers the whole card. */
.name::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
/* Anchors inside the card (e.g. the source badge) sit above the stretched target. */
.find-card :global(a:not(.name)) {
position: relative;
z-index: 1;
}
.subtitle {
margin: 0 0 0.6rem;
color: var(--muted);
@@ -77,4 +123,31 @@ function host(url: string) {
align-items: baseline;
}
.ext-label { color: var(--accent); font-family: ui-sans-serif, system-ui, sans-serif; }
.edit-checkbox {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 3;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--rule);
border-radius: 4px;
cursor: pointer;
}
.edit-checkbox input {
width: 1rem;
height: 1rem;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.find-card:has(.edit-checkbox input:checked) {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent);
}
</style>
-35
View File
@@ -1,35 +0,0 @@
---
import type { FindItem } from '../lib/find-items';
import FindCard from './FindCard.astro';
interface Props {
items: FindItem[];
}
const { items } = Astro.props;
---
<ul class="finds-grid">
{items.map((item) => (
<li>
<FindCard item={item} />
</li>
))}
</ul>
<style>
.finds-grid {
column-count: 3;
column-gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 880px) { .finds-grid { column-count: 2; } }
@media (max-width: 520px) { .finds-grid { column-count: 1; } }
.finds-grid > li {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
</style>
+29 -22
View File
@@ -42,10 +42,11 @@ const dataBatch = batch != null ? String(batch) : undefined;
source.parentElement?.insertBefore(masonry, source);
source.remove();
const sentinel = document.createElement('div');
sentinel.className = 'masonry-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
masonry.parentElement?.insertBefore(sentinel, masonry.nextSibling);
const loadMore = document.createElement('button');
loadMore.type = 'button';
loadMore.className = 'masonry-load-more';
loadMore.textContent = 'Load more';
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
let cols: HTMLDivElement[] = [];
let stash: HTMLElement[] = tiles.slice();
@@ -94,31 +95,23 @@ const dataBatch = batch != null ? String(batch) : undefined;
}
function rebuild() {
const visibleCount = placed.length;
stash = placed.concat(stash);
placed = [];
buildColumns();
placeBatch(stash.length, false);
placeBatch(visibleCount, false);
}
buildColumns();
placeBatch(Math.min(initial, stash.length), false);
if (progressive && stash.length > 0) {
let busy = false;
const io = new IntersectionObserver((entries) => {
if (busy || stash.length === 0) return;
if (!entries[0].isIntersecting) return;
busy = true;
loadMore.addEventListener('click', () => {
placeBatch(Math.min(batch, stash.length), true);
setTimeout(() => { busy = false; }, 120);
if (stash.length === 0) {
io.disconnect();
sentinel.remove();
}
}, { rootMargin: '600px 0px' });
io.observe(sentinel);
if (stash.length === 0) loadMore.remove();
});
} else {
sentinel.remove();
loadMore.remove();
}
let resizeTimer: number | undefined;
@@ -173,9 +166,23 @@ const dataBatch = batch != null ? String(batch) : undefined;
@media (prefers-reduced-motion: reduce) {
.masonry-tile.revealing { animation: none; }
}
.masonry-sentinel {
height: 1px;
margin-top: -1px;
pointer-events: none;
.masonry-load-more {
display: block;
margin: 1.5rem auto 0;
padding: 0.55rem 1.1rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
background: transparent;
border: 1px solid var(--rule);
border-radius: 999px;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease;
}
.masonry-load-more:hover {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
}
</style>
+23 -4
View File
@@ -1,8 +1,15 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const stripIndex = ({ entry }: { entry: string }) =>
entry.replace(/\/index\.mdx$/, '');
const reviews = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/reviews' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/reviews',
generateId: stripIndex,
}),
schema: z.object({
name: z.string(),
subtitle: z.string(),
@@ -18,7 +25,11 @@ const reviews = defineCollection({
});
const posts = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/posts',
generateId: stripIndex,
}),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
@@ -29,7 +40,11 @@ const posts = defineCollection({
});
const find = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/find' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/find',
generateId: stripIndex,
}),
schema: z.object({
name: z.string(),
subtitle: z.string().optional(),
@@ -45,7 +60,11 @@ const find = defineCollection({
});
const finds = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/finds' }),
loader: glob({
pattern: '**/index.mdx',
base: './src/content/finds',
generateId: stripIndex,
}),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
+16 -1
View File
@@ -1,12 +1,14 @@
---
import { getSiteStats } from '../lib/entries';
import SEO from '../components/SEO.astro';
import EditConfirm from '../components/EditConfirm.astro';
import type { OgImage } from '../components/SEO.astro';
interface Props {
title: string;
description?: string;
subheader?: string;
subheaderSubtitle?: string;
subheaderVariant?: 'tagline' | 'headline';
lastUpdated?: Date;
canonicalPath?: string;
@@ -24,6 +26,7 @@ const {
title,
description = 'A small log of delightful, unique things — products, apps, phenomena, oddities.',
subheader,
subheaderSubtitle,
subheaderVariant = 'tagline',
lastUpdated,
canonicalPath,
@@ -87,18 +90,21 @@ const year = new Date().getFullYear();
<header class="masthead">
<div class="masthead-row">
<div class="masthead-stats">
<span>{stats.total} entries</span>
<span><a href="/posts/2026-05-03-why-this-exists/">founded {fmtShort.format(SITE_FOUNDED)}</a></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>
<a href="/search/">Search</a>
</nav>
</div>
{subheader && (
<div class="subheader">
<p class={`subheader-line${subheaderVariant === 'headline' ? ' subheader-headline' : ''}`}>{subheader}</p>
{subheaderSubtitle && subheaderVariant === 'headline' && (
<p class="subheader-subtitle">{subheaderSubtitle}</p>
)}
{updatedDate && subheaderVariant === 'headline' && (
<p class="subheader-date">
<time datetime={updatedDate.toISOString()}>{fmtLong.format(updatedDate)}</time>
@@ -291,6 +297,14 @@ const year = new Date().getFullYear();
letter-spacing: -0.01em;
color: var(--fg);
}
.subheader-subtitle {
margin: 0.35rem 0 0;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-style: italic;
font-size: 1.05rem;
line-height: 1.4;
color: var(--muted);
}
.subheader-date {
margin: 0.25rem 0 0;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
@@ -403,5 +417,6 @@ const year = new Date().getFullYear();
}
hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; }
</style>
{import.meta.env.DEV && <EditConfirm />}
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import type { ImageMetadata } from 'astro';
const modules = import.meta.glob<{ default: ImageMetadata }>(
'../content/find/*/hero.{png,jpg,jpeg,gif,webp}',
{ eager: true }
);
const heroes = new Map<string, ImageMetadata>();
for (const [path, mod] of Object.entries(modules)) {
const match = path.match(/find\/([^/]+)\/hero\./);
if (match) heroes.set(match[1], mod.default);
}
export function findHero(slug: string): ImageMetadata | undefined {
return heroes.get(slug);
}
+19 -17
View File
@@ -1,21 +1,22 @@
import type { ImageMetadata } from 'astro';
import alcove from '../assets/reviews/alcove/hero.png';
import altTab from '../assets/reviews/alt-tab/hero.jpg';
import cotypist from '../assets/reviews/cotypist/hero.gif';
import fluxMarkdown from '../assets/reviews/flux-markdown/demo.gif';
import folderPeek from '../assets/reviews/folder-peek/screenshot.png';
import hyperkey from '../assets/reviews/hyperkey/hero.png';
import ice from '../assets/reviews/ice/ice-bar.png';
import macMouseFix from '../assets/reviews/mac-mouse-fix/studio-display.png';
import tot from '../assets/reviews/tot/seven-dots.png';
import alcove from '../content/reviews/alcove/hero.png';
import altTab from '../content/reviews/alt-tab/hero.jpg';
import esproP7 from '../content/reviews/espro-p7-french-press/hero.jpg';
import folderPeek from '../content/reviews/folder-peek/screenshot.png';
import hyperkey from '../content/reviews/hyperkey/hero.png';
import ice from '../content/reviews/ice/ice-bar.png';
import macMouseFix from '../content/reviews/mac-mouse-fix/studio-display.png';
import tot from '../content/reviews/tot/seven-dots.png';
import antinote from '../assets/reviews/antinote/swipe.mp4';
import dockdoor from '../assets/reviews/dockdoor/alttab.mp4';
import homerow from '../assets/reviews/homerow/overlay.mp4';
import monocle from '../assets/reviews/monocle/spotlight.mp4';
import shottr from '../assets/reviews/shottr/text-erase.mp4';
import typewhisper from '../assets/reviews/typewhisper/demo.mp4';
import antinote from '../content/reviews/antinote/swipe.mp4';
import cotypist from '../content/reviews/cotypist/hero.mp4';
import dockdoor from '../content/reviews/dockdoor/alttab.mp4';
import fluxMarkdown from '../content/reviews/flux-markdown/demo.mp4';
import homerow from '../content/reviews/homerow/overlay.mp4';
import monocle from '../content/reviews/monocle/spotlight.mp4';
import shottr from '../content/reviews/shottr/text-erase.mp4';
import typewhisper from '../content/reviews/typewhisper/demo.mp4';
export type Hero =
| { kind: 'image'; src: ImageMetadata }
@@ -24,15 +25,16 @@ export type Hero =
export const heroes: Record<string, Hero | undefined> = {
alcove: { kind: 'image', src: alcove },
'alt-tab': { kind: 'image', src: altTab },
cotypist: { kind: 'image', src: cotypist },
'flux-markdown': { kind: 'image', src: fluxMarkdown },
'espro-p7-french-press': { kind: 'image', src: esproP7 },
'folder-peek': { kind: 'image', src: folderPeek },
hyperkey: { kind: 'image', src: hyperkey },
ice: { kind: 'image', src: ice },
'mac-mouse-fix': { kind: 'image', src: macMouseFix },
tot: { kind: 'image', src: tot },
antinote: { kind: 'video', src: antinote },
cotypist: { kind: 'video', src: cotypist },
dockdoor: { kind: 'video', src: dockdoor },
'flux-markdown': { kind: 'video', src: fluxMarkdown },
homerow: { kind: 'video', src: homerow },
monocle: { kind: 'video', src: monocle },
shottr: { kind: 'video', src: shottr },
+47 -29
View File
@@ -1,9 +1,12 @@
---
import { getCollection, render } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import Backlinks from '../../components/Backlinks.astro';
import { findsReferencing } from '../../lib/backlinks';
import { findHero } from '../../lib/find-hero';
import EditAction from '../../components/EditAction.astro';
import {
resolveExternalLink,
AFFILIATE_REL,
@@ -22,12 +25,7 @@ export async function getStaticPaths() {
const { item } = Astro.props;
const { Content } = await render(item);
const featuringPosts = await findsReferencing(item.id);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const hero = findHero(item.id);
const linkLabel =
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
@@ -72,6 +70,10 @@ const breadcrumbs = buildBreadcrumbs([
<BaseLayout
title={item.data.name}
description={description}
subheader={item.data.name}
subheaderSubtitle={item.data.subtitle}
subheaderVariant="headline"
lastUpdated={item.data.date}
ogType="article"
ogImage={ogImage}
canonicalOverride={canonicalOverride}
@@ -80,18 +82,22 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[productSchema, breadcrumbs]}
>
<article>
<p class="source-line"><SourceBadge source={item.data.source} /></p>
<header class="find-header">
<h1>{item.data.name}</h1>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="meta">
<time datetime={item.data.date.toISOString()}>
{fmt.format(item.data.date)}
</time>
</p>
<p class="source-line"><SourceBadge source={item.data.source} /></p>
</header>
{hero && (
<Image
class="hero"
src={hero}
widths={[640, 960, 1280]}
sizes="(max-width: 720px) 92vw, 42rem"
loading="eager"
decoding="async"
alt={item.data.name}
/>
)}
<div class="body">
<Content />
</div>
@@ -120,6 +126,16 @@ const breadcrumbs = buildBreadcrumbs([
)
}
{import.meta.env.DEV && !item.data.promotedTo && (
<div class="edit-toolbar">
<EditAction
label="Graduate to review"
command={`/build-review ${item.id}`}
confirm={`This will draft src/content/reviews/${item.id}.mdx, expand the find body into a full review, propose a hero asset, link the find via promotedTo, and append a graduation note to daily-finds.log.md.`}
/>
</div>
)}
<Backlinks posts={featuringPosts} />
{
@@ -136,23 +152,20 @@ const breadcrumbs = buildBreadcrumbs([
</article>
<style>
.source-line { margin: 0 0 0.5rem; }
.find-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.35rem; }
.subtitle {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 1.1rem;
font-style: italic;
}
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
.source-line { margin: 0; }
.hero {
display: block;
width: 100%;
height: auto;
max-height: 28rem;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--card-fill);
margin: 0 0 1.5rem;
}
.body :global(p) { margin: 0 0 1rem; }
.external {
@@ -208,5 +221,10 @@ const breadcrumbs = buildBreadcrumbs([
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
.edit-toolbar {
margin: 2rem 0 0;
padding-top: 1rem;
border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--rule));
}
</style>
</BaseLayout>
+59 -30
View File
@@ -1,15 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import FindCard from '../../components/FindCard.astro';
import EditAction from '../../components/EditAction.astro';
import { getAllFinds } from '../../lib/find-items';
const INITIAL_GRID = 18;
const BATCH_SIZE = 18;
const finds = await getAllFinds();
const lastUpdated = finds[0]?.data.date;
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
---
<BaseLayout
@@ -19,35 +19,64 @@ const fmt = new Intl.DateTimeFormat('en-US', {
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>
<Masonry initial={INITIAL_GRID} batch={BATCH_SIZE}>
{finds.map((f) => <FindCard item={f} />)}
</Masonry>
{import.meta.env.DEV && (
<div class="edit-floating-toolbar" data-find-toolbar>
<span class="edit-toolbar-count">
<span data-find-selected-count>0</span> selected
</span>
<EditAction
action="finds-collection"
label="Make a finds collection"
confirm="This will draft a themed finds list (.mdx in src/content/finds/) from the selected items."
/>
</div>
)}
<script>
// Update the selected-count badge as boxes are toggled.
const counter = document.querySelector('[data-find-selected-count]');
if (counter) {
const refresh = () => {
const n = document.querySelectorAll('[data-find-checkbox]:checked').length;
counter.textContent = String(n);
};
document.addEventListener('change', (e) => {
const t = e.target as HTMLElement;
if (t.matches('[data-find-checkbox]')) refresh();
});
refresh();
}
</script>
<style>
.find-index {
list-style: none;
margin: 0;
padding: 0;
}
.find-index li {
.edit-floating-toolbar {
position: sticky;
bottom: 1rem;
margin-top: 2rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
align-items: center;
gap: 0.85rem;
justify-content: flex-end;
padding: 0.65rem 0.85rem;
background: var(--bg);
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
border-radius: 8px;
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.find-index li:last-child { border-bottom: 0; }
.find-index time {
.edit-toolbar-count {
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.edit-toolbar-count [data-find-selected-count] {
color: var(--fg);
font-weight: 700;
}
</style>
</BaseLayout>
+8 -26
View File
@@ -1,7 +1,8 @@
---
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FindsGrid from '../../components/FindsGrid.astro';
import Masonry from '../../components/Masonry.astro';
import FindCard from '../../components/FindCard.astro';
import { resolveFinds } from '../../lib/find-items';
import {
buildOgImage,
@@ -23,12 +24,6 @@ const { post } = Astro.props;
const { Content } = await render(post);
const items = await resolveFinds(post.data.items);
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const description =
post.data.description ?? post.data.blurb ?? `A collection: ${post.data.title}.`;
@@ -65,6 +60,9 @@ const breadcrumbs = buildBreadcrumbs([
<BaseLayout
title={post.data.title}
description={description}
subheader={post.data.title}
subheaderVariant="headline"
lastUpdated={post.data.date}
ogType="article"
ogImage={ogImage}
publishedTime={post.data.date}
@@ -72,20 +70,13 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[articleSchema, itemList, breadcrumbs]}
>
<article>
<header class="finds-header">
<h1>{post.data.title}</h1>
<p class="meta">
<time datetime={post.data.date.toISOString()}>
{fmt.format(post.data.date)}
</time>
</p>
</header>
<div class="body">
<Content />
</div>
<FindsGrid items={items} />
<Masonry>
{items.map((item) => <FindCard item={item} />)}
</Masonry>
{
post.data.tags.length > 0 && (
@@ -101,15 +92,6 @@ const breadcrumbs = buildBreadcrumbs([
</article>
<style>
.finds-header {
margin-bottom: 1.5rem;
}
h1 { margin: 0 0 0.35rem; font-size: 1.85rem; }
.meta {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
}
.body {
margin-bottom: 1.75rem;
max-width: 42rem;
+56 -22
View File
@@ -1,5 +1,6 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Masonry from '../../components/Masonry.astro';
import { getCollection } from 'astro:content';
const lists = (await getCollection('finds')).sort(
@@ -11,6 +12,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
---
@@ -21,35 +23,67 @@ const fmt = new Intl.DateTimeFormat('en-US', {
lastUpdated={lastUpdated}
noindex
>
<ul class="lists-index">
<Masonry>
{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>
<article>
<a href={`/finds/${l.id}/`} class="list-tile">
<p class="eyebrow">
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
<span class="dot" aria-hidden="true">·</span>
<span>{l.data.items.length} picks</span>
</p>
<h2 class="title">{l.data.title}</h2>
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
</a>
</article>
))}
</ul>
</Masonry>
<style>
.lists-index {
list-style: none;
margin: 0;
padding: 0;
.list-tile {
display: block;
padding: 1.25rem 1.35rem 1.4rem;
border-radius: 8px;
border: 2px solid var(--rule);
background: linear-gradient(155deg, var(--rule), transparent);
color: inherit;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.lists-index li {
.list-tile:hover {
text-decoration: none;
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
}
.list-tile:hover .title { color: var(--accent); }
.eyebrow {
margin: 0 0 0.5rem;
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 {
flex-wrap: wrap;
gap: 0.4rem;
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);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eyebrow .dot { color: var(--rule); }
.title {
margin: 0 0 0.5rem;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-weight: 700;
font-size: 1.35rem;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.blurb {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.45;
}
</style>
</BaseLayout>
+1 -1
View File
@@ -48,7 +48,7 @@ const lastUpdated = reviews[0]?.data.date;
loop
muted
playsinline
preload="metadata"
preload="none"
aria-label={tile.name}
/>
)}
+1 -1
View File
@@ -100,7 +100,7 @@ const itemList = buildItemList(
loop
muted
playsinline
preload="metadata"
preload="none"
aria-label={tile.name}
/>
)}
+26 -21
View File
@@ -2,6 +2,7 @@
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SourceBadge from '../../components/SourceBadge.astro';
import EditAction from '../../components/EditAction.astro';
import {
resolveExternalLink,
AFFILIATE_REL,
@@ -30,6 +31,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
const linkLabel =
@@ -78,6 +80,10 @@ const breadcrumbs = buildBreadcrumbs([
<BaseLayout
title={review.data.name}
description={description}
subheader={review.data.name}
subheaderSubtitle={review.data.subtitle}
subheaderVariant="headline"
lastUpdated={review.data.date}
ogType="article"
ogImage={ogImage}
publishedTime={review.data.date}
@@ -85,18 +91,11 @@ const breadcrumbs = buildBreadcrumbs([
jsonLd={[reviewSchema, breadcrumbs]}
>
<article>
{review.data.source && (
<p class="source-line"><SourceBadge source={review.data.source} /></p>
)}
<header class="review-header">
<h1>{review.data.name}</h1>
<p class="subtitle">{review.data.subtitle}</p>
{review.data.source && (
<p class="source-line"><SourceBadge source={review.data.source} /></p>
)}
<p class="meta">
<time datetime={review.data.date.toISOString()}>
{fmt.format(review.data.date)}
</time>
<span class="dot">·</span>
<a href={`/categories/${review.data.category}/`} class="category">
{review.data.category}
</a>
@@ -135,6 +134,16 @@ const breadcrumbs = buildBreadcrumbs([
<Content />
</div>
{import.meta.env.DEV && (
<div class="edit-toolbar">
<EditAction
label="Make today's pick"
command={`/set-pick ${review.id}`}
confirm={`This will set ${review.data.name} as the homepage hero (updates HERO_SLUG in src/pages/index.astro).`}
/>
</div>
)}
{
review.data.tags.length > 0 && (
<footer class="tags">
@@ -160,19 +169,10 @@ const breadcrumbs = buildBreadcrumbs([
}
.from-find time { color: var(--muted); }
.review-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
h1 { margin: 0 0 0.35rem; }
.subtitle {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 1.1rem;
font-style: italic;
margin-bottom: 1.5rem;
}
.meta {
margin: 0;
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
@@ -309,5 +309,10 @@ const breadcrumbs = buildBreadcrumbs([
.tag { font-size: 0.85rem; color: var(--muted); }
.tag:hover { color: var(--accent); }
.back { margin-top: 2rem; font-size: 0.9rem; }
.edit-toolbar {
margin: 2rem 0 0;
padding-top: 1rem;
border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--rule));
}
</style>
</BaseLayout>
+313
View File
@@ -0,0 +1,313 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
title="Search"
description="Search reviews, finds, lists, and posts across Unique."
subheader="Search"
noindex
>
<form class="search-form" role="search" autocomplete="off">
<input
id="q"
type="search"
name="q"
placeholder="Search reviews, finds, lists, posts…"
aria-label="Search"
autofocus
/>
</form>
<p id="status" class="status" aria-live="polite"></p>
<div id="results" class="masonry" role="list"></div>
<script>
const input = document.getElementById('q') as HTMLInputElement;
const status = document.getElementById('status') as HTMLParagraphElement;
const results = document.getElementById('results') as HTMLDivElement;
type Entry = {
type: 'review' | 'find' | 'list' | 'post';
url: string;
title: string;
subtitle: string;
date: string;
tags: string[];
blurb: string;
source?: string;
};
const typeLabels: Record<Entry['type'], string> = {
review: 'Review',
find: 'Find',
list: 'List',
post: 'Post',
};
let entries: Entry[] = [];
let ready = false;
let lastTiles: HTMLElement[] = [];
const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function score(entry: Entry, terms: string[]): number {
const title = entry.title.toLowerCase();
const subtitle = entry.subtitle.toLowerCase();
const blurb = entry.blurb.toLowerCase();
const tags = entry.tags.join(' ').toLowerCase();
const source = (entry.source ?? '').toLowerCase();
let total = 0;
for (const term of terms) {
let s = 0;
if (title.includes(term)) s += 10;
if (title.startsWith(term)) s += 5;
if (subtitle.includes(term)) s += 4;
if (tags.includes(term)) s += 3;
if (blurb.includes(term)) s += 2;
if (source.includes(term)) s += 2;
if (s === 0) return 0;
total += s;
}
return total;
}
function colCount(): number {
const w = window.innerWidth;
if (w <= 520) return 1;
if (w <= 880) return 2;
return 3;
}
function tileHtml(entry: Entry): string {
const date = fmt.format(new Date(entry.date));
const blurb = entry.blurb || entry.subtitle;
const blurbEl = blurb
? `<p class="r-blurb">${escapeHtml(blurb)}</p>`
: '';
const source = entry.source
? `<p class="r-source">via ${escapeHtml(entry.source)}</p>`
: '';
return `<a href="${entry.url}" class="r-tile" role="listitem">
<p class="r-eyebrow"><span class="r-type">${typeLabels[entry.type]}</span><span class="r-dot" aria-hidden="true">·</span><time datetime="${entry.date}">${date}</time></p>
<p class="r-title">${escapeHtml(entry.title)}</p>
${source}${blurbEl}
</a>`;
}
function layout(tiles: HTMLElement[]) {
results.replaceChildren();
const cols: HTMLDivElement[] = [];
const n = colCount();
for (let i = 0; i < n; i++) {
const c = document.createElement('div');
c.className = 'masonry-col';
results.appendChild(c);
cols.push(c);
}
for (const tile of tiles) {
let min = Infinity;
let idx = 0;
for (let i = 0; i < cols.length; i++) {
const h = cols[i].offsetHeight;
if (h < min) { min = h; idx = i; }
}
cols[idx].appendChild(tile);
}
}
function buildTiles(items: Entry[]): HTMLElement[] {
const wrap = document.createElement('div');
wrap.innerHTML = items.map(tileHtml).join('');
return Array.from(wrap.children) as HTMLElement[];
}
function render(query: string) {
const q = query.trim().toLowerCase();
if (!q) {
lastTiles = [];
results.replaceChildren();
status.textContent = ready
? `${entries.length} entries indexed.`
: 'Loading index…';
return;
}
if (!ready) {
status.textContent = 'Loading index…';
return;
}
const terms = q.split(/\s+/).filter(Boolean);
const matches: Array<{ entry: Entry; score: number }> = [];
for (const entry of entries) {
const s = score(entry, terms);
if (s > 0) matches.push({ entry, score: s });
}
matches.sort((a, b) => b.score - a.score || b.entry.date.localeCompare(a.entry.date));
const top = matches.slice(0, 80);
status.textContent = matches.length
? `${matches.length} match${matches.length === 1 ? '' : 'es'}`
: 'No matches.';
lastTiles = buildTiles(top.map((m) => m.entry));
layout(lastTiles);
}
function syncFromUrl() {
const params = new URLSearchParams(window.location.search);
const q = params.get('q') ?? '';
input.value = q;
render(q);
}
let urlTimer: number | undefined;
input.addEventListener('input', () => {
render(input.value);
if (urlTimer) window.clearTimeout(urlTimer);
urlTimer = window.setTimeout(() => {
const url = new URL(window.location.href);
if (input.value) url.searchParams.set('q', input.value);
else url.searchParams.delete('q');
window.history.replaceState(null, '', url.toString());
}, 300);
});
let resizeTimer: number | undefined;
let currentCols = colCount();
window.addEventListener('resize', () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const next = colCount();
if (next !== currentCols) {
currentCols = next;
if (lastTiles.length) layout(lastTiles);
}
}, 150);
});
fetch('/search.json')
.then((r) => r.json())
.then((data: { entries: Entry[] }) => {
entries = data.entries;
ready = true;
syncFromUrl();
})
.catch(() => {
status.textContent = 'Could not load search index.';
});
syncFromUrl();
</script>
<style>
.search-form {
margin: 0 0 1.25rem;
}
#q {
width: 100%;
padding: 0.7rem 0.9rem;
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
font-size: 1.05rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--rule);
border-radius: 8px;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
#q:focus {
border-color: color-mix(in srgb, var(--accent) 55%, var(--rule));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.status {
margin: 0 0 1rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.masonry {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.masonry :global(.masonry-col) {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.masonry :global(.r-tile) {
display: block;
padding: 1rem 1.1rem 1.1rem;
border-radius: 8px;
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;
}
.masonry :global(.r-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);
}
.masonry :global(.r-tile:hover .r-title) { color: var(--accent); }
.masonry :global(.r-eyebrow) {
margin: 0 0 0.4rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.masonry :global(.r-type) { color: var(--accent); }
.masonry :global(.r-dot) { color: var(--rule); }
.masonry :global(.r-title) {
margin: 0 0 0.35rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
color: var(--fg);
transition: color 0.18s ease;
}
.masonry :global(.r-source) {
margin: 0 0 0.45rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.masonry :global(.r-blurb) {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.4;
}
</style>
</BaseLayout>
+56
View File
@@ -0,0 +1,56 @@
import { getCollection } from 'astro:content';
export async function GET() {
const [reviews, finds, lists, posts] = await Promise.all([
getCollection('reviews'),
getCollection('find'),
getCollection('finds'),
getCollection('posts'),
]);
const entries = [
...reviews.map((r) => ({
type: 'review',
url: `/reviews/${r.id}/`,
title: r.data.name,
subtitle: r.data.subtitle ?? '',
date: r.data.date.toISOString(),
tags: r.data.tags ?? [],
blurb: r.data.description ?? '',
})),
...finds.map((f) => ({
type: 'find',
url: `/find/${f.id}/`,
title: f.data.name,
subtitle: f.data.subtitle ?? '',
date: f.data.date.toISOString(),
tags: f.data.tags ?? [],
blurb: f.data.description ?? '',
source: f.data.source,
})),
...lists.map((l) => ({
type: 'list',
url: `/finds/${l.id}/`,
title: l.data.title,
subtitle: l.data.blurb ?? '',
date: l.data.date.toISOString(),
tags: l.data.tags ?? [],
blurb: l.data.description ?? '',
})),
...posts.map((p) => ({
type: 'post',
url: `/posts/${p.id}/`,
title: p.data.title,
subtitle: '',
date: p.data.date.toISOString(),
tags: p.data.tags ?? [],
blurb: p.data.description ?? '',
})),
];
entries.sort((a, b) => b.date.localeCompare(a.date));
return new Response(JSON.stringify({ entries }), {
headers: { 'Content-Type': 'application/json' },
});
}
+23 -7
View File
@@ -20,16 +20,15 @@ type Source = {
notes: string;
};
const sources: Source[] = [...sourcesData.sources].sort((a, b) =>
a.name.localeCompare(b.name)
);
const topicLabels: Record<string, string> = {
'macos-apps': 'macOS apps',
'apple-tips': 'apple tips',
'tools': 'tools',
'gifts': 'gifts',
'clothing': 'clothing',
'travel': 'travel',
'quotes': 'quotes',
'dad-jokes': 'dad jokes',
'general-delight': 'general delight',
};
@@ -48,10 +47,27 @@ for (const f of allFinds) {
arr.push(f);
findsBySource.set(f.data.source, arr);
}
for (const arr of findsBySource.values()) {
arr.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
const lastUpdated = allFinds.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
)[0]?.data.date;
const sources: Source[] = [...sourcesData.sources].sort((a, b) => {
const af = findsBySource.get(a.name) ?? [];
const bf = findsBySource.get(b.name) ?? [];
const aLatest = af[0]?.data.date.valueOf() ?? -Infinity;
const bLatest = bf[0]?.data.date.valueOf() ?? -Infinity;
if (aLatest !== bLatest) return bLatest - aLatest;
if (af.length !== bf.length) return bf.length - af.length;
return a.name.localeCompare(b.name);
});
const lastUpdated = findsBySource.size
? new Date(
Math.max(
...[...findsBySource.values()].map((arr) => arr[0].data.date.valueOf())
)
)
: undefined;
const description = `The ${sources.length} feeds, shops, and forums Unique draws daily picks from.`;
const ogImage = buildOgImage(undefined, 'site', 'Unique sources catalog');