site: rename "finds" superpost collection to "bundles"
Disambiguates the one-letter `find` (item) vs `finds` (roundup) split that
kept causing confusion. Audience-facing now: `/bundles/<slug>/`, "Bundle"
labels in home/footer/search.
Internals: `getCollection('finds')` → `('bundles')`, `Entry.type 'finds'`
→ `'bundle'`, `OgFallback 'finds'` → `'bundles'`, `findListMosaic` →
`bundleMosaic`, `FindsPost` → `Bundle`, `findsReferencing` →
`bundlesReferencing`. Build script + generated mosaic dir follow
(`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`).
Skills `build-finds` → `build-bundle`, `cluster-finds` →
`cluster-bundles`. The `find` collection (individual items) is unchanged.
No URL redirects: low external-link surface area.
This commit is contained in:
@@ -1,39 +1,39 @@
|
|||||||
---
|
---
|
||||||
name: build-finds
|
name: build-bundle
|
||||||
description: Draft a 'finds' superpost (editorial roundup) for unique.rzen.dev. Selects find items from src/content/find/, groups them under a theme, and writes a new file at src/content/finds/. Use when the user runs /build-finds, asks to "draft today's finds post", "build a roundup", or "make a catch".
|
description: Draft a 'bundle' (themed editorial roundup of finds) for unique.rzen.dev. Selects find items from src/content/find/, groups them under a theme, and writes a new file at src/content/bundles/. Use when the user runs /build-bundle, asks to "draft today's bundle", "build a roundup", "make a bundle", or "make a catch".
|
||||||
disable-model-invocation: true
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# build-finds
|
# build-bundle
|
||||||
|
|
||||||
Author one editorial superpost (collection: `finds`) that references existing find items. Each superpost has a theme, a short blurb, and an `items` array listing find slugs. The grid layout is rendered by the page template — this skill only writes the MDX.
|
Author one editorial bundle (collection: `bundles`) that references existing find items. Each bundle has a theme, a short blurb, and an `items` array listing find slugs. The grid layout is rendered by the page template — this skill only writes the MDX.
|
||||||
|
|
||||||
Invoke from inside the `unique.rzen.dev` repo.
|
Invoke from inside the `unique.rzen.dev` repo.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- `src/content/find/*.mdx` — the available pool of finds.
|
- `src/content/find/*.mdx` — the available pool of finds.
|
||||||
- `src/content/finds/*.mdx` — existing superposts (used to detect already-referenced finds).
|
- `src/content/bundles/*.mdx` — existing bundles (used to detect already-bundled finds).
|
||||||
- Optional argument: a theme phrase (e.g. "single-virtue tools", "things-disguised-as-other-things").
|
- Optional argument: a theme phrase (e.g. "single-virtue tools", "things-disguised-as-other-things").
|
||||||
- Optional argument: an explicit list of find slugs the user wants in the post.
|
- Optional argument: an explicit list of find slugs the user wants in the bundle.
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
### 1. Determine candidate pool
|
### 1. Determine candidate pool
|
||||||
|
|
||||||
- **If the user provided explicit slugs (seed mode)** — usually the case when the user clicks the "Make a finds collection" button on `/find/`. Treat the slugs as a *seed*, not a final list.
|
- **If the user provided explicit slugs (seed mode)** — usually the case when the user clicks the "Bundle this selection" button on `/find/`. Treat the slugs as a *seed*, not a final list.
|
||||||
1. Read each seed find's frontmatter (`tags`, `topics`, `subtitle`, `description`) and body.
|
1. Read each seed find's frontmatter (`tags`, `topics`, `subtitle`, `description`) and body.
|
||||||
2. Infer the through-line — the smallest phrase that captures what these finds have in common (a virtue, a use, a feeling, a structural pattern). Lean specific. Good: *"single-virtue tools"*, *"things that pour clean"*, *"objects that exist for no reason except someone wanted them to"*, *"vintage-leaning everyday carry"*. Bad: *"useful things"*, *"cool stuff"*.
|
2. Infer the through-line — the smallest phrase that captures what these finds have in common (a virtue, a use, a feeling, a structural pattern). Lean specific. Good: *"single-virtue tools"*, *"things that pour clean"*, *"objects that exist for no reason except someone wanted them to"*, *"vintage-leaning everyday carry"*. Bad: *"useful things"*, *"cool stuff"*.
|
||||||
3. Compute the **unreferenced pool**: every find that does NOT appear in the `items[]` array of any existing `src/content/finds/*.mdx` superpost. The seed slugs may or may not be in this pool — they're the seed regardless.
|
3. Compute the **unbundled pool**: every find that does NOT appear in the `items[]` array of any existing `src/content/bundles/*.mdx` bundle. The seed slugs may or may not be in this pool — they're the seed regardless.
|
||||||
4. From the unreferenced pool, find every entry that fits the inferred theme. Use frontmatter signals (tags, topics, subtitle) and body content. Don't reach: three weakly-fitting items is worse than zero. If a candidate's fit feels strained, drop it.
|
4. From the unbundled pool, find every entry that fits the inferred theme. Use frontmatter signals (tags, topics, subtitle) and body content. Don't reach: three weakly-fitting items is worse than zero. If a candidate's fit feels strained, drop it.
|
||||||
5. Combine `seed + qualifying unreferenced matches`, deduped. Cap at **8 total** (per sizing notes below). If the combined list exceeds 8, drop the weakest fits — never drop a seed slug; if the seed itself is more than 8, ask the user which to drop.
|
5. Combine `seed + qualifying unbundled matches`, deduped. Cap at **8 total** (per sizing notes below). If the combined list exceeds 8, drop the weakest fits — never drop a seed slug; if the seed itself is more than 8, ask the user which to drop.
|
||||||
6. Produce one cluster (theme phrase + final items list). Continue to step 2 to confirm before writing.
|
6. Produce one cluster (theme phrase + final items list). Continue to step 2 to confirm before writing.
|
||||||
|
|
||||||
- **If the user provided a theme phrase only:** scan all finds, surface the 8–12 that best fit the theme.
|
- **If the user provided a theme phrase only:** scan all finds, surface the 8–12 that best fit the theme.
|
||||||
|
|
||||||
- **If neither:** scan recent (last ~14 days) finds that are not yet referenced by any existing superpost. From the unreferenced pool, propose 1–3 thematic clusters of 5–10 finds each, with a candidate theme phrase per cluster.
|
- **If neither:** scan recent (last ~14 days) finds that are not yet referenced by any existing bundle. From the unbundled pool, propose 1–3 thematic clusters of 5–10 finds each, with a candidate theme phrase per cluster.
|
||||||
|
|
||||||
Re-use is allowed — a find can appear in multiple superposts under different themes. But by default, prefer surfacing **unreferenced** finds first; only reach for re-uses when the theme genuinely calls for them.
|
Re-use is allowed — a find can appear in multiple bundles under different themes. But by default, prefer surfacing **unbundled** finds first; only reach for re-uses when the theme genuinely calls for them.
|
||||||
|
|
||||||
### 2. Confirm the cluster
|
### 2. Confirm the cluster
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Seed (your selection):
|
|||||||
- <slug>: <name>
|
- <slug>: <name>
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
Expansion (unreferenced finds matching the theme):
|
Expansion (unbundled finds matching the theme):
|
||||||
- <slug>: <name>
|
- <slug>: <name>
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ Cluster B — "<theme phrase>"
|
|||||||
|
|
||||||
Wait for the user to confirm, refine the theme phrase, or drop/swap items. Don't write anything until confirmed.
|
Wait for the user to confirm, refine the theme phrase, or drop/swap items. Don't write anything until confirmed.
|
||||||
|
|
||||||
### 3. Write the superpost
|
### 3. Write the bundle
|
||||||
|
|
||||||
File: `src/content/finds/<YYYY-MM-DD>-<theme-slug>.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max).
|
File: `src/content/bundles/<YYYY-MM-DD>-<theme-slug>/index.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max).
|
||||||
|
|
||||||
Frontmatter:
|
Frontmatter:
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ title: "<Theme phrased as a noun phrase, max ~60 chars>"
|
|||||||
date: YYYY-MM-DD
|
date: YYYY-MM-DD
|
||||||
tags: [daily-finds, <topic-tag-1>, <topic-tag-2>]
|
tags: [daily-finds, <topic-tag-1>, <topic-tag-2>]
|
||||||
description: "<One-line description for meta tags. Reuse the title if nothing better.>"
|
description: "<One-line description for meta tags. Reuse the title if nothing better.>"
|
||||||
blurb: "<One short editorial sentence; appears as the post's secondary line in the home feed.>"
|
blurb: "<One short editorial sentence; appears as the bundle's secondary line in the home feed.>"
|
||||||
items:
|
items:
|
||||||
- <slug>
|
- <slug>
|
||||||
- <slug>
|
- <slug>
|
||||||
@@ -99,15 +99,15 @@ Example body:
|
|||||||
|
|
||||||
### 4. Verify
|
### 4. Verify
|
||||||
|
|
||||||
- Confirm every slug in `items[]` exists at `src/content/find/<slug>.mdx`. If any is missing, abort and report — never reference a non-existent find.
|
- Confirm every slug in `items[]` exists at `src/content/find/<slug>/index.mdx`. If any is missing, abort and report — never reference a non-existent find.
|
||||||
- Run `npm run build` to confirm the new superpost compiles.
|
- Run `npm run build` to confirm the new bundle compiles.
|
||||||
|
|
||||||
### 5. Update the log
|
### 5. Update the log
|
||||||
|
|
||||||
Append to `daily-finds.log.md`:
|
Append to `daily-finds.log.md`:
|
||||||
|
|
||||||
```
|
```
|
||||||
## YYYY-MM-DD — superpost: <theme>
|
## YYYY-MM-DD — bundle: <theme>
|
||||||
|
|
||||||
Slug: <date>-<theme-slug>
|
Slug: <date>-<theme-slug>
|
||||||
Items referenced:
|
Items referenced:
|
||||||
@@ -118,7 +118,7 @@ Items referenced:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Default to 5 finds per superpost. Use 3 for tighter themes, up to 8 if the theme really supports it. Beyond 8 the grid stops being scannable.
|
- Default to 5 finds per bundle. Use 3 for tighter themes, up to 8 if the theme really supports it. Beyond 8 the grid stops being scannable.
|
||||||
- The body is for the audience, not the user. Never write "pick one", "candidates", "today's pick" — those are internal terms.
|
- The body is for the audience, not the user. Never write "pick one", "candidates", "today's pick" — those are internal terms.
|
||||||
- Never edit existing find files when building a superpost. The find body and metadata are stable; the superpost only references them.
|
- Never edit existing find files when building a bundle. The find body and metadata are stable; the bundle only references them.
|
||||||
- If a find that's referenced has been graduated to a review (its frontmatter has `promotedTo`), it can still appear in the superpost — the grid card simply links to the find page, which itself links forward to the review.
|
- If a find that's referenced has been graduated to a review (its frontmatter has `promotedTo`), it can still appear in the bundle — the grid card simply links to the find page, which itself links forward to the review.
|
||||||
@@ -72,14 +72,34 @@ Edit `src/content/find/<slug>.mdx` frontmatter: add `promotedTo: <review-slug>`.
|
|||||||
|
|
||||||
(Slug usually matches the find's slug, so `promotedTo` and the find's filename match.)
|
(Slug usually matches the find's slug, so `promotedTo` and the find's filename match.)
|
||||||
|
|
||||||
### 6. Verify
|
### 6. Register the hero in `src/lib/hero.ts` (REQUIRED if a hero asset exists)
|
||||||
|
|
||||||
|
This is the step that has bitten us repeatedly. **The home grid and category cards do NOT auto-discover review hero assets** — they only show a hero if the review's slug is a key in the `heroes` record exported by `src/lib/hero.ts`. Skipping this step ships a review whose grid card is empty.
|
||||||
|
|
||||||
|
For a review with a static image hero (`hero.png`, `hero.jpg`, `hero.webp`):
|
||||||
|
|
||||||
|
1. Add an `import` line near the top, alphabetically grouped with the other image imports. The variable name is the slug in camelCase. Example for slug `tablepro`:
|
||||||
|
```ts
|
||||||
|
import tablepro from '../content/reviews/tablepro/hero.png';
|
||||||
|
```
|
||||||
|
2. Add an entry to the `heroes` record, alphabetically by key:
|
||||||
|
```ts
|
||||||
|
tablepro: { kind: 'image', src: tablepro },
|
||||||
|
```
|
||||||
|
|
||||||
|
For a review with a video hero (`.mp4`), put the import in the second (video) import group and the record entry uses `kind: 'video'`. Match the existing pattern.
|
||||||
|
|
||||||
|
If the review legitimately has no hero asset (rare — e.g. text-only reviews like `snewpapers-newspaper-archive`, `sindre-sorhus-older-mac-apps`), explicitly note it in the log entry from step 8 ("no hero asset — text-only review") so the omission is intentional and traceable.
|
||||||
|
|
||||||
|
### 7. Verify
|
||||||
|
|
||||||
- Run `npm run build`. Confirm:
|
- Run `npm run build`. Confirm:
|
||||||
- `/reviews/<slug>/` exists, shows the source badge and "← First surfaced…" backlink to the find page.
|
- `/reviews/<slug>/` exists, shows the source badge and "← First surfaced…" backlink to the find page.
|
||||||
- `/find/<slug>/` shows the "→ Read the full review" callout pointing to the review.
|
- `/find/<slug>/` shows the "→ Read the full review" callout pointing to the review.
|
||||||
- Any superpost that references the find still renders fine; the find card on the grid remains.
|
- Any bundle that references the find still renders fine; the find card on the grid remains.
|
||||||
|
- Visit `/grid/` (or `/`) and confirm the new review's tile renders with its hero — not blank. **If the tile is empty, step 6 was skipped.**
|
||||||
|
|
||||||
### 7. Update the log
|
### 8. Update the log
|
||||||
|
|
||||||
Append to `daily-finds.log.md`:
|
Append to `daily-finds.log.md`:
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: cluster-finds
|
name: cluster-bundles
|
||||||
description: Batch-cluster every existing find item in src/content/find/ into 4-7 thematic 'finds' superposts. Use when the user wants to bring the find pool up to date all at once — ("re-cluster the finds", "build superposts for everything", "spread the new finds across themed posts"). For drafting one superpost from a single theme, use build-finds instead.
|
description: Batch-cluster every existing find item in src/content/find/ into 4-7 thematic bundles. Use when the user wants to bring the find pool up to date all at once — ("re-cluster the finds", "build bundles for everything", "spread the new finds across themed bundles"). For drafting one bundle from a single theme, use build-bundle instead.
|
||||||
disable-model-invocation: true
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# cluster-finds
|
# cluster-bundles
|
||||||
|
|
||||||
Bulk version of `build-finds`. Reads the entire `src/content/find/` pool, partitions it into themed clusters, and writes one `src/content/finds/<date>-<slug>.mdx` superpost per cluster. The audience-facing site treats `finds` as the primary discovery vehicle for individual `find` items, so the goal is *coverage*: by the end of the run, almost every find that deserves a home should have one.
|
Bulk version of `build-bundle`. Reads the entire `src/content/find/` pool, partitions it into themed clusters, and writes one `src/content/bundles/<date>-<slug>/index.mdx` bundle per cluster. The audience-facing site treats `bundles` as the primary discovery vehicle for individual `find` items, so the goal is *coverage*: by the end of the run, almost every find that deserves a home should have one.
|
||||||
|
|
||||||
Invoke from inside the `unique.rzen.dev` repo.
|
Invoke from inside the `unique.rzen.dev` repo.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- `src/content/find/*.mdx` — the full pool to cluster.
|
- `src/content/find/*.mdx` — the full pool to cluster.
|
||||||
- `src/content/finds/*.mdx` — already-written superposts. Their `items[]` lists are the prior clustering — read these to know which finds already have a home and what themes have been used.
|
- `src/content/bundles/*.mdx` — already-written bundles. Their `items[]` lists are the prior clustering — read these to know which finds already have a home and what themes have been used.
|
||||||
- Optional argument: a number of clusters to target (default: model's call, usually 4-7).
|
- Optional argument: a number of clusters to target (default: model's call, usually 4-7).
|
||||||
- Optional argument: a list of theme phrases to seed clustering around.
|
- Optional argument: a list of theme phrases to seed clustering around.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Invoke from inside the `unique.rzen.dev` repo.
|
|||||||
|
|
||||||
Read the frontmatter and body of every find. Note the `name`, `subtitle`, `topics`, `tags`, and the body's stated *virtue* (the specific thing that makes the item delightful). Tags and topics are hints, not the answer — the body usually tells you what cluster a find actually belongs to.
|
Read the frontmatter and body of every find. Note the `name`, `subtitle`, `topics`, `tags`, and the body's stated *virtue* (the specific thing that makes the item delightful). Tags and topics are hints, not the answer — the body usually tells you what cluster a find actually belongs to.
|
||||||
|
|
||||||
Also read every existing finds file's `items[]` array — these are pre-clustered and should not be re-clustered into a new redundant theme. They can still be *re-used* in a new cluster if the new theme genuinely calls for them (see overlap rules).
|
Also read every existing bundle's `items[]` array — these are pre-clustered and should not be re-clustered into a new redundant theme. They can still be *re-used* in a new cluster if the new theme genuinely calls for them (see overlap rules).
|
||||||
|
|
||||||
### 2. Partition into clusters
|
### 2. Partition into clusters
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@ Aim for 4-7 clusters. Each cluster should:
|
|||||||
|
|
||||||
- Hang off **one specific editorial angle**, phrasable in 4-8 words ("Pocket carry", "The morning kitchen ritual", "Travel as a kind of attention"). Avoid generic buckets ("Tools", "Apps") — those are tag pages.
|
- Hang off **one specific editorial angle**, phrasable in 4-8 words ("Pocket carry", "The morning kitchen ritual", "Travel as a kind of attention"). Avoid generic buckets ("Tools", "Apps") — those are tag pages.
|
||||||
- Hold **3-8 items**. Below 3 the grid feels thin; above 8 it stops being scannable.
|
- Hold **3-8 items**. Below 3 the grid feels thin; above 8 it stops being scannable.
|
||||||
- Be **distinct from existing finds posts** in angle, even if a couple of items repeat.
|
- Be **distinct from existing bundles** in angle, even if a couple of items repeat.
|
||||||
|
|
||||||
Coverage check: after partitioning, every find should appear in at least one cluster (existing or new). If a find genuinely fits no theme, leave it uncovered and report it — don't pad a cluster with a misfit.
|
Coverage check: after partitioning, every find should appear in at least one cluster (existing or new). If a find genuinely fits no theme, leave it uncovered and report it — don't pad a cluster with a misfit.
|
||||||
|
|
||||||
### 3. Overlap rules
|
### 3. Overlap rules
|
||||||
|
|
||||||
Re-use is allowed but rationed. Set a soft budget: at most ~20% of items across all new posts may be repeats from existing posts or from other new posts in this same run. Concretely, for a 25-item pool, that's ~5 reuses total. Track repeats as you draft.
|
Re-use is allowed but rationed. Set a soft budget: at most ~20% of items across all new bundles may be repeats from existing bundles or from other new bundles in this same run. Concretely, for a 25-item pool, that's ~5 reuses total. Track repeats as you draft.
|
||||||
|
|
||||||
When choosing which items to repeat: only repeat a find when the second theme genuinely illuminates a different facet of it. "It also kind of fits over here" is not enough.
|
When choosing which items to repeat: only repeat a find when the second theme genuinely illuminates a different facet of it. "It also kind of fits over here" is not enough.
|
||||||
|
|
||||||
@@ -52,15 +52,15 @@ Cluster A — "<theme>" (N items)
|
|||||||
Cluster B — "<theme>" (N items)
|
Cluster B — "<theme>" (N items)
|
||||||
...
|
...
|
||||||
|
|
||||||
Overlap with existing posts: <slug> appears in <existing-post> + <new cluster>; ...
|
Overlap with existing bundles: <slug> appears in <existing-bundle> + <new cluster>; ...
|
||||||
Uncovered finds: <list, or "none">
|
Uncovered finds: <list, or "none">
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait for the user to confirm, refine, swap, or merge clusters. Do not write files until they sign off.
|
Wait for the user to confirm, refine, swap, or merge clusters. Do not write files until they sign off.
|
||||||
|
|
||||||
### 5. Write each superpost
|
### 5. Write each bundle
|
||||||
|
|
||||||
Same file format as `build-finds`. For each cluster, write `src/content/finds/<YYYY-MM-DD>-<theme-slug>.mdx`:
|
Same file format as `build-bundle`. For each cluster, write `src/content/bundles/<YYYY-MM-DD>-<theme-slug>/index.mdx`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -75,19 +75,19 @@ items:
|
|||||||
- ...
|
- ...
|
||||||
---
|
---
|
||||||
|
|
||||||
> <One audience-facing sentence or two that names what links these finds. Vary the opening — don't reuse 'Today's catch' for every post in the run. Read like a magazine standfirst, not a checklist.>
|
> <One audience-facing sentence or two that names what links these finds. Vary the opening — don't reuse 'Today's catch' for every bundle in the run. Read like a magazine standfirst, not a checklist.>
|
||||||
```
|
```
|
||||||
|
|
||||||
Body rules (same as `build-finds`):
|
Body rules (same as `build-bundle`):
|
||||||
|
|
||||||
- 1-2 sentence intro paragraph in a blockquote. No headings, no list items, no numbered candidates — the grid handles the items.
|
- 1-2 sentence intro paragraph in a blockquote. No headings, no list items, no numbered candidates — the grid handles the items.
|
||||||
- Audience-facing language. Never write "today's pick", "candidates", "winners".
|
- Audience-facing language. Never write "today's pick", "candidates", "winners".
|
||||||
- Vary the opening across the batch. With 4-7 posts going out the same day, repeating "Today's catch" five times feels mechanical.
|
- Vary the opening across the batch. With 4-7 bundles going out the same day, repeating "Today's catch" five times feels mechanical.
|
||||||
|
|
||||||
### 6. Verify
|
### 6. Verify
|
||||||
|
|
||||||
- Every slug in every `items[]` must exist at `src/content/find/<slug>.mdx`. Confirm before declaring done.
|
- Every slug in every `items[]` must exist at `src/content/find/<slug>/index.mdx`. Confirm before declaring done.
|
||||||
- Run `npm run build`. If any superpost fails to compile, fix and re-run.
|
- Run `npm run build`. If any bundle fails to compile, fix and re-run.
|
||||||
|
|
||||||
### 7. Update the log
|
### 7. Update the log
|
||||||
|
|
||||||
@@ -96,11 +96,11 @@ Append to `daily-finds.log.md`:
|
|||||||
```
|
```
|
||||||
## YYYY-MM-DD — cluster run
|
## YYYY-MM-DD — cluster run
|
||||||
|
|
||||||
Wrote <N> superposts from a pool of <M> finds.
|
Wrote <N> bundles from a pool of <M> finds.
|
||||||
Reuses across posts: <count> (budget: ~20%).
|
Reuses across bundles: <count> (budget: ~20%).
|
||||||
Uncovered finds: <list, or "none">.
|
Uncovered finds: <list, or "none">.
|
||||||
|
|
||||||
Posts created:
|
Bundles created:
|
||||||
- <date>-<slug-A>: <title> (N items)
|
- <date>-<slug-A>: <title> (N items)
|
||||||
- <date>-<slug-B>: <title> (N items)
|
- <date>-<slug-B>: <title> (N items)
|
||||||
- ...
|
- ...
|
||||||
@@ -108,7 +108,7 @@ Posts created:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This skill complements, doesn't replace, `build-finds`. After a cluster run, `build-finds` is still the right tool for ad-hoc, single-theme posts on top of new captures.
|
- This skill complements, doesn't replace, `build-bundle`. After a cluster run, `build-bundle` is still the right tool for ad-hoc, single-theme bundles on top of new captures.
|
||||||
- If two themes you're considering have heavy overlap (≥50% same items), they're really one theme — merge them and pick the stronger framing.
|
- If two themes you're considering have heavy overlap (≥50% same items), they're really one theme — merge them and pick the stronger framing.
|
||||||
- Date all posts with the run date, not staggered — these are siblings written together, and the home feed will sort them naturally by `title`.
|
- Date all bundles with the run date, not staggered — these are siblings written together, and the home feed will sort them naturally by `title`.
|
||||||
- Don't edit existing finds posts when running this. If an existing post overlaps with a new cluster you're proposing, either keep the new cluster (overlap-tolerated) or drop it from the plan; do not retroactively rewrite history.
|
- Don't edit existing bundles when running this. If an existing bundle overlaps with a new cluster you're proposing, either keep the new cluster (overlap-tolerated) or drop it from the plan; do not retroactively rewrite history.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: daily-finds
|
name: daily-finds
|
||||||
description: Capture-only daily sweep for unique.rzen.dev. Scans every source in sources.json, writes one find file per qualifying item to src/content/find/, and nominates any new sources cited in the items it reads (plus optionally via WebSearch). Does NOT pick a winner, write a superpost, or address the user/audience. Picking happens via /build-finds and /build-review. Use when the user asks for "today's finds", "morning sweep", "capture", or runs /daily-finds.
|
description: Capture-only daily sweep for unique.rzen.dev. Scans every source in sources.json, writes one find file per qualifying item to src/content/find/, and nominates any new sources cited in the items it reads (plus optionally via WebSearch). Does NOT pick a winner, write a bundle, or address the user/audience. Picking happens via /build-bundle and /build-review. Use when the user asks for "today's finds", "morning sweep", "capture", or runs /daily-finds.
|
||||||
disable-model-invocation: true
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# daily-finds
|
# daily-finds
|
||||||
|
|
||||||
Pure capture: every item that passes the bar becomes a `find` file. No daily cap, no theme detection, no audience-facing post. Whether any of these finds appears in a `finds` superpost or graduates to a review is a separate editorial decision.
|
Pure capture: every item that passes the bar becomes a `find` file. No daily cap, no theme detection, no audience-facing post. Whether any of these finds appears in a `bundle` or graduates to a review is a separate editorial decision.
|
||||||
|
|
||||||
Invoke from inside the `unique.rzen.dev` repo.
|
Invoke from inside the `unique.rzen.dev` repo.
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ Short summary in chat:
|
|||||||
```
|
```
|
||||||
Captured <N> finds across <M> productive sources today.
|
Captured <N> finds across <M> productive sources today.
|
||||||
Blocked: <K> sources. New source nominees: <count>.
|
Blocked: <K> sources. New source nominees: <count>.
|
||||||
Run /build-finds to draft a superpost from the recent pool, or /build-review <slug> to graduate one to a review.
|
Run /build-bundle to draft a bundle from the recent pool, or /build-review <slug> to graduate one to a review.
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. No theme, no candidates list, no "pick a winner."
|
That's it. No theme, no candidates list, no "pick a winner."
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## May 2026
|
## May 2026
|
||||||
|
|
||||||
|
- Rename the "finds" superpost concept to **bundle**. The `finds` collection becomes `bundles` (`src/content/bundles/`), URLs move from `/finds/<slug>/` to `/bundles/<slug>/`, and the home/footer/search labels switch from "List" to "Bundle". Internal renames: `findListMosaic` → `bundleMosaic`, `FindsPost` → `Bundle`, `findsReferencing` → `bundlesReferencing`, `Entry.type 'finds'` → `'bundle'`, `OgFallback 'finds'` → `'bundles'`. Build script and generated mosaic dir follow (`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`). Skills `build-finds` and `cluster-finds` renamed to `build-bundle` and `cluster-bundles`. The `find` collection (individual items) is unchanged
|
||||||
- Add Cloudflare Web Analytics: privacy-preserving, cookieless beacon injected into `BaseLayout.astro` so per-page traffic, referrers, and Core Web Vitals show up in the Cloudflare dashboard scoped to `unique.rzen.dev`. Privacy statement on `/legal/` updated to disclose the beacon
|
- Add Cloudflare Web Analytics: privacy-preserving, cookieless beacon injected into `BaseLayout.astro` so per-page traffic, referrers, and Core Web Vitals show up in the Cloudflare dashboard scoped to `unique.rzen.dev`. Privacy statement on `/legal/` updated to disclose the beacon
|
||||||
- Strengthen the "Use of the site" section on `/legal/`: explicit "inclusion isn't endorsement" + no warranty on safety/quality/durability/fit/continued-existence + no responsibility for what happens after click-through. Tone kept plain, not legalese
|
- Strengthen the "Use of the site" section on `/legal/`: explicit "inclusion isn't endorsement" + no warranty on safety/quality/durability/fit/continued-existence + no responsibility for what happens after click-through. Tone kept plain, not legalese
|
||||||
- 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
|
- 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
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ Built with [Astro](https://astro.build) + MDX.
|
|||||||
|
|
||||||
## Key features
|
## Key features
|
||||||
|
|
||||||
- **Four-collection pipeline**: `find` (captured items, hidden from indexes), `finds` (editorial superposts that reference find slugs in a grid), `reviews` (full-length, sometimes graduated from a find), `posts` (generic essays)
|
- **Four-collection pipeline**: `find` (captured items, hidden from indexes), `bundles` (editorial roundups that reference find slugs in a grid), `reviews` (full-length, sometimes graduated from a find), `posts` (generic essays)
|
||||||
- **Daily capture skills** under `.claude/skills/`: `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item — capture-only, no editorial selection); `build-finds` (drafts a thematic superpost from the find pool); `build-review` (graduates a find to a full review, linking the two via `fromFind` / `promotedTo`)
|
- **Daily capture skills** under `.claude/skills/`: `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item — capture-only, no editorial selection); `build-bundle` (drafts a thematic bundle from the find pool); `cluster-bundles` (batch-clusters the entire find pool into multiple bundles); `build-review` (graduates a find to a full review, linking the two via `fromFind` / `promotedTo`)
|
||||||
- **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 bundles 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 bundle that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
|
||||||
- **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
|
- **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/`
|
- **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
|
- **Unified chronological feed** at `/index3/` across reviews + bundles + 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)
|
- **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
|
||||||
@@ -113,13 +113,13 @@ description: "Optional <meta> tag override."
|
|||||||
Body in markdown / MDX.
|
Body in markdown / MDX.
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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 `bundles/` collections — each entry is a folder with `index.mdx` and any sibling media.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
content.config.ts # collection schemas (reviews, posts, find, finds)
|
content.config.ts # collection schemas (reviews, posts, find, bundles)
|
||||||
content/
|
content/
|
||||||
reviews/<slug>/
|
reviews/<slug>/
|
||||||
index.mdx # article + frontmatter
|
index.mdx # article + frontmatter
|
||||||
@@ -128,7 +128,7 @@ src/
|
|||||||
find/<slug>/
|
find/<slug>/
|
||||||
index.mdx
|
index.mdx
|
||||||
hero.{jpg,png,gif,webp} # auto-resolved by src/lib/find-hero.ts
|
hero.{jpg,png,gif,webp} # auto-resolved by src/lib/find-hero.ts
|
||||||
finds/<slug>/index.mdx # editorial superposts (text-only today)
|
bundles/<slug>/index.mdx # editorial roundups (text intro + grid)
|
||||||
posts/<slug>/index.mdx # essays
|
posts/<slug>/index.mdx # essays
|
||||||
lib/
|
lib/
|
||||||
entries.ts # getAllEntries() — unified feed across collections
|
entries.ts # getAllEntries() — unified feed across collections
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:mosaics": "node scripts/build-list-mosaics.mjs",
|
"build:mosaics": "node scripts/build-bundle-mosaics.mjs",
|
||||||
"predev": "npm run build:mosaics",
|
"predev": "npm run build:mosaics",
|
||||||
"prestart": "npm run build:mosaics",
|
"prestart": "npm run build:mosaics",
|
||||||
"prebuild": "npm run build:mosaics",
|
"prebuild": "npm run build:mosaics",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Build-time generator: for each list in src/content/finds, compose a
|
// Build-time generator: for each bundle in src/content/bundles, compose a
|
||||||
// mosaic JPG from the item heroes available in that list. Skips work
|
// mosaic JPG from the item heroes available in that bundle. Skips work
|
||||||
// when output is newer than every input. Layout depends on hero count:
|
// when output is newer than every input. Layout depends on hero count:
|
||||||
// 0 -> no mosaic (file is removed if it exists)
|
// 0 -> no mosaic (file is removed if it exists)
|
||||||
// 1 -> 1200x1200 single image
|
// 1 -> 1200x1200 single image
|
||||||
@@ -15,9 +15,9 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = resolve(__dirname, '..');
|
const ROOT = resolve(__dirname, '..');
|
||||||
const FINDS_DIR = join(ROOT, 'src/content/finds');
|
const BUNDLES_DIR = join(ROOT, 'src/content/bundles');
|
||||||
const FIND_DIR = join(ROOT, 'src/content/find');
|
const FIND_DIR = join(ROOT, 'src/content/find');
|
||||||
const OUT_DIR = join(ROOT, 'src/generated/list-mosaics');
|
const OUT_DIR = join(ROOT, 'src/generated/bundle-mosaics');
|
||||||
|
|
||||||
const TILE = 598;
|
const TILE = 598;
|
||||||
const GAP = 4;
|
const GAP = 4;
|
||||||
@@ -122,7 +122,7 @@ async function tryUnlink(path) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await mkdir(OUT_DIR, { recursive: true });
|
await mkdir(OUT_DIR, { recursive: true });
|
||||||
const listDirs = (await readdir(FINDS_DIR, { withFileTypes: true }))
|
const bundleDirs = (await readdir(BUNDLES_DIR, { withFileTypes: true }))
|
||||||
.filter((d) => d.isDirectory())
|
.filter((d) => d.isDirectory())
|
||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
|
|
||||||
@@ -131,13 +131,13 @@ async function main() {
|
|||||||
let removed = 0;
|
let removed = 0;
|
||||||
let absent = 0;
|
let absent = 0;
|
||||||
|
|
||||||
for (const listSlug of listDirs) {
|
for (const bundleSlug of bundleDirs) {
|
||||||
const mdxPath = join(FINDS_DIR, listSlug, 'index.mdx');
|
const mdxPath = join(BUNDLES_DIR, bundleSlug, 'index.mdx');
|
||||||
const mdPath = join(FINDS_DIR, listSlug, 'index.md');
|
const mdPath = join(BUNDLES_DIR, bundleSlug, 'index.md');
|
||||||
const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null;
|
const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null;
|
||||||
if (!sourcePath) continue;
|
if (!sourcePath) continue;
|
||||||
|
|
||||||
const outPath = join(OUT_DIR, `${listSlug}.jpg`);
|
const outPath = join(OUT_DIR, `${bundleSlug}.jpg`);
|
||||||
const fm = await readFrontmatter(sourcePath);
|
const fm = await readFrontmatter(sourcePath);
|
||||||
const items = parseItems(fm);
|
const items = parseItems(fm);
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -165,10 +165,10 @@ async function main() {
|
|||||||
|
|
||||||
await buildMosaic(heroPaths, outPath);
|
await buildMosaic(heroPaths, outPath);
|
||||||
built++;
|
built++;
|
||||||
console.log(` built ${listSlug}.jpg (${heroPaths.length}-up)`);
|
console.log(` built ${bundleSlug}.jpg (${heroPaths.length}-up)`);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`list mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)`
|
`bundle mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
import type { FindsPost } from '../lib/backlinks';
|
import type { Bundle } from '../lib/backlinks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
posts: FindsPost[];
|
posts: Bundle[];
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
const { posts, label = 'Featured in' } = Astro.props;
|
const { posts, label = 'Featured in' } = Astro.props;
|
||||||
@@ -15,7 +15,7 @@ const { posts, label = 'Featured in' } = Astro.props;
|
|||||||
{posts.map((p, i) => (
|
{posts.map((p, i) => (
|
||||||
<>
|
<>
|
||||||
{i > 0 && <span class="sep">·</span>}
|
{i > 0 && <span class="sep">·</span>}
|
||||||
<a href={`/finds/${p.id}/`}>{p.data.title}</a>
|
<a href={`/bundles/${p.id}/`}>{p.data.title}</a>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
let command = btn.dataset.command || '';
|
let command = btn.dataset.command || '';
|
||||||
let description = btn.dataset.confirm || '';
|
let description = btn.dataset.confirm || '';
|
||||||
|
|
||||||
if (btn.dataset.editAction === 'finds-collection') {
|
if (btn.dataset.editAction === 'bundle-from-selection') {
|
||||||
const checked = Array.from(
|
const checked = Array.from(
|
||||||
document.querySelectorAll<HTMLInputElement>('[data-find-checkbox]:checked')
|
document.querySelectorAll<HTMLInputElement>('[data-find-checkbox]:checked')
|
||||||
);
|
);
|
||||||
@@ -144,10 +144,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(',');
|
const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(',');
|
||||||
command = `/build-finds ${slugs}`;
|
command = `/build-bundle ${slugs}`;
|
||||||
description = `Build a finds collection from ${checked.length} selected find${
|
description = `Bundle ${checked.length} selected find${
|
||||||
checked.length === 1 ? '' : 's'
|
checked.length === 1 ? '' : 's'
|
||||||
}.`;
|
} under one theme.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn.dataset.editAction === 'tag-prompt') {
|
if (btn.dataset.editAction === 'tag-prompt') {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import type { ImageMetadata } from 'astro';
|
||||||
|
import Masonry from './Masonry.astro';
|
||||||
|
import type { Entry } from '../lib/entries';
|
||||||
|
import { heroes } from '../lib/hero';
|
||||||
|
import { bundleMosaic } from '../lib/bundle-mosaic';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: Entry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entries } = Astro.props;
|
||||||
|
|
||||||
|
type Visual =
|
||||||
|
| { kind: 'image'; src: ImageMetadata }
|
||||||
|
| { kind: 'video'; src: string };
|
||||||
|
|
||||||
|
function visualFor(entry: Entry): Visual | undefined {
|
||||||
|
if (entry.type === 'review') return heroes[entry.id];
|
||||||
|
if (entry.type === 'bundle') {
|
||||||
|
const mosaic = bundleMosaic(entry.id);
|
||||||
|
return mosaic ? { kind: 'image', src: mosaic } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Masonry>
|
||||||
|
{
|
||||||
|
entries.map((entry) => {
|
||||||
|
const visual = visualFor(entry);
|
||||||
|
const isBundle = entry.type === 'bundle';
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<a
|
||||||
|
href={entry.href}
|
||||||
|
class:list={['tile', { 'text-only': !visual, 'is-bundle': isBundle }]}
|
||||||
|
>
|
||||||
|
{visual?.kind === 'image' && (
|
||||||
|
<Image
|
||||||
|
class="media"
|
||||||
|
src={visual.src}
|
||||||
|
widths={[320, 480, 640]}
|
||||||
|
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 320px"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt={entry.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{visual?.kind === 'video' && (
|
||||||
|
<video
|
||||||
|
class="media"
|
||||||
|
src={visual.src}
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="none"
|
||||||
|
aria-label={entry.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div class="text">
|
||||||
|
{isBundle && <span class="eyebrow">Bundle</span>}
|
||||||
|
<span class="name">{entry.primary}</span>
|
||||||
|
{entry.secondary && <span class="subtitle">{entry.secondary}</span>}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Masonry>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tile {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--rule);
|
||||||
|
background: var(--bg);
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
background: var(--card-fill);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tile.is-bundle .name {
|
||||||
|
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -60,16 +60,16 @@ const find = defineCollection({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const finds = defineCollection({
|
const bundles = defineCollection({
|
||||||
loader: glob({
|
loader: glob({
|
||||||
pattern: '**/index.mdx',
|
pattern: '**/index.mdx',
|
||||||
base: './src/content/finds',
|
base: './src/content/bundles',
|
||||||
generateId: stripIndex,
|
generateId: stripIndex,
|
||||||
}),
|
}),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
date: z.coerce.date(),
|
date: z.coerce.date(),
|
||||||
category: z.string().default('finds'),
|
category: z.string().default('bundles'),
|
||||||
tags: z.array(z.string()).default([]),
|
tags: z.array(z.string()).default([]),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
blurb: z.string().optional(),
|
blurb: z.string().optional(),
|
||||||
@@ -77,4 +77,4 @@ const finds = defineCollection({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const collections = { reviews, posts, find, finds };
|
export const collections = { reviews, posts, find, bundles };
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const year = new Date().getFullYear();
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href="/grid/">Reviews</a></li>
|
<li><a href="/grid/">Reviews</a></li>
|
||||||
<li><a href="/find/">Finds</a></li>
|
<li><a href="/find/">Finds</a></li>
|
||||||
<li><a href="/finds/">Lists</a></li>
|
<li><a href="/bundles/">Bundles</a></li>
|
||||||
<li><a href="/blog/">Posts</a></li>
|
<li><a href="/blog/">Posts</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -417,6 +417,120 @@ const year = new Date().getFullYear();
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; }
|
hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; }
|
||||||
|
|
||||||
|
/* External CTA — primary "go to source" link on /reviews/ and /find/.
|
||||||
|
Sits in the body region below the write-up, not in the colophon. */
|
||||||
|
.external {
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
.external a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.external a:hover { text-decoration: none; opacity: 0.9; }
|
||||||
|
.external .aff-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colophon — understated meta block at the foot of a post.
|
||||||
|
Used on /reviews/, /find/, /bundles/ pages. */
|
||||||
|
.colophon {
|
||||||
|
margin: 3rem 0 0;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.colophon dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.55rem 1.25rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.colophon dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.colophon dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.colophon a {
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.colophon a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: currentColor;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.colophon .colophon-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
.colophon .colophon-tags a { border-bottom: 0; }
|
||||||
|
.colophon time { color: var(--muted); }
|
||||||
|
.colophon .aff-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.45rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--rule);
|
||||||
|
border-radius: 999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.colophon-aff {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px dashed var(--rule);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.colophon dl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.15rem 0;
|
||||||
|
}
|
||||||
|
.colophon dt { margin-top: 0.6rem; }
|
||||||
|
.colophon dt:first-child { margin-top: 0; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{import.meta.env.DEV && <EditConfirm />}
|
{import.meta.env.DEV && <EditConfirm />}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
export type FindsPost = CollectionEntry<'finds'>;
|
export type Bundle = CollectionEntry<'bundles'>;
|
||||||
|
|
||||||
export async function findsReferencing(slug: string): Promise<FindsPost[]> {
|
export async function bundlesReferencing(slug: string): Promise<Bundle[]> {
|
||||||
const all = await getCollection('finds');
|
const all = await getCollection('bundles');
|
||||||
return all
|
return all
|
||||||
.filter((p) => p.data.items.includes(slug))
|
.filter((p) => p.data.items.includes(slug))
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { ImageMetadata } from 'astro';
|
import type { ImageMetadata } from 'astro';
|
||||||
|
|
||||||
const modules = import.meta.glob<{ default: ImageMetadata }>(
|
const modules = import.meta.glob<{ default: ImageMetadata }>(
|
||||||
'../generated/list-mosaics/*.jpg',
|
'../generated/bundle-mosaics/*.jpg',
|
||||||
{ eager: true }
|
{ eager: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const mosaics = new Map<string, ImageMetadata>();
|
const mosaics = new Map<string, ImageMetadata>();
|
||||||
for (const [path, mod] of Object.entries(modules)) {
|
for (const [path, mod] of Object.entries(modules)) {
|
||||||
const match = path.match(/list-mosaics\/([^/]+)\.jpg$/);
|
const match = path.match(/bundle-mosaics\/([^/]+)\.jpg$/);
|
||||||
if (match) mosaics.set(match[1], mod.default);
|
if (match) mosaics.set(match[1], mod.default);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findListMosaic(slug: string): ImageMetadata | undefined {
|
export function bundleMosaic(slug: string): ImageMetadata | undefined {
|
||||||
return mosaics.get(slug);
|
return mosaics.get(slug);
|
||||||
}
|
}
|
||||||
+10
-10
@@ -1,7 +1,7 @@
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
export type Entry = {
|
export type Entry = {
|
||||||
type: 'review' | 'post' | 'finds';
|
type: 'review' | 'post' | 'bundle';
|
||||||
id: string;
|
id: string;
|
||||||
href: string;
|
href: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -14,7 +14,7 @@ export type Entry = {
|
|||||||
export async function getAllEntries(): Promise<Entry[]> {
|
export async function getAllEntries(): Promise<Entry[]> {
|
||||||
const reviews = await getCollection('reviews');
|
const reviews = await getCollection('reviews');
|
||||||
const posts = await getCollection('posts');
|
const posts = await getCollection('posts');
|
||||||
const findsPosts = await getCollection('finds');
|
const bundles = await getCollection('bundles');
|
||||||
|
|
||||||
const r: Entry[] = reviews.map((e) => ({
|
const r: Entry[] = reviews.map((e) => ({
|
||||||
type: 'review',
|
type: 'review',
|
||||||
@@ -37,10 +37,10 @@ export async function getAllEntries(): Promise<Entry[]> {
|
|||||||
primary: e.data.title,
|
primary: e.data.title,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const f: Entry[] = findsPosts.map((e) => ({
|
const b: Entry[] = bundles.map((e) => ({
|
||||||
type: 'finds',
|
type: 'bundle',
|
||||||
id: e.id,
|
id: e.id,
|
||||||
href: `/finds/${e.id}/`,
|
href: `/bundles/${e.id}/`,
|
||||||
date: e.data.date,
|
date: e.data.date,
|
||||||
category: e.data.category,
|
category: e.data.category,
|
||||||
tags: e.data.tags,
|
tags: e.data.tags,
|
||||||
@@ -48,7 +48,7 @@ export async function getAllEntries(): Promise<Entry[]> {
|
|||||||
secondary: e.data.blurb,
|
secondary: e.data.blurb,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...r, ...p, ...f].sort((a, b) => b.date.valueOf() - a.date.valueOf());
|
return [...r, ...p, ...b].sort((a, b) => b.date.valueOf() - a.date.valueOf());
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SiteStats = {
|
export type SiteStats = {
|
||||||
@@ -57,7 +57,7 @@ export type SiteStats = {
|
|||||||
reviews: number;
|
reviews: number;
|
||||||
finds: number;
|
finds: number;
|
||||||
posts: number;
|
posts: number;
|
||||||
lists: number;
|
bundles: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let statsCache: Promise<SiteStats> | null = null;
|
let statsCache: Promise<SiteStats> | null = null;
|
||||||
@@ -73,17 +73,17 @@ async function computeSiteStats(): Promise<SiteStats> {
|
|||||||
const reviews = entries.filter((e) => e.type === 'review').length;
|
const reviews = entries.filter((e) => e.type === 'review').length;
|
||||||
const finds = findItems.length;
|
const finds = findItems.length;
|
||||||
const posts = entries.filter((e) => e.type === 'post').length;
|
const posts = entries.filter((e) => e.type === 'post').length;
|
||||||
const lists = entries.filter((e) => e.type === 'finds').length;
|
const bundles = entries.filter((e) => e.type === 'bundle').length;
|
||||||
const allDates = [
|
const allDates = [
|
||||||
...entries.map((e) => e.date),
|
...entries.map((e) => e.date),
|
||||||
...findItems.map((f) => f.data.date),
|
...findItems.map((f) => f.data.date),
|
||||||
].sort((a, b) => b.valueOf() - a.valueOf());
|
].sort((a, b) => b.valueOf() - a.valueOf());
|
||||||
return {
|
return {
|
||||||
total: reviews + finds + posts + lists,
|
total: reviews + finds + posts + bundles,
|
||||||
lastUpdated: allDates[0] ?? null,
|
lastUpdated: allDates[0] ?? null,
|
||||||
reviews,
|
reviews,
|
||||||
finds,
|
finds,
|
||||||
posts,
|
posts,
|
||||||
lists,
|
bundles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import { heroes } from './hero';
|
import { heroes } from './hero';
|
||||||
import type { OgImage } from '../components/SEO.astro';
|
import type { OgImage } from '../components/SEO.astro';
|
||||||
|
|
||||||
export type OgFallback = 'reviews' | 'finds' | 'posts' | 'site';
|
export type OgFallback = 'reviews' | 'bundles' | 'posts' | 'site';
|
||||||
|
|
||||||
const SITE_URL = 'https://unique.rzen.dev';
|
const SITE_URL = 'https://unique.rzen.dev';
|
||||||
|
|
||||||
const FALLBACK_PATHS: Record<OgFallback, string> = {
|
const FALLBACK_PATHS: Record<OgFallback, string> = {
|
||||||
reviews: '/og/reviews.png',
|
reviews: '/og/reviews.png',
|
||||||
finds: '/og/finds.png',
|
bundles: '/og/bundles.png',
|
||||||
posts: '/og/posts.png',
|
posts: '/og/posts.png',
|
||||||
site: '/og/default.png',
|
site: '/og/default.png',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from '../../lib/seo';
|
} from '../../lib/seo';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const finds = await getCollection('finds');
|
const bundles = await getCollection('bundles');
|
||||||
return finds.map((post) => ({
|
return bundles.map((post) => ({
|
||||||
params: { slug: post.id },
|
params: { slug: post.id },
|
||||||
props: { post },
|
props: { post },
|
||||||
}));
|
}));
|
||||||
@@ -29,17 +29,17 @@ const description =
|
|||||||
|
|
||||||
const ogImage = buildOgImage(
|
const ogImage = buildOgImage(
|
||||||
items[0]?.data.promotedTo,
|
items[0]?.data.promotedTo,
|
||||||
'finds',
|
'bundles',
|
||||||
`${post.data.title} cover image`
|
`${post.data.title} cover image`
|
||||||
);
|
);
|
||||||
|
|
||||||
const articleSchema: Record<string, unknown> = {
|
const articleSchema: Record<string, unknown> = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Article',
|
'@type': 'Article',
|
||||||
'@id': absoluteUrl(`/finds/${post.id}/#article`),
|
'@id': absoluteUrl(`/bundles/${post.id}/#article`),
|
||||||
headline: post.data.title,
|
headline: post.data.title,
|
||||||
description,
|
description,
|
||||||
url: absoluteUrl(`/finds/${post.id}/`),
|
url: absoluteUrl(`/bundles/${post.id}/`),
|
||||||
datePublished: post.data.date.toISOString(),
|
datePublished: post.data.date.toISOString(),
|
||||||
author: PERSON_AUTHOR,
|
author: PERSON_AUTHOR,
|
||||||
image: ogImage.url,
|
image: ogImage.url,
|
||||||
@@ -53,7 +53,7 @@ const itemList = buildItemList(
|
|||||||
|
|
||||||
const breadcrumbs = buildBreadcrumbs([
|
const breadcrumbs = buildBreadcrumbs([
|
||||||
{ name: 'Home', url: '/' },
|
{ name: 'Home', url: '/' },
|
||||||
{ name: post.data.title, url: `/finds/${post.id}/` },
|
{ name: post.data.title, url: `/bundles/${post.id}/` },
|
||||||
]);
|
]);
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,15 +78,34 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
{items.map((item) => <FindCard item={item} />)}
|
{items.map((item) => <FindCard item={item} />)}
|
||||||
</Masonry>
|
</Masonry>
|
||||||
|
|
||||||
{
|
{(post.data.tags.length > 0 ||
|
||||||
post.data.tags.length > 0 && (
|
(post.data.category && post.data.category !== 'bundles')) && (
|
||||||
<footer class="tags">
|
<aside class="colophon" aria-label="Colophon">
|
||||||
{post.data.tags.map((tag) => (
|
<dl>
|
||||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
{post.data.category && post.data.category !== 'bundles' && (
|
||||||
))}
|
<>
|
||||||
</footer>
|
<dt>Filed under</dt>
|
||||||
)
|
<dd>
|
||||||
}
|
<a href={`/categories/${post.data.category}/`}>
|
||||||
|
{post.data.category.charAt(0).toUpperCase() +
|
||||||
|
post.data.category.slice(1)}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{post.data.tags.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt>Tagged</dt>
|
||||||
|
<dd class="colophon-tags">
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<a href={`/tags/${tag}/`}>#{tag}</a>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<p class="back"><a href="/">← back</a></p>
|
<p class="back"><a href="/">← back</a></p>
|
||||||
</article>
|
</article>
|
||||||
@@ -105,16 +124,6 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.body :global(blockquote p) { margin: 0; }
|
.body :global(blockquote p) { margin: 0; }
|
||||||
.tags {
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--rule);
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.tag { font-size: 0.85rem; color: var(--muted); }
|
|
||||||
.tag:hover { color: var(--accent); }
|
|
||||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||||
</style>
|
</style>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -3,12 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
import Masonry from '../../components/Masonry.astro';
|
import Masonry from '../../components/Masonry.astro';
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from 'astro:assets';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { findListMosaic } from '../../lib/find-list-mosaic';
|
import { bundleMosaic } from '../../lib/bundle-mosaic';
|
||||||
|
|
||||||
const lists = (await getCollection('finds')).sort(
|
const bundles = (await getCollection('bundles')).sort(
|
||||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
);
|
);
|
||||||
const lastUpdated = lists[0]?.data.date;
|
const lastUpdated = bundles[0]?.data.date;
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -19,18 +19,18 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title="Lists"
|
title="Bundles"
|
||||||
description={`All ${lists.length} themed lists on Unique.`}
|
description={`All ${bundles.length} themed bundles on Unique.`}
|
||||||
subheader={`All ${lists.length} themed lists.`}
|
subheader={`All ${bundles.length} themed bundles.`}
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
noindex
|
noindex
|
||||||
>
|
>
|
||||||
<Masonry>
|
<Masonry>
|
||||||
{lists.map((l) => {
|
{bundles.map((b) => {
|
||||||
const mosaic = findListMosaic(l.id);
|
const mosaic = bundleMosaic(b.id);
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
<a href={`/finds/${l.id}/`} class="list-tile">
|
<a href={`/bundles/${b.id}/`} class="bundle-tile">
|
||||||
{mosaic && (
|
{mosaic && (
|
||||||
<div class="mosaic">
|
<div class="mosaic">
|
||||||
<Image
|
<Image
|
||||||
@@ -44,12 +44,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<h2 class="title">{l.data.title}</h2>
|
<h2 class="title">{b.data.title}</h2>
|
||||||
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
|
{b.data.blurb && <p class="blurb">{b.data.blurb}</p>}
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
|
<time datetime={b.data.date.toISOString()}>{fmt.format(b.data.date)}</time>
|
||||||
<span class="dot" aria-hidden="true">·</span>
|
<span class="dot" aria-hidden="true">·</span>
|
||||||
<span>{l.data.items.length} picks</span>
|
<span>{b.data.items.length} picks</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -59,7 +59,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
</Masonry>
|
</Masonry>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.list-tile {
|
.bundle-tile {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -67,12 +67,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
.list-tile:hover {
|
.bundle-tile:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
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);
|
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
}
|
}
|
||||||
.list-tile:hover .title { color: var(--accent); }
|
.bundle-tile:hover .title { color: var(--accent); }
|
||||||
|
|
||||||
.mosaic {
|
.mosaic {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from 'astro:assets';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.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 { bundlesReferencing } from '../../lib/backlinks';
|
||||||
import { findHero } from '../../lib/find-hero';
|
import { findHero } from '../../lib/find-hero';
|
||||||
|
import { sourceSlug } from '../../lib/sources';
|
||||||
import EditAction from '../../components/EditAction.astro';
|
import EditAction from '../../components/EditAction.astro';
|
||||||
import {
|
import {
|
||||||
resolveExternalLink,
|
resolveExternalLink,
|
||||||
@@ -24,7 +24,7 @@ export async function getStaticPaths() {
|
|||||||
|
|
||||||
const { item } = Astro.props;
|
const { item } = Astro.props;
|
||||||
const { Content } = await render(item);
|
const { Content } = await render(item);
|
||||||
const featuringPosts = await findsReferencing(item.id);
|
const featuringPosts = await bundlesReferencing(item.id);
|
||||||
const hero = findHero(item.id);
|
const hero = findHero(item.id);
|
||||||
|
|
||||||
const linkLabel =
|
const linkLabel =
|
||||||
@@ -41,7 +41,7 @@ const canonicalOverride = item.data.promotedTo
|
|||||||
|
|
||||||
const ogImage = buildOgImage(
|
const ogImage = buildOgImage(
|
||||||
item.data.promotedTo,
|
item.data.promotedTo,
|
||||||
'finds',
|
'bundles',
|
||||||
`${item.data.name} preview image`
|
`${item.data.name} preview image`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +82,6 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
jsonLd={[productSchema, breadcrumbs]}
|
jsonLd={[productSchema, breadcrumbs]}
|
||||||
>
|
>
|
||||||
<article>
|
<article>
|
||||||
<header class="find-header">
|
|
||||||
<p class="source-line"><SourceBadge source={item.data.source} /></p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{hero && (
|
{hero && (
|
||||||
<Image
|
<Image
|
||||||
class="hero"
|
class="hero"
|
||||||
@@ -138,24 +134,29 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
|
|
||||||
<Backlinks posts={featuringPosts} />
|
<Backlinks posts={featuringPosts} />
|
||||||
|
|
||||||
{
|
<aside class="colophon" aria-label="Colophon">
|
||||||
item.data.tags.length > 0 && (
|
<dl>
|
||||||
<footer class="tags">
|
<dt>Via</dt>
|
||||||
{item.data.tags.map((tag) => (
|
<dd>
|
||||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
<a href={`/sources/#${sourceSlug(item.data.source)}`}>{item.data.source}</a>
|
||||||
))}
|
</dd>
|
||||||
</footer>
|
{item.data.tags.length > 0 && (
|
||||||
)
|
<>
|
||||||
}
|
<dt>Tagged</dt>
|
||||||
|
<dd class="colophon-tags">
|
||||||
|
{item.data.tags.map((tag) => (
|
||||||
|
<a href={`/tags/${tag}/`}>#{tag}</a>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<p class="back"><a href="/">← back</a></p>
|
<p class="back"><a href="/">← back</a></p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.find-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.source-line { margin: 0; }
|
|
||||||
.hero {
|
.hero {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -168,40 +169,6 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
.body :global(p) { margin: 0 0 1rem; }
|
.body :global(p) { margin: 0 0 1rem; }
|
||||||
.external {
|
|
||||||
margin: 1.5rem 0 0;
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
.external a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.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;
|
||||||
@@ -210,16 +177,6 @@ const breadcrumbs = buildBreadcrumbs([
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
.promoted a { font-weight: 600; }
|
.promoted a { font-weight: 600; }
|
||||||
.tags {
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--rule);
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.tag { font-size: 0.85rem; color: var(--muted); }
|
|
||||||
.tag:hover { color: var(--accent); }
|
|
||||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||||
.edit-toolbar {
|
.edit-toolbar {
|
||||||
margin: 2rem 0 0;
|
margin: 2rem 0 0;
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ const decorated = (await getAllFinds())
|
|||||||
const finds = decorated.map((d) => d.item);
|
const finds = decorated.map((d) => d.item);
|
||||||
const lastUpdated = finds[0]?.data.date;
|
const lastUpdated = finds[0]?.data.date;
|
||||||
|
|
||||||
// Slugs referenced by any superpost in the `finds/` collection — dev-only filter.
|
// Slugs referenced by any bundle — dev-only filter.
|
||||||
const inListSlugs = new Set(
|
const bundledSlugs = new Set(
|
||||||
(await getCollection('finds')).flatMap((l) => l.data.items)
|
(await getCollection('bundles')).flatMap((b) => b.data.items)
|
||||||
);
|
);
|
||||||
const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-');
|
const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-');
|
||||||
const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-');
|
const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-');
|
||||||
const isUnused = (f: (typeof finds)[number]) =>
|
const isUnused = (f: (typeof finds)[number]) =>
|
||||||
!inListSlugs.has(f.id) && !f.data.promotedTo;
|
!bundledSlugs.has(f.id) && !f.data.promotedTo;
|
||||||
const unusedCount = finds.filter(isUnused).length;
|
const unusedCount = finds.filter(isUnused).length;
|
||||||
const jokeCount = finds.filter(isJoke).length;
|
const jokeCount = finds.filter(isJoke).length;
|
||||||
const quoteCount = finds.filter(isQuote).length;
|
const quoteCount = finds.filter(isQuote).length;
|
||||||
@@ -105,9 +105,9 @@ const quoteCount = finds.filter(isQuote).length;
|
|||||||
<span data-find-selected-count>0</span> selected
|
<span data-find-selected-count>0</span> selected
|
||||||
</span>
|
</span>
|
||||||
<EditAction
|
<EditAction
|
||||||
action="finds-collection"
|
action="bundle-from-selection"
|
||||||
label="Make a finds collection"
|
label="Bundle this selection"
|
||||||
confirm="This will draft a themed finds list (.mdx in src/content/finds/) from the selected items."
|
confirm="This will draft a themed bundle (.mdx in src/content/bundles/) from the selected items."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+20
-20
@@ -4,10 +4,10 @@ import { Image } from 'astro:assets';
|
|||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import Masonry from '../components/Masonry.astro';
|
import Masonry from '../components/Masonry.astro';
|
||||||
import { heroes } from '../lib/hero';
|
import { heroes } from '../lib/hero';
|
||||||
import { findListMosaic } from '../lib/find-list-mosaic';
|
import { bundleMosaic } from '../lib/bundle-mosaic';
|
||||||
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
||||||
|
|
||||||
const HERO_SLUG = 'monitorcontrol';
|
const HERO_SLUG = 'gum-shell';
|
||||||
const INITIAL_GRID = 12;
|
const INITIAL_GRID = 12;
|
||||||
const BATCH_SIZE = 7;
|
const BATCH_SIZE = 7;
|
||||||
|
|
||||||
@@ -22,23 +22,23 @@ type Tile =
|
|||||||
hero: ReturnType<typeof getReviewHero>;
|
hero: ReturnType<typeof getReviewHero>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'list';
|
kind: 'bundle';
|
||||||
href: string;
|
href: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle: string | undefined;
|
subtitle: string | undefined;
|
||||||
date: Date;
|
date: Date;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
mosaic: ReturnType<typeof findListMosaic>;
|
mosaic: ReturnType<typeof bundleMosaic>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getReviewHero(slug: string) {
|
function getReviewHero(slug: string) {
|
||||||
return heroes[slug];
|
return heroes[slug];
|
||||||
}
|
}
|
||||||
|
|
||||||
const [reviews, lists] = await Promise.all([
|
const [reviews, bundles] = await Promise.all([
|
||||||
getCollection('reviews'),
|
getCollection('reviews'),
|
||||||
getCollection('finds'),
|
getCollection('bundles'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const reviewTiles: Tile[] = reviews.map((review) => ({
|
const reviewTiles: Tile[] = reviews.map((review) => ({
|
||||||
@@ -51,18 +51,18 @@ const reviewTiles: Tile[] = reviews.map((review) => ({
|
|||||||
hero: getReviewHero(review.id),
|
hero: getReviewHero(review.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const listTiles: Tile[] = lists.map((list) => ({
|
const bundleTiles: Tile[] = bundles.map((bundle) => ({
|
||||||
kind: 'list' as const,
|
kind: 'bundle' as const,
|
||||||
href: `/finds/${list.id}/`,
|
href: `/bundles/${bundle.id}/`,
|
||||||
id: list.id,
|
id: bundle.id,
|
||||||
name: list.data.title,
|
name: bundle.data.title,
|
||||||
subtitle: list.data.blurb,
|
subtitle: bundle.data.blurb,
|
||||||
date: list.data.date,
|
date: bundle.data.date,
|
||||||
itemCount: list.data.items.length,
|
itemCount: bundle.data.items.length,
|
||||||
mosaic: findListMosaic(list.id),
|
mosaic: bundleMosaic(bundle.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tiles: Tile[] = [...reviewTiles, ...listTiles].sort(
|
const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
|
||||||
(a, b) => b.date.valueOf() - a.date.valueOf()
|
(a, b) => b.date.valueOf() - a.date.valueOf()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ const itemList = buildItemList(
|
|||||||
tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined;
|
tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined;
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
<a href={tile.href} class:list={['tile', { 'text-only': !visual, 'is-list': tile.kind === 'list' }]}>
|
<a href={tile.href} class:list={['tile', { 'text-only': !visual, 'is-bundle': tile.kind === 'bundle' }]}>
|
||||||
{visual?.kind === 'image' && (
|
{visual?.kind === 'image' && (
|
||||||
<Image
|
<Image
|
||||||
class="media"
|
class="media"
|
||||||
@@ -155,8 +155,8 @@ const itemList = buildItemList(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
{tile.kind === 'list' && (
|
{tile.kind === 'bundle' && (
|
||||||
<span class="eyebrow">List · {tile.itemCount} picks</span>
|
<span class="eyebrow">Bundle · {tile.itemCount} picks</span>
|
||||||
)}
|
)}
|
||||||
<span class="name">{tile.name}</span>
|
<span class="name">{tile.name}</span>
|
||||||
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
|
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
|
||||||
@@ -260,7 +260,7 @@ const itemList = buildItemList(
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.tile.is-list .name {
|
.tile.is-bundle .name {
|
||||||
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import rss from '@astrojs/rss';
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
export async function GET(context) {
|
export async function GET(context) {
|
||||||
const [reviews, posts, finds] = await Promise.all([
|
const [reviews, posts, bundles] = await Promise.all([
|
||||||
getCollection('reviews'),
|
getCollection('reviews'),
|
||||||
getCollection('posts'),
|
getCollection('posts'),
|
||||||
getCollection('finds'),
|
getCollection('bundles'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
@@ -23,12 +23,12 @@ export async function GET(context) {
|
|||||||
link: `/posts/${p.id}/`,
|
link: `/posts/${p.id}/`,
|
||||||
categories: [p.data.category, ...p.data.tags],
|
categories: [p.data.category, ...p.data.tags],
|
||||||
})),
|
})),
|
||||||
...finds.map((f) => ({
|
...bundles.map((b) => ({
|
||||||
title: f.data.title,
|
title: b.data.title,
|
||||||
description: f.data.description ?? f.data.blurb ?? '',
|
description: b.data.description ?? b.data.blurb ?? '',
|
||||||
pubDate: f.data.date,
|
pubDate: b.data.date,
|
||||||
link: `/finds/${f.id}/`,
|
link: `/bundles/${b.id}/`,
|
||||||
categories: [f.data.category, ...f.data.tags],
|
categories: [b.data.category, ...b.data.tags],
|
||||||
})),
|
})),
|
||||||
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
|
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title="Search"
|
title="Search"
|
||||||
description="Search reviews, finds, lists, and posts across Unique."
|
description="Search reviews, finds, bundles, and posts across Unique."
|
||||||
subheader="Search"
|
subheader="Search"
|
||||||
noindex
|
noindex
|
||||||
>
|
>
|
||||||
@@ -13,7 +13,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
id="q"
|
id="q"
|
||||||
type="search"
|
type="search"
|
||||||
name="q"
|
name="q"
|
||||||
placeholder="Search reviews, finds, lists, posts…"
|
placeholder="Search reviews, finds, bundles, posts…"
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
@@ -29,7 +29,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
const results = document.getElementById('results') as HTMLDivElement;
|
const results = document.getElementById('results') as HTMLDivElement;
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
type: 'review' | 'find' | 'list' | 'post';
|
type: 'review' | 'find' | 'bundle' | 'post';
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -42,7 +42,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
const typeLabels: Record<Entry['type'], string> = {
|
const typeLabels: Record<Entry['type'], string> = {
|
||||||
review: 'Review',
|
review: 'Review',
|
||||||
find: 'Find',
|
find: 'Find',
|
||||||
list: 'List',
|
bundle: 'Bundle',
|
||||||
post: 'Post',
|
post: 'Post',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -1,10 +1,10 @@
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const [reviews, finds, lists, posts] = await Promise.all([
|
const [reviews, finds, bundles, posts] = await Promise.all([
|
||||||
getCollection('reviews'),
|
getCollection('reviews'),
|
||||||
getCollection('find'),
|
getCollection('find'),
|
||||||
getCollection('finds'),
|
getCollection('bundles'),
|
||||||
getCollection('posts'),
|
getCollection('posts'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -28,14 +28,14 @@ export async function GET() {
|
|||||||
blurb: f.data.description ?? '',
|
blurb: f.data.description ?? '',
|
||||||
source: f.data.source,
|
source: f.data.source,
|
||||||
})),
|
})),
|
||||||
...lists.map((l) => ({
|
...bundles.map((b) => ({
|
||||||
type: 'list',
|
type: 'bundle',
|
||||||
url: `/finds/${l.id}/`,
|
url: `/bundles/${b.id}/`,
|
||||||
title: l.data.title,
|
title: b.data.title,
|
||||||
subtitle: l.data.blurb ?? '',
|
subtitle: b.data.blurb ?? '',
|
||||||
date: l.data.date.toISOString(),
|
date: b.data.date.toISOString(),
|
||||||
tags: l.data.tags ?? [],
|
tags: b.data.tags ?? [],
|
||||||
blurb: l.data.description ?? '',
|
blurb: b.data.description ?? '',
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => ({
|
||||||
type: 'post',
|
type: 'post',
|
||||||
|
|||||||
Reference in New Issue
Block a user