site: hero auto-discovery, pick.json hero selection, amazonLink + shortlist fields
- src/lib/hero.ts: review heroes are now auto-discovered via import.meta.glob over reviews/*/hero.<ext>; only legacy non-hero-named assets remain as explicit overrides. New reviews need zero registration. - Pick of the day moves from a hardcoded HERO_SLUG in index.astro to src/data/pick.json (content edit, not code edit); dev set-pick API and the review-page button now write pick.json. Fallback: latest review. - Schemas: finds gain shortlist (bool) + amazonLink; reviews gain amazonLink. - Review + find pages render an 'Also on Amazon' affiliate link when amazonLink is set, with shared disclosure handling. - Both hero globs now include .avif (two June finds ship avif heroes).
This commit is contained in:
@@ -21,6 +21,7 @@ const reviews = defineCollection({
|
||||
tags: z.array(z.string()).default([]),
|
||||
link: z.string().url().optional(),
|
||||
linkText: z.string().optional(),
|
||||
amazonLink: z.string().url().optional(),
|
||||
description: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
fromFind: z.string().optional(),
|
||||
@@ -54,11 +55,13 @@ const find = defineCollection({
|
||||
date: z.coerce.date(),
|
||||
link: z.string().url(),
|
||||
linkText: z.string().optional(),
|
||||
amazonLink: z.string().url().optional(),
|
||||
source: z.string(),
|
||||
topics: z.array(z.string()).default([]),
|
||||
tags: z.array(z.string()).default([]),
|
||||
description: z.string().optional(),
|
||||
promotedTo: z.string().optional(),
|
||||
shortlist: z.boolean().default(false),
|
||||
editorialTags: z.array(z.string()).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"hero": "continue-y-n-game"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
const modules = import.meta.glob<{ default: ImageMetadata }>(
|
||||
'../content/find/**/hero.{png,jpg,jpeg,gif,webp}',
|
||||
'../content/find/**/hero.{png,jpg,jpeg,gif,webp,avif}',
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
|
||||
+35
-41
@@ -1,34 +1,17 @@
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
import alcove from '../content/reviews/alcove/hero.png';
|
||||
import altTab from '../content/reviews/alt-tab/hero.jpg';
|
||||
import apex from '../content/reviews/apex-markdown-processor/hero.png';
|
||||
// Legacy hero assets that predate the hero.<ext> naming convention.
|
||||
// New reviews should name their hero file hero.<ext> — it is auto-discovered
|
||||
// by the globs below and needs no registration here.
|
||||
import bartender from '../content/reviews/bartender-6-dynamic-peninsula/peninsula.png';
|
||||
import blip from '../content/reviews/blip/hero.jpg';
|
||||
import continueYn from '../content/reviews/continue-y-n-game/hero.png';
|
||||
import cursorCamp from '../content/reviews/cursor-camp-neal-fun/hero.png';
|
||||
import editorio from '../content/reviews/editorio/hero.webp';
|
||||
import esproP7 from '../content/reviews/espro-p7-french-press/hero.jpg';
|
||||
import finalist from '../content/reviews/finalist/hero.png';
|
||||
import folderPeek from '../content/reviews/folder-peek/screenshot.png';
|
||||
import gnome from '../content/reviews/gnome-gif-menubar/screenshot.png';
|
||||
import gumShell from '../content/reviews/gum-shell/hero.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 monitorcontrol from '../content/reviews/monitorcontrol/hero.png';
|
||||
import muxy from '../content/reviews/muxy-terminal/hero.png';
|
||||
import remctl from '../content/reviews/remctl-reminders-cli/hero.png';
|
||||
import smartmoverShortcut from '../content/reviews/smartmover-shortcut/hero.jpg';
|
||||
import superkey from '../content/reviews/superkey/hero.jpg';
|
||||
import tablepro from '../content/reviews/tablepro/hero.png';
|
||||
import timelined from '../content/reviews/timelined/hero.png';
|
||||
import tot from '../content/reviews/tot/seven-dots.png';
|
||||
import valchooseBarfBags from '../content/reviews/valchoose-barf-bags/hero.jpg';
|
||||
import zenteek from '../content/reviews/zenteek-music-player/library.png';
|
||||
|
||||
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';
|
||||
@@ -41,35 +24,41 @@ export type Hero =
|
||||
| { kind: 'image'; src: ImageMetadata }
|
||||
| { kind: 'video'; src: string };
|
||||
|
||||
export const heroes: Record<string, Hero | undefined> = {
|
||||
alcove: { kind: 'image', src: alcove },
|
||||
'alt-tab': { kind: 'image', src: altTab },
|
||||
'apex-markdown-processor': { kind: 'image', src: apex },
|
||||
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
|
||||
'../content/reviews/*/hero.{png,jpg,jpeg,gif,webp,avif}',
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
const videoModules = import.meta.glob<string>('../content/reviews/*/hero.mp4', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
function slugOf(path: string): string | undefined {
|
||||
return path.match(/\/reviews\/([^/]+)\//)?.[1];
|
||||
}
|
||||
|
||||
const discovered: Record<string, Hero> = {};
|
||||
for (const [path, mod] of Object.entries(imageModules)) {
|
||||
const slug = slugOf(path);
|
||||
if (slug) discovered[slug] = { kind: 'image', src: mod.default };
|
||||
}
|
||||
// A hero.mp4 wins over a sibling hero image.
|
||||
for (const [path, src] of Object.entries(videoModules)) {
|
||||
const slug = slugOf(path);
|
||||
if (slug) discovered[slug] = { kind: 'video', src };
|
||||
}
|
||||
|
||||
const overrides: Record<string, Hero> = {
|
||||
'bartender-6-dynamic-peninsula': { kind: 'image', src: bartender },
|
||||
blip: { kind: 'image', src: blip },
|
||||
'continue-y-n-game': { kind: 'image', src: continueYn },
|
||||
'cursor-camp-neal-fun': { kind: 'image', src: cursorCamp },
|
||||
editorio: { kind: 'image', src: editorio },
|
||||
'espro-p7-french-press': { kind: 'image', src: esproP7 },
|
||||
finalist: { kind: 'image', src: finalist },
|
||||
'folder-peek': { kind: 'image', src: folderPeek },
|
||||
'gnome-gif-menubar': { kind: 'image', src: gnome },
|
||||
'gum-shell': { kind: 'image', src: gumShell },
|
||||
hyperkey: { kind: 'image', src: hyperkey },
|
||||
ice: { kind: 'image', src: ice },
|
||||
'mac-mouse-fix': { kind: 'image', src: macMouseFix },
|
||||
monitorcontrol: { kind: 'image', src: monitorcontrol },
|
||||
'muxy-terminal': { kind: 'image', src: muxy },
|
||||
'remctl-reminders-cli': { kind: 'image', src: remctl },
|
||||
'smartmover-shortcut': { kind: 'image', src: smartmoverShortcut },
|
||||
superkey: { kind: 'image', src: superkey },
|
||||
tablepro: { kind: 'image', src: tablepro },
|
||||
timelined: { kind: 'image', src: timelined },
|
||||
tot: { kind: 'image', src: tot },
|
||||
'valchoose-barf-bags': { kind: 'image', src: valchooseBarfBags },
|
||||
'zenteek-music-player': { kind: 'image', src: zenteek },
|
||||
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 },
|
||||
@@ -78,3 +67,8 @@ export const heroes: Record<string, Hero | undefined> = {
|
||||
shottr: { kind: 'video', src: shottr },
|
||||
typewhisper: { kind: 'video', src: typewhisper },
|
||||
};
|
||||
|
||||
export const heroes: Record<string, Hero | undefined> = {
|
||||
...discovered,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -31,6 +31,9 @@ const linkLabel =
|
||||
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
|
||||
|
||||
const external = resolveExternalLink(item.data.link);
|
||||
const amazon = item.data.amazonLink
|
||||
? resolveExternalLink(item.data.amazonLink)
|
||||
: undefined;
|
||||
|
||||
const description =
|
||||
item.data.description ?? item.data.subtitle ?? `A find: ${item.data.name}.`;
|
||||
@@ -109,7 +112,20 @@ const breadcrumbs = buildBreadcrumbs([
|
||||
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||
</p>
|
||||
|
||||
{external.isAffiliate && (
|
||||
{amazon && (
|
||||
<p class="external">
|
||||
<a
|
||||
href={amazon.href}
|
||||
target="_blank"
|
||||
rel={amazon.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
||||
>
|
||||
Also on Amazon ↗
|
||||
</a>
|
||||
{amazon.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(external.isAffiliate || amazon?.isAffiliate) && (
|
||||
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { heroes } from '../lib/hero';
|
||||
import { bundleMosaic } from '../lib/bundle-mosaic';
|
||||
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
||||
|
||||
const HERO_SLUG = 'continue-y-n-game';
|
||||
import pick from '../data/pick.json';
|
||||
|
||||
// Pick of the day. Set in src/data/pick.json; null falls back to the latest review.
|
||||
const HERO_SLUG: string | null = pick.hero;
|
||||
const INITIAL_GRID = 24;
|
||||
const BATCH_SIZE = 7;
|
||||
|
||||
@@ -69,7 +72,9 @@ const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
|
||||
const heroIndex = tiles.findIndex(
|
||||
(t) => t.kind === 'review' && t.href === `/reviews/${HERO_SLUG}/`
|
||||
);
|
||||
const hero = heroIndex >= 0 ? tiles[heroIndex] : reviewTiles[0];
|
||||
// Fall back to the newest review (tiles are sorted by date desc).
|
||||
const hero =
|
||||
heroIndex >= 0 ? tiles[heroIndex] : tiles.find((t) => t.kind === 'review');
|
||||
const rest = tiles.filter((t) => t !== hero);
|
||||
|
||||
const lastUpdated = tiles[0]?.date;
|
||||
|
||||
@@ -39,6 +39,9 @@ const linkLabel =
|
||||
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
|
||||
|
||||
const external = review.data.link ? resolveExternalLink(review.data.link) : undefined;
|
||||
const amazon = review.data.amazonLink
|
||||
? resolveExternalLink(review.data.amazonLink)
|
||||
: undefined;
|
||||
|
||||
const fromFindItem = review.data.fromFind
|
||||
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
|
||||
@@ -111,7 +114,20 @@ const categoryLabel =
|
||||
</p>
|
||||
)}
|
||||
|
||||
{external?.isAffiliate && (
|
||||
{amazon && (
|
||||
<p class="external">
|
||||
<a
|
||||
href={amazon.href}
|
||||
target="_blank"
|
||||
rel={amazon.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
||||
>
|
||||
Also on Amazon ↗
|
||||
</a>
|
||||
{amazon.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(external?.isAffiliate || amazon?.isAffiliate) && (
|
||||
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
||||
)}
|
||||
|
||||
@@ -121,7 +137,7 @@ const categoryLabel =
|
||||
label="Make today's pick"
|
||||
action="set-pick"
|
||||
slug={review.id}
|
||||
confirm={`This will set ${review.data.name} as the homepage hero (rewrites HERO_SLUG in src/pages/index.astro).`}
|
||||
confirm={`This will set ${review.data.name} as the homepage hero (writes src/data/pick.json).`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user