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

Code, configs, README, CHANGELOG, sources/log files, .claude.
Subsequent chunks land the renamed media files.
This commit is contained in:
2026-05-04 17:54:14 -04:00
parent e873460465
commit 5dcd99361f
25 changed files with 1138 additions and 243 deletions
+1
View File
@@ -11,6 +11,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
---
+87 -14
View File
@@ -1,11 +1,14 @@
---
import { Image } from 'astro:assets';
import type { FindItem } from '../lib/find-items';
import { findHero } from '../lib/find-hero';
import SourceBadge from './SourceBadge.astro';
interface Props {
item: FindItem;
}
const { item } = Astro.props;
const hero = findHero(item.id);
function host(url: string) {
try {
@@ -16,36 +19,64 @@ function host(url: string) {
}
---
<a href={`/find/${item.id}/`} class="find-card">
<div class="head">
<span class="name">{item.data.name}</span>
<SourceBadge source={item.data.source} />
<article class:list={['find-card', { 'has-hero': !!hero }]}>
{import.meta.env.DEV && (
<label class="edit-checkbox" aria-label={`Select ${item.data.name}`}>
<input type="checkbox" data-find-checkbox data-slug={item.id} />
</label>
)}
{hero && (
<Image
class="media"
src={hero}
widths={[320, 480, 640]}
sizes="(max-width: 520px) 92vw, (max-width: 880px) 45vw, 240px"
loading="lazy"
decoding="async"
alt={item.data.name}
/>
)}
<div class="content">
<div class="head">
<a href={`/find/${item.id}/`} class="name">{item.data.name}</a>
<SourceBadge source={item.data.source} />
</div>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="external">
<span class="ext-label">→</span>
<span class="host">{host(item.data.link)}</span>
</p>
</div>
{item.data.subtitle && <p class="subtitle">{item.data.subtitle}</p>}
<p class="external">
<span class="ext-label">→</span>
<span class="host">{host(item.data.link)}</span>
</p>
</a>
</article>
<style>
.find-card {
display: block;
position: relative;
border-radius: 8px;
border: 1px solid var(--rule);
background: var(--bg);
padding: 0.95rem 1.05rem 1rem;
color: inherit;
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.find-card:hover {
text-decoration: none;
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
}
.find-card:hover .name { color: var(--accent); }
.media {
display: block;
width: 100%;
height: auto;
background: var(--card-fill);
border-bottom: 1px solid var(--rule);
}
.content {
padding: 0.95rem 1.05rem 1rem;
}
.head {
display: flex;
flex-direction: column;
@@ -58,8 +89,23 @@ function host(url: string) {
font-size: 1.05rem;
color: var(--fg);
letter-spacing: -0.01em;
text-decoration: none;
transition: color 0.18s ease;
}
.name:hover { text-decoration: none; }
/* Stretched click target — covers the whole card. */
.name::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
/* Anchors inside the card (e.g. the source badge) sit above the stretched target. */
.find-card :global(a:not(.name)) {
position: relative;
z-index: 1;
}
.subtitle {
margin: 0 0 0.6rem;
color: var(--muted);
@@ -77,4 +123,31 @@ function host(url: string) {
align-items: baseline;
}
.ext-label { color: var(--accent); font-family: ui-sans-serif, system-ui, sans-serif; }
.edit-checkbox {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 3;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--rule);
border-radius: 4px;
cursor: pointer;
}
.edit-checkbox input {
width: 1rem;
height: 1rem;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.find-card:has(.edit-checkbox input:checked) {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent);
}
</style>
-35
View File
@@ -1,35 +0,0 @@
---
import type { FindItem } from '../lib/find-items';
import FindCard from './FindCard.astro';
interface Props {
items: FindItem[];
}
const { items } = Astro.props;
---
<ul class="finds-grid">
{items.map((item) => (
<li>
<FindCard item={item} />
</li>
))}
</ul>
<style>
.finds-grid {
column-count: 3;
column-gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 880px) { .finds-grid { column-count: 2; } }
@media (max-width: 520px) { .finds-grid { column-count: 1; } }
.finds-grid > li {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
</style>
+29 -22
View File
@@ -42,10 +42,11 @@ const dataBatch = batch != null ? String(batch) : undefined;
source.parentElement?.insertBefore(masonry, source);
source.remove();
const sentinel = document.createElement('div');
sentinel.className = 'masonry-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
masonry.parentElement?.insertBefore(sentinel, masonry.nextSibling);
const loadMore = document.createElement('button');
loadMore.type = 'button';
loadMore.className = 'masonry-load-more';
loadMore.textContent = 'Load more';
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
let cols: HTMLDivElement[] = [];
let stash: HTMLElement[] = tiles.slice();
@@ -94,31 +95,23 @@ const dataBatch = batch != null ? String(batch) : undefined;
}
function rebuild() {
const visibleCount = placed.length;
stash = placed.concat(stash);
placed = [];
buildColumns();
placeBatch(stash.length, false);
placeBatch(visibleCount, false);
}
buildColumns();
placeBatch(Math.min(initial, stash.length), false);
if (progressive && stash.length > 0) {
let busy = false;
const io = new IntersectionObserver((entries) => {
if (busy || stash.length === 0) return;
if (!entries[0].isIntersecting) return;
busy = true;
loadMore.addEventListener('click', () => {
placeBatch(Math.min(batch, stash.length), true);
setTimeout(() => { busy = false; }, 120);
if (stash.length === 0) {
io.disconnect();
sentinel.remove();
}
}, { rootMargin: '600px 0px' });
io.observe(sentinel);
if (stash.length === 0) loadMore.remove();
});
} else {
sentinel.remove();
loadMore.remove();
}
let resizeTimer: number | undefined;
@@ -173,9 +166,23 @@ const dataBatch = batch != null ? String(batch) : undefined;
@media (prefers-reduced-motion: reduce) {
.masonry-tile.revealing { animation: none; }
}
.masonry-sentinel {
height: 1px;
margin-top: -1px;
pointer-events: none;
.masonry-load-more {
display: block;
margin: 1.5rem auto 0;
padding: 0.55rem 1.1rem;
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
background: transparent;
border: 1px solid var(--rule);
border-radius: 999px;
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease;
}
.masonry-load-more:hover {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
}
</style>