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,8 +1,8 @@
|
||||
---
|
||||
import type { FindsPost } from '../lib/backlinks';
|
||||
import type { Bundle } from '../lib/backlinks';
|
||||
|
||||
interface Props {
|
||||
posts: FindsPost[];
|
||||
posts: Bundle[];
|
||||
label?: string;
|
||||
}
|
||||
const { posts, label = 'Featured in' } = Astro.props;
|
||||
@@ -15,7 +15,7 @@ const { posts, label = 'Featured in' } = Astro.props;
|
||||
{posts.map((p, i) => (
|
||||
<>
|
||||
{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>
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
let command = btn.dataset.command || '';
|
||||
let description = btn.dataset.confirm || '';
|
||||
|
||||
if (btn.dataset.editAction === 'finds-collection') {
|
||||
if (btn.dataset.editAction === 'bundle-from-selection') {
|
||||
const checked = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>('[data-find-checkbox]:checked')
|
||||
);
|
||||
@@ -144,10 +144,10 @@
|
||||
return;
|
||||
}
|
||||
const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(',');
|
||||
command = `/build-finds ${slugs}`;
|
||||
description = `Build a finds collection from ${checked.length} selected find${
|
||||
command = `/build-bundle ${slugs}`;
|
||||
description = `Bundle ${checked.length} selected find${
|
||||
checked.length === 1 ? '' : 's'
|
||||
}.`;
|
||||
} under one theme.`;
|
||||
}
|
||||
|
||||
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({
|
||||
pattern: '**/index.mdx',
|
||||
base: './src/content/finds',
|
||||
base: './src/content/bundles',
|
||||
generateId: stripIndex,
|
||||
}),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
category: z.string().default('finds'),
|
||||
category: z.string().default('bundles'),
|
||||
tags: z.array(z.string()).default([]),
|
||||
description: 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>
|
||||
<li><a href="/grid/">Reviews</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>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -417,6 +417,120 @@ const year = new Date().getFullYear();
|
||||
font-style: italic;
|
||||
}
|
||||
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>
|
||||
{import.meta.env.DEV && <EditConfirm />}
|
||||
</body>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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[]> {
|
||||
const all = await getCollection('finds');
|
||||
export async function bundlesReferencing(slug: string): Promise<Bundle[]> {
|
||||
const all = await getCollection('bundles');
|
||||
return all
|
||||
.filter((p) => p.data.items.includes(slug))
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
const modules = import.meta.glob<{ default: ImageMetadata }>(
|
||||
'../generated/list-mosaics/*.jpg',
|
||||
'../generated/bundle-mosaics/*.jpg',
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
const mosaics = new Map<string, ImageMetadata>();
|
||||
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);
|
||||
}
|
||||
|
||||
export function findListMosaic(slug: string): ImageMetadata | undefined {
|
||||
export function bundleMosaic(slug: string): ImageMetadata | undefined {
|
||||
return mosaics.get(slug);
|
||||
}
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export type Entry = {
|
||||
type: 'review' | 'post' | 'finds';
|
||||
type: 'review' | 'post' | 'bundle';
|
||||
id: string;
|
||||
href: string;
|
||||
date: Date;
|
||||
@@ -14,7 +14,7 @@ export type Entry = {
|
||||
export async function getAllEntries(): Promise<Entry[]> {
|
||||
const reviews = await getCollection('reviews');
|
||||
const posts = await getCollection('posts');
|
||||
const findsPosts = await getCollection('finds');
|
||||
const bundles = await getCollection('bundles');
|
||||
|
||||
const r: Entry[] = reviews.map((e) => ({
|
||||
type: 'review',
|
||||
@@ -37,10 +37,10 @@ export async function getAllEntries(): Promise<Entry[]> {
|
||||
primary: e.data.title,
|
||||
}));
|
||||
|
||||
const f: Entry[] = findsPosts.map((e) => ({
|
||||
type: 'finds',
|
||||
const b: Entry[] = bundles.map((e) => ({
|
||||
type: 'bundle',
|
||||
id: e.id,
|
||||
href: `/finds/${e.id}/`,
|
||||
href: `/bundles/${e.id}/`,
|
||||
date: e.data.date,
|
||||
category: e.data.category,
|
||||
tags: e.data.tags,
|
||||
@@ -48,7 +48,7 @@ export async function getAllEntries(): Promise<Entry[]> {
|
||||
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 = {
|
||||
@@ -57,7 +57,7 @@ export type SiteStats = {
|
||||
reviews: number;
|
||||
finds: number;
|
||||
posts: number;
|
||||
lists: number;
|
||||
bundles: number;
|
||||
};
|
||||
|
||||
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 finds = findItems.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 = [
|
||||
...entries.map((e) => e.date),
|
||||
...findItems.map((f) => f.data.date),
|
||||
].sort((a, b) => b.valueOf() - a.valueOf());
|
||||
return {
|
||||
total: reviews + finds + posts + lists,
|
||||
total: reviews + finds + posts + bundles,
|
||||
lastUpdated: allDates[0] ?? null,
|
||||
reviews,
|
||||
finds,
|
||||
posts,
|
||||
lists,
|
||||
bundles,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
import { heroes } from './hero';
|
||||
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 FALLBACK_PATHS: Record<OgFallback, string> = {
|
||||
reviews: '/og/reviews.png',
|
||||
finds: '/og/finds.png',
|
||||
bundles: '/og/bundles.png',
|
||||
posts: '/og/posts.png',
|
||||
site: '/og/default.png',
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const finds = await getCollection('finds');
|
||||
return finds.map((post) => ({
|
||||
const bundles = await getCollection('bundles');
|
||||
return bundles.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}));
|
||||
@@ -29,17 +29,17 @@ const description =
|
||||
|
||||
const ogImage = buildOgImage(
|
||||
items[0]?.data.promotedTo,
|
||||
'finds',
|
||||
'bundles',
|
||||
`${post.data.title} cover image`
|
||||
);
|
||||
|
||||
const articleSchema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'@id': absoluteUrl(`/finds/${post.id}/#article`),
|
||||
'@id': absoluteUrl(`/bundles/${post.id}/#article`),
|
||||
headline: post.data.title,
|
||||
description,
|
||||
url: absoluteUrl(`/finds/${post.id}/`),
|
||||
url: absoluteUrl(`/bundles/${post.id}/`),
|
||||
datePublished: post.data.date.toISOString(),
|
||||
author: PERSON_AUTHOR,
|
||||
image: ogImage.url,
|
||||
@@ -53,7 +53,7 @@ const itemList = buildItemList(
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ 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} />)}
|
||||
</Masonry>
|
||||
|
||||
{
|
||||
post.data.tags.length > 0 && (
|
||||
<footer class="tags">
|
||||
{post.data.tags.map((tag) => (
|
||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
{(post.data.tags.length > 0 ||
|
||||
(post.data.category && post.data.category !== 'bundles')) && (
|
||||
<aside class="colophon" aria-label="Colophon">
|
||||
<dl>
|
||||
{post.data.category && post.data.category !== 'bundles' && (
|
||||
<>
|
||||
<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>
|
||||
</article>
|
||||
@@ -105,16 +124,6 @@ const breadcrumbs = buildBreadcrumbs([
|
||||
font-style: normal;
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -3,12 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Masonry from '../../components/Masonry.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
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()
|
||||
);
|
||||
const lastUpdated = lists[0]?.data.date;
|
||||
const lastUpdated = bundles[0]?.data.date;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -19,18 +19,18 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Lists"
|
||||
description={`All ${lists.length} themed lists on Unique.`}
|
||||
subheader={`All ${lists.length} themed lists.`}
|
||||
title="Bundles"
|
||||
description={`All ${bundles.length} themed bundles on Unique.`}
|
||||
subheader={`All ${bundles.length} themed bundles.`}
|
||||
lastUpdated={lastUpdated}
|
||||
noindex
|
||||
>
|
||||
<Masonry>
|
||||
{lists.map((l) => {
|
||||
const mosaic = findListMosaic(l.id);
|
||||
{bundles.map((b) => {
|
||||
const mosaic = bundleMosaic(b.id);
|
||||
return (
|
||||
<article>
|
||||
<a href={`/finds/${l.id}/`} class="list-tile">
|
||||
<a href={`/bundles/${b.id}/`} class="bundle-tile">
|
||||
{mosaic && (
|
||||
<div class="mosaic">
|
||||
<Image
|
||||
@@ -44,12 +44,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
</div>
|
||||
)}
|
||||
<div class="body">
|
||||
<h2 class="title">{l.data.title}</h2>
|
||||
{l.data.blurb && <p class="blurb">{l.data.blurb}</p>}
|
||||
<h2 class="title">{b.data.title}</h2>
|
||||
{b.data.blurb && <p class="blurb">{b.data.blurb}</p>}
|
||||
<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>{l.data.items.length} picks</span>
|
||||
<span>{b.data.items.length} picks</span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -59,7 +59,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
</Masonry>
|
||||
|
||||
<style>
|
||||
.list-tile {
|
||||
.bundle-tile {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
@@ -67,12 +67,12 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
color: inherit;
|
||||
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.list-tile:hover {
|
||||
.bundle-tile:hover {
|
||||
text-decoration: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
||||
box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
.list-tile:hover .title { color: var(--accent); }
|
||||
.bundle-tile:hover .title { color: var(--accent); }
|
||||
|
||||
.mosaic {
|
||||
display: block;
|
||||
@@ -2,10 +2,10 @@
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import { Image } from 'astro:assets';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import SourceBadge from '../../components/SourceBadge.astro';
|
||||
import Backlinks from '../../components/Backlinks.astro';
|
||||
import { findsReferencing } from '../../lib/backlinks';
|
||||
import { bundlesReferencing } from '../../lib/backlinks';
|
||||
import { findHero } from '../../lib/find-hero';
|
||||
import { sourceSlug } from '../../lib/sources';
|
||||
import EditAction from '../../components/EditAction.astro';
|
||||
import {
|
||||
resolveExternalLink,
|
||||
@@ -24,7 +24,7 @@ export async function getStaticPaths() {
|
||||
|
||||
const { item } = Astro.props;
|
||||
const { Content } = await render(item);
|
||||
const featuringPosts = await findsReferencing(item.id);
|
||||
const featuringPosts = await bundlesReferencing(item.id);
|
||||
const hero = findHero(item.id);
|
||||
|
||||
const linkLabel =
|
||||
@@ -41,7 +41,7 @@ const canonicalOverride = item.data.promotedTo
|
||||
|
||||
const ogImage = buildOgImage(
|
||||
item.data.promotedTo,
|
||||
'finds',
|
||||
'bundles',
|
||||
`${item.data.name} preview image`
|
||||
);
|
||||
|
||||
@@ -82,10 +82,6 @@ const breadcrumbs = buildBreadcrumbs([
|
||||
jsonLd={[productSchema, breadcrumbs]}
|
||||
>
|
||||
<article>
|
||||
<header class="find-header">
|
||||
<p class="source-line"><SourceBadge source={item.data.source} /></p>
|
||||
</header>
|
||||
|
||||
{hero && (
|
||||
<Image
|
||||
class="hero"
|
||||
@@ -138,24 +134,29 @@ const breadcrumbs = buildBreadcrumbs([
|
||||
|
||||
<Backlinks posts={featuringPosts} />
|
||||
|
||||
{
|
||||
item.data.tags.length > 0 && (
|
||||
<footer class="tags">
|
||||
{item.data.tags.map((tag) => (
|
||||
<a href={`/tags/${tag}/`} class="tag">#{tag}</a>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
<aside class="colophon" aria-label="Colophon">
|
||||
<dl>
|
||||
<dt>Via</dt>
|
||||
<dd>
|
||||
<a href={`/sources/#${sourceSlug(item.data.source)}`}>{item.data.source}</a>
|
||||
</dd>
|
||||
{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>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.find-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.source-line { margin: 0; }
|
||||
.hero {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -168,40 +169,6 @@ const breadcrumbs = buildBreadcrumbs([
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.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 {
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0.85rem 1rem;
|
||||
@@ -210,16 +177,6 @@ const breadcrumbs = buildBreadcrumbs([
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.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; }
|
||||
.edit-toolbar {
|
||||
margin: 2rem 0 0;
|
||||
|
||||
@@ -35,14 +35,14 @@ const decorated = (await getAllFinds())
|
||||
const finds = decorated.map((d) => d.item);
|
||||
const lastUpdated = finds[0]?.data.date;
|
||||
|
||||
// Slugs referenced by any superpost in the `finds/` collection — dev-only filter.
|
||||
const inListSlugs = new Set(
|
||||
(await getCollection('finds')).flatMap((l) => l.data.items)
|
||||
// Slugs referenced by any bundle — dev-only filter.
|
||||
const bundledSlugs = new Set(
|
||||
(await getCollection('bundles')).flatMap((b) => b.data.items)
|
||||
);
|
||||
const isJoke = (f: (typeof finds)[number]) => f.id.startsWith('joke-');
|
||||
const isQuote = (f: (typeof finds)[number]) => f.id.startsWith('quote-');
|
||||
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 jokeCount = finds.filter(isJoke).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>
|
||||
<EditAction
|
||||
action="finds-collection"
|
||||
label="Make a finds collection"
|
||||
confirm="This will draft a themed finds list (.mdx in src/content/finds/) from the selected items."
|
||||
action="bundle-from-selection"
|
||||
label="Bundle this selection"
|
||||
confirm="This will draft a themed bundle (.mdx in src/content/bundles/) from the selected items."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+20
-20
@@ -4,10 +4,10 @@ import { Image } from 'astro:assets';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Masonry from '../components/Masonry.astro';
|
||||
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';
|
||||
|
||||
const HERO_SLUG = 'monitorcontrol';
|
||||
const HERO_SLUG = 'gum-shell';
|
||||
const INITIAL_GRID = 12;
|
||||
const BATCH_SIZE = 7;
|
||||
|
||||
@@ -22,23 +22,23 @@ type Tile =
|
||||
hero: ReturnType<typeof getReviewHero>;
|
||||
}
|
||||
| {
|
||||
kind: 'list';
|
||||
kind: 'bundle';
|
||||
href: string;
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string | undefined;
|
||||
date: Date;
|
||||
itemCount: number;
|
||||
mosaic: ReturnType<typeof findListMosaic>;
|
||||
mosaic: ReturnType<typeof bundleMosaic>;
|
||||
};
|
||||
|
||||
function getReviewHero(slug: string) {
|
||||
return heroes[slug];
|
||||
}
|
||||
|
||||
const [reviews, lists] = await Promise.all([
|
||||
const [reviews, bundles] = await Promise.all([
|
||||
getCollection('reviews'),
|
||||
getCollection('finds'),
|
||||
getCollection('bundles'),
|
||||
]);
|
||||
|
||||
const reviewTiles: Tile[] = reviews.map((review) => ({
|
||||
@@ -51,18 +51,18 @@ const reviewTiles: Tile[] = reviews.map((review) => ({
|
||||
hero: getReviewHero(review.id),
|
||||
}));
|
||||
|
||||
const listTiles: Tile[] = lists.map((list) => ({
|
||||
kind: 'list' as const,
|
||||
href: `/finds/${list.id}/`,
|
||||
id: list.id,
|
||||
name: list.data.title,
|
||||
subtitle: list.data.blurb,
|
||||
date: list.data.date,
|
||||
itemCount: list.data.items.length,
|
||||
mosaic: findListMosaic(list.id),
|
||||
const bundleTiles: Tile[] = bundles.map((bundle) => ({
|
||||
kind: 'bundle' as const,
|
||||
href: `/bundles/${bundle.id}/`,
|
||||
id: bundle.id,
|
||||
name: bundle.data.title,
|
||||
subtitle: bundle.data.blurb,
|
||||
date: bundle.data.date,
|
||||
itemCount: bundle.data.items.length,
|
||||
mosaic: bundleMosaic(bundle.id),
|
||||
}));
|
||||
|
||||
const tiles: Tile[] = [...reviewTiles, ...listTiles].sort(
|
||||
const tiles: Tile[] = [...reviewTiles, ...bundleTiles].sort(
|
||||
(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;
|
||||
return (
|
||||
<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' && (
|
||||
<Image
|
||||
class="media"
|
||||
@@ -155,8 +155,8 @@ const itemList = buildItemList(
|
||||
/>
|
||||
)}
|
||||
<div class="text">
|
||||
{tile.kind === 'list' && (
|
||||
<span class="eyebrow">List · {tile.itemCount} picks</span>
|
||||
{tile.kind === 'bundle' && (
|
||||
<span class="eyebrow">Bundle · {tile.itemCount} picks</span>
|
||||
)}
|
||||
<span class="name">{tile.name}</span>
|
||||
{tile.subtitle && <span class="subtitle">{tile.subtitle}</span>}
|
||||
@@ -260,7 +260,7 @@ const itemList = buildItemList(
|
||||
color: var(--accent);
|
||||
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-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
|
||||
@@ -2,10 +2,10 @@ import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function GET(context) {
|
||||
const [reviews, posts, finds] = await Promise.all([
|
||||
const [reviews, posts, bundles] = await Promise.all([
|
||||
getCollection('reviews'),
|
||||
getCollection('posts'),
|
||||
getCollection('finds'),
|
||||
getCollection('bundles'),
|
||||
]);
|
||||
|
||||
const items = [
|
||||
@@ -23,12 +23,12 @@ export async function GET(context) {
|
||||
link: `/posts/${p.id}/`,
|
||||
categories: [p.data.category, ...p.data.tags],
|
||||
})),
|
||||
...finds.map((f) => ({
|
||||
title: f.data.title,
|
||||
description: f.data.description ?? f.data.blurb ?? '',
|
||||
pubDate: f.data.date,
|
||||
link: `/finds/${f.id}/`,
|
||||
categories: [f.data.category, ...f.data.tags],
|
||||
...bundles.map((b) => ({
|
||||
title: b.data.title,
|
||||
description: b.data.description ?? b.data.blurb ?? '',
|
||||
pubDate: b.data.date,
|
||||
link: `/bundles/${b.id}/`,
|
||||
categories: [b.data.category, ...b.data.tags],
|
||||
})),
|
||||
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
<BaseLayout
|
||||
title="Search"
|
||||
description="Search reviews, finds, lists, and posts across Unique."
|
||||
description="Search reviews, finds, bundles, and posts across Unique."
|
||||
subheader="Search"
|
||||
noindex
|
||||
>
|
||||
@@ -13,7 +13,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
id="q"
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search reviews, finds, lists, posts…"
|
||||
placeholder="Search reviews, finds, bundles, posts…"
|
||||
aria-label="Search"
|
||||
autofocus
|
||||
/>
|
||||
@@ -29,7 +29,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
const results = document.getElementById('results') as HTMLDivElement;
|
||||
|
||||
type Entry = {
|
||||
type: 'review' | 'find' | 'list' | 'post';
|
||||
type: 'review' | 'find' | 'bundle' | 'post';
|
||||
url: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -42,7 +42,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
const typeLabels: Record<Entry['type'], string> = {
|
||||
review: 'Review',
|
||||
find: 'Find',
|
||||
list: 'List',
|
||||
bundle: 'Bundle',
|
||||
post: 'Post',
|
||||
};
|
||||
|
||||
|
||||
+10
-10
@@ -1,10 +1,10 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function GET() {
|
||||
const [reviews, finds, lists, posts] = await Promise.all([
|
||||
const [reviews, finds, bundles, posts] = await Promise.all([
|
||||
getCollection('reviews'),
|
||||
getCollection('find'),
|
||||
getCollection('finds'),
|
||||
getCollection('bundles'),
|
||||
getCollection('posts'),
|
||||
]);
|
||||
|
||||
@@ -28,14 +28,14 @@ export async function GET() {
|
||||
blurb: f.data.description ?? '',
|
||||
source: f.data.source,
|
||||
})),
|
||||
...lists.map((l) => ({
|
||||
type: 'list',
|
||||
url: `/finds/${l.id}/`,
|
||||
title: l.data.title,
|
||||
subtitle: l.data.blurb ?? '',
|
||||
date: l.data.date.toISOString(),
|
||||
tags: l.data.tags ?? [],
|
||||
blurb: l.data.description ?? '',
|
||||
...bundles.map((b) => ({
|
||||
type: 'bundle',
|
||||
url: `/bundles/${b.id}/`,
|
||||
title: b.data.title,
|
||||
subtitle: b.data.blurb ?? '',
|
||||
date: b.data.date.toISOString(),
|
||||
tags: b.data.tags ?? [],
|
||||
blurb: b.data.description ?? '',
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: 'post',
|
||||
|
||||
Reference in New Issue
Block a user