site: founding post, headline post layout, masonry grid, source links
- New "Why this exists" post; masthead "founded" links to it
- Post template: serif headline + date in masthead, "Filed under {category}" in footer
- Drop counts from footer Collections list
- Masonry component for home + sources grid; source badges link to /sources/#slug
- Theme toggle (settings page + cog) with dark-mode-aware tokens
- Six new themed find lists; cluster-finds skill
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
---
|
||||
interface Props {
|
||||
initial?: number;
|
||||
batch?: number;
|
||||
}
|
||||
const { initial, batch } = Astro.props;
|
||||
const dataInitial = initial != null ? String(initial) : undefined;
|
||||
const dataBatch = batch != null ? String(batch) : undefined;
|
||||
---
|
||||
|
||||
<div
|
||||
class="masonry-source"
|
||||
data-initial={dataInitial}
|
||||
data-batch={dataBatch}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function colCount(): number {
|
||||
const w = window.innerWidth;
|
||||
if (w <= 520) return 1;
|
||||
if (w <= 880) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
for (const source of sources) {
|
||||
const tiles = Array.from(source.children) as HTMLElement[];
|
||||
for (const t of tiles) t.classList.add('masonry-tile');
|
||||
|
||||
const initialAttr = source.dataset.initial;
|
||||
const batchAttr = source.dataset.batch;
|
||||
const progressive = initialAttr !== undefined && batchAttr !== undefined;
|
||||
const initial = progressive ? Number(initialAttr) : tiles.length;
|
||||
const batch = progressive ? Number(batchAttr) : tiles.length;
|
||||
|
||||
const masonry = document.createElement('div');
|
||||
masonry.className = 'masonry';
|
||||
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);
|
||||
|
||||
let cols: HTMLDivElement[] = [];
|
||||
let stash: HTMLElement[] = tiles.slice();
|
||||
let placed: HTMLElement[] = [];
|
||||
let currentCols = colCount();
|
||||
|
||||
function buildColumns() {
|
||||
masonry.replaceChildren();
|
||||
cols = [];
|
||||
for (let i = 0; i < currentCols; i++) {
|
||||
const c = document.createElement('div');
|
||||
c.className = 'masonry-col';
|
||||
masonry.appendChild(c);
|
||||
cols.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
function shortestColumn(): HTMLDivElement {
|
||||
let min = Infinity;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const h = cols[i].offsetHeight;
|
||||
if (h < min) { min = h; idx = i; }
|
||||
}
|
||||
return cols[idx];
|
||||
}
|
||||
|
||||
function placeOne(tile: HTMLElement, animate: boolean) {
|
||||
if (animate && !prefersReducedMotion) {
|
||||
tile.classList.add('revealing');
|
||||
tile.addEventListener(
|
||||
'animationend',
|
||||
() => tile.classList.remove('revealing'),
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
shortestColumn().appendChild(tile);
|
||||
}
|
||||
|
||||
function placeBatch(n: number, animate: boolean) {
|
||||
const slice = stash.splice(0, n);
|
||||
for (const t of slice) {
|
||||
placed.push(t);
|
||||
placeOne(t, animate);
|
||||
}
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
stash = placed.concat(stash);
|
||||
placed = [];
|
||||
buildColumns();
|
||||
placeBatch(stash.length, 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;
|
||||
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);
|
||||
} else {
|
||||
sentinel.remove();
|
||||
}
|
||||
|
||||
let resizeTimer: number | undefined;
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeTimer) window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
const next = colCount();
|
||||
if (next !== currentCols) {
|
||||
currentCols = next;
|
||||
rebuild();
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
/* No-JS fallback: column-count masonry. JS replaces this with .masonry. */
|
||||
.masonry-source {
|
||||
column-count: 3;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
@media (max-width: 880px) { .masonry-source { column-count: 2; } }
|
||||
@media (max-width: 520px) { .masonry-source { column-count: 1; } }
|
||||
.masonry-source > * {
|
||||
break-inside: avoid;
|
||||
display: block;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* JS-built masonry: real columns, never reflow placed tiles. */
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.masonry-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.masonry-tile.revealing {
|
||||
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
@keyframes masonry-tile-in {
|
||||
from { opacity: 0; transform: translateY(22px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.masonry-tile.revealing { animation: none; }
|
||||
}
|
||||
.masonry-sentinel {
|
||||
height: 1px;
|
||||
margin-top: -1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,17 @@
|
||||
---
|
||||
import { sourceSlug } from '../lib/sources';
|
||||
|
||||
interface Props {
|
||||
source: string;
|
||||
prefix?: string;
|
||||
}
|
||||
const { source, prefix = 'via' } = Astro.props;
|
||||
const href = `/sources/#${sourceSlug(source)}`;
|
||||
---
|
||||
|
||||
<span class="source-badge">{prefix} <span class="name">{source}</span></span>
|
||||
<span class="source-badge">
|
||||
{prefix} <a href={href} class="name">{source}</a>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.source-badge {
|
||||
@@ -25,4 +30,7 @@ const { source, prefix = 'via' } = Astro.props;
|
||||
letter-spacing: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.source-badge .name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user