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:
@@ -11,6 +11,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user