diff --git a/public/images/categories/case-studies-banner-1200.webp b/public/images/categories/case-studies-banner-1200.webp new file mode 100644 index 0000000..8c25e71 Binary files /dev/null and b/public/images/categories/case-studies-banner-1200.webp differ diff --git a/public/images/categories/case-studies-banner.svg b/public/images/categories/case-studies-banner.svg new file mode 100644 index 0000000..4ec33e7 --- /dev/null +++ b/public/images/categories/case-studies-banner.svg @@ -0,0 +1,324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/categories/case-studies-banner.webp b/public/images/categories/case-studies-banner.webp new file mode 100644 index 0000000..534b72d Binary files /dev/null and b/public/images/categories/case-studies-banner.webp differ diff --git a/scripts/gen-case-studies-banner.mjs b/scripts/gen-case-studies-banner.mjs new file mode 100644 index 0000000..d87c538 --- /dev/null +++ b/scripts/gen-case-studies-banner.mjs @@ -0,0 +1,239 @@ +/** + * Generates the case-studies portfolio banner — 21:9, no baked-in text. + * + * The consultant's spec for /categories/case-studies/ asks for a wide architecture + * visual: three production hosts, Docker, Prometheus and the automation flow, in a + * dark terminal grey-green with restrained warm-copper accents, rendered into a + * 240–300 px tall band beside the hero copy. Everything here is geometry, no words: + * at that display height any label would be unreadable, and the headline copy has to + * stay semantic HTML. + * + * Deterministic (seeded), so regenerating never churns the committed asset. + * + * node scripts/gen-case-studies-banner.mjs + * + * Writes: + * public/images/categories/case-studies-banner.webp 2400x1029 (21:9) + * public/images/categories/case-studies-banner-1200.webp 1200x514 + * public/images/categories/case-studies-banner.svg the source geometry + */ +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; + +const W = 2400; +const H = 1029; // 21:9 + +// Host panels: the real fleet is 3 hosts / 162 containers, and the visual carries the +// same proportions (Unraid 62, Synology 78, VPS 22) so the art is not decorative lies. +const HOSTS = [ + { x: 130, y: 300, w: 300, rows: 10, cols: 10, count: 62, hot: [3, 17, 41] }, + { x: 520, y: 210, w: 320, rows: 12, cols: 10, count: 78, hot: [2, 9, 33, 60] }, + { x: 930, y: 396, w: 280, rows: 7, cols: 10, count: 22, hot: [6, 15] }, +]; + +const HUB = { cx: 1560, cy: 514 }; + +// Deterministic pseudo-random: same seed -> same picture, forever. +function rnd(seed) { + let s = seed; + return () => { + s = (s * 1103515245 + 12345) % 2147483648; + return s / 2147483648; + }; +} + +function hostHeight(host) { + return 26 + 14 * 2 + host.rows * 15; +} + +function hostPanel(host, seed) { + const r = rnd(seed); + const head = 26; + const pad = 14; + const cols = host.cols; + const cw = 20; + const ch = 11; + const gx = (host.w - pad * 2 - cols * cw) / (cols - 1); + const gy = 15; + const h = hostHeight(host); + + const chips = []; + let drawn = 0; + const hot = new Set(host.hot); + for (let row = 0; row < host.rows && drawn < host.count; row++) { + for (let col = 0; col < cols && drawn < host.count; col++) { + const i = row * cols + col; + const accent = hot.has(i) + ? '#d08b4a' + : r() > 0.86 + ? '#4fae7a' + : r() > 0.5 + ? '#3a7157' + : '#2e5c48'; + chips.push( + ``, + ); + drawn++; + } + } + + return ` + + + + + + + ${chips.join('\n ')} + + + `; +} + +/** A Prometheus-ish readout hub: concentric rings, a flame glyph, four metric tiles. */ +function hub() { + const { cx, cy } = HUB; + const tiles = [ + { top: cy - 196, pts: '0,34 14,26 28,30 42,16 56,20 70,8 84,12' }, + { top: cy - 66, pts: '0,10 14,22 28,14 42,28 56,18 70,30 84,22' }, + { top: cy + 66, pts: '0,28 14,12 28,20 42,6 56,14 70,10 84,18' }, + { top: cy + 196, pts: '0,20 14,18 28,24 42,12 56,16 70,6 84,14' }, + ].map((t) => ({ ...t, x: cx + 206, y: t.top - 43 })); + + const tileSvg = tiles.map( + (t) => ` + + + + `, + ); + + return ` + + + + + + + ${tileSvg.join('\n ')} + `; +} + +/** + * Hosts → hub wiring, drawn as a network bus rather than three curves across the + * panels: each host drops a stub into a shared copper bus below the fleet, and the + * bus rises into the hub's outer ring. Crossing the panels looked like a mistake at + * banner size, which is exactly the kind of detail nobody should have to decode. + */ +function flows() { + const busY = 760; + const busX = HUB.cx; + const drops = HOSTS.map((host) => { + const x = host.x + host.w / 2; + const y0 = host.y + hostHeight(host) + 22; + return ` + + + + `; + }).join('\n '); + + return ` + + + + ${drops} + `; +} + +/** The automation tail: hub → three workflow nodes, drawn as a green chain. */ +function automation() { + const nodes = [ + { x: 1960, y: 320, w: 170, h: 112 }, + { x: 2160, y: 470, w: 170, h: 112 }, + { x: 1960, y: 620, w: 170, h: 112 }, + ]; + const chain = nodes + .map( + (n) => ` + + + + + + + `, + ) + .join('\n '); + + return ` + + + + ${chain} + `; +} + +const svg = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + ${Array.from({ length: 24 }, (_, i) => ``).join('\n ')} + + + ${HOSTS.map((h, i) => hostPanel(h, 97 + i * 13)).join('\n ')} + ${flows()} + ${hub()} + ${automation()} + +`; + +const outDir = path.join(process.cwd(), 'public', 'images', 'categories'); +await mkdir(outDir, { recursive: true }); + +await sharp(Buffer.from(svg)).webp({ quality: 82 }).toFile(path.join(outDir, 'case-studies-banner.webp')); +await sharp(Buffer.from(svg)) + .resize(1200, 514, { fit: 'fill' }) + .webp({ quality: 80 }) + .toFile(path.join(outDir, 'case-studies-banner-1200.webp')); + +// Keep the source next to the render so a future edit does not have to reverse-engineer +// the geometry out of a WebP. +await writeFile(path.join(outDir, 'case-studies-banner.svg'), svg, 'utf8'); + +const report = []; +for (const f of ['case-studies-banner.webp', 'case-studies-banner-1200.webp']) { + const { size } = await stat(path.join(outDir, f)); + report.push(`${f}: ${(size / 1024).toFixed(1)} KB`); +} +console.log(report.join('\n')); diff --git a/scripts/probe-layout.mjs b/scripts/probe-layout.mjs new file mode 100644 index 0000000..cdb6019 --- /dev/null +++ b/scripts/probe-layout.mjs @@ -0,0 +1,194 @@ +/** + * Layout probe over CDP: horizontal-overflow + interaction checks at fixed viewports. + * + * Uses Node's built-in WebSocket client (Node >= 22) so nothing has to be installed. + * + * node scripts/probe-layout.mjs [baseUrl] + * + * Requires a headless Chrome with remote debugging and a static server on `baseUrl` + * (default http://127.0.0.1:4321, i.e. `python -m http.server 4321 --directory dist`). + * + * Checks per page × width: + * - documentElement.scrollWidth === innerWidth (no horizontal scroll) + * - names every element whose right edge exceeds the viewport by > 1px (so a failure + * is actionable instead of "something overflows") + * - the /posts/ featured block collapses on the first keystroke and comes back when + * the filter is cleared + */ +const BASE = process.argv[2] ?? 'http://127.0.0.1:4321'; +const CDP = process.argv[3] ?? 'http://127.0.0.1:9333'; + +const PAGES = [ + '/', + '/posts/', + '/zh/posts/', + '/categories/', + '/categories/case-studies/', + '/zh/categories/case-studies/', + '/categories/devops/', + '/tags/docker/', + '/about/', +]; + +const WIDTHS = [1920, 1280, 960, 768, 430, 390, 375, 320]; + +async function target(url) { + const res = await fetch(`${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' }); + if (!res.ok) throw new Error(`cannot create target: ${res.status} ${await res.text()}`); + return res.json(); +} + +function connect(wsUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + ws.addEventListener('open', () => resolve(ws)); + ws.addEventListener('error', reject); + }); +} + +let nextId = 0; +function send(ws, method, params = {}) { + const id = ++nextId; + return new Promise((resolve, reject) => { + const onMessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id !== id) return; + ws.removeEventListener('message', onMessage); + if (msg.error) reject(new Error(`${method}: ${msg.error.message}`)); + else resolve(msg.result); + }; + ws.addEventListener('message', onMessage); + ws.send(JSON.stringify({ id, method, params })); + }); +} + +async function evaluate(ws, expression) { + const r = await send(ws, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + userGesture: true, + }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.text); + return r.result.value; +} + +const OVERFLOW_JS = `(() => { + const vw = window.innerWidth; + const offenders = []; + for (const el of document.querySelectorAll('body *')) { + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + if (r.right > vw + 1 || r.left < -1) { + offenders.push({ + tag: el.tagName.toLowerCase(), + cls: (el.className && String(el.className).slice(0, 60)) || '', + left: Math.round(r.left), + right: Math.round(r.right), + }); + } + } + return { + vw, + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + docScroll: document.documentElement.scrollHeight, + offenders: offenders.slice(0, 6), + }; +})()`; + +const COLLAPSE_JS = `(async () => { + const feat = document.getElementById('archive-featured'); + const q = document.getElementById('archive-q'); + const status = document.getElementById('archive-status-text'); + const clear = document.getElementById('archive-clear'); + if (!feat || !q) return { skipped: true }; + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const before = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() }; + q.value = 'docker'; + q.dispatchEvent(new Event('input', { bubbles: true })); + await wait(420); + const during = { + collapsed: feat.dataset.collapsed, + h: Math.round(feat.getBoundingClientRect().height), + status: status?.textContent?.trim(), + clearVisible: clear ? !clear.hidden : null, + inert: feat.inert === true, + visibleRows: [...document.querySelectorAll('.archive-row')].filter((r) => !r.hidden).length, + }; + clear?.click(); + await wait(420); + const after = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() }; + return { before, during, after }; +})()`; + +const results = []; +const fails = []; + +for (const path of PAGES) { + const t = await target('about:blank'); + const ws = await connect(t.webSocketDebuggerUrl); + await send(ws, 'Page.enable'); + await send(ws, 'Runtime.enable'); + + for (const width of WIDTHS) { + await send(ws, 'Emulation.setDeviceMetricsOverride', { + width, + height: 900, + deviceScaleFactor: 1, + mobile: width < 700, + }); + await send(ws, 'Page.navigate', { url: BASE + path }); + await new Promise((r) => setTimeout(r, 700)); + const m = await evaluate(ws, OVERFLOW_JS); + const ok = m.scrollWidth <= m.clientWidth + 1; + results.push({ path, width, ok, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders.length }); + if (!ok || m.offenders.length) { + fails.push({ path, width, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders }); + } + } + + // Interaction check only where the featured block exists (desktop + phone widths). + if (path === '/posts/' || path === '/zh/posts/') { + for (const width of [1280, 390]) { + await send(ws, 'Emulation.setDeviceMetricsOverride', { + width, + height: 900, + deviceScaleFactor: 1, + mobile: width < 700, + }); + await send(ws, 'Page.navigate', { url: BASE + path }); + await new Promise((r) => setTimeout(r, 700)); + const c = await evaluate(ws, COLLAPSE_JS); + results.push({ path, width, collapse: c }); + if (!c.skipped) { + const good = + c.before.collapsed === 'false' && + c.during.collapsed === 'true' && + c.during.h === 0 && + c.during.inert === true && + c.during.clearVisible === true && + /docker/.test(String(c.during.status)) === false && + c.after.collapsed === 'false' && + c.after.h > 100; + if (!good) fails.push({ path, width, collapse: c }); + else console.log(`collapse ok ${path} @${width}: ${c.before.status} → ${c.during.status} → ${c.after.status} (h ${c.before.h} → ${c.during.h} → ${c.after.h})`); + } + } + } + + await send(ws, 'Page.close'); + ws.close(); +} + +const byWidth = results.filter((r) => r.ok !== undefined); +const okCount = byWidth.filter((r) => r.ok).length; +console.log(`\nviewport checks: ${okCount}/${byWidth.length} clean`); +console.log(`pages × widths: ${PAGES.length} × ${WIDTHS.length}`); + +if (fails.length) { + console.log('\nFAILURES:'); + for (const f of fails) console.log(JSON.stringify(f)); + process.exit(1); +} +console.log('no horizontal overflow, featured collapse verified'); diff --git a/src/components/Archive.astro b/src/components/Archive.astro index 0a8a58e..644fd3e 100644 --- a/src/components/Archive.astro +++ b/src/components/Archive.astro @@ -1,6 +1,8 @@ --- import type { CollectionEntry } from 'astro:content'; import { tagCounts, tagHref } from '../lib/tags'; +import { categoryLabel } from '../lib/categories'; +import type { FeaturedCard } from '../lib/featured'; interface Props { posts: CollectionEntry<'posts'>[]; @@ -17,8 +19,13 @@ interface Props { chipPosts?: CollectionEntry<'posts'>[]; /** On a tag page, the tag being viewed — its chip is marked active instead of looking clickable-to-self. */ activeTag?: string; + /** + * Featured cards above the rows. Only the archive passes these; the block collapses + * out of the way as soon as the reader types a filter or picks a category chip. + */ + featured?: FeaturedCard[]; } -const { posts, lang = 'en', chrome = true, chipPosts = posts, activeTag } = Astro.props; +const { posts, lang = 'en', chrome = true, chipPosts = posts, activeTag, featured } = Astro.props; const isZh = lang === 'zh'; const counts = tagCounts(chipPosts); @@ -39,8 +46,39 @@ const shortDate = (d: Date) => const yearOf = (d: Date) => d.getUTCFullYear(); const labels = isZh - ? { filter: '按标题或标签筛选…', all: '全部', empty: '没有匹配的文章。', search: '全文搜索', count: '篇' } - : { filter: 'Filter by title or tag…', all: 'All', empty: 'No posts match.', search: 'Full-text search', count: 'posts' }; + ? { + filter: '按标题或标签筛选…', + all: '全部', + empty: '没有匹配的文章。', + search: '全文搜索', + count: '篇', + featured: '精选案例', + readArticle: '阅读文章', + readCase: '阅读案例复盘', + minutes: '分钟', + statusAll: `共 ${posts.length} 篇`, + statusSome: `命中 {n} / ${posts.length} 篇`, + clear: '清除筛选', + } + : { + filter: 'Filter by title or tag…', + all: 'All', + empty: 'No posts match.', + search: 'Full-text search', + count: 'posts', + featured: 'Featured case studies', + readArticle: 'Read article', + readCase: 'Read case study', + minutes: 'min read', + statusAll: `All ${posts.length} posts`, + statusSome: `Showing {n} of ${posts.length} posts`, + clear: 'Clear filters', + }; + +// A case study is a portfolio artefact, so its call to action says so. Applied at the +// one place that renders listing cards so no page can drift from the rule. +const ctaLabel = (category: string) => + category === 'case-studies' ? labels.readCase : labels.readArticle; --- {chrome && ( @@ -58,25 +96,90 @@ const labels = isZh
- - {categories.map((c) => ( - - ))} + + + + {categories.map((c) => ( + + ))} +
- {chips.map((c) => ( - {c.tag} {c.n} - ))} + + + {chips.map((c) => ( + {c.tag} {c.n} + ))} +
)} {!chrome &&

{posts.length} {labels.count}

} +{featured && featured.length > 0 && ( + +)} + +{chrome && ( +

+ {labels.statusAll} + +

+)} +
    {years.map((y) => (
  1. @@ -91,7 +194,9 @@ const labels = isZh > {p.data.title} - {p.data.category} + + {categoryLabel(p.data.category, lang)} +
  2. ))}
@@ -119,6 +224,7 @@ const labels = isZh font: inherit; font-size: 0.95rem; padding: 0.6rem 0.8rem; + min-height: 44px; } .archive-input:focus { outline: none; border-color: var(--brand); } .archive-overlay-btn { @@ -129,13 +235,49 @@ const labels = isZh border: 1px solid var(--border); border-radius: var(--radius); padding: 0.6rem 0.8rem; + min-height: 44px; cursor: pointer; white-space: nowrap; } .archive-overlay-btn:hover { color: var(--brand); border-color: var(--brand); } - .archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; } + .archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; align-items: center; } .archive-tags { margin-bottom: 1.1rem; } + /* Two strips of identical chips sat next to each other and read as one list, with + "devops" appearing twice (category 30, topic 6). Name each strip. */ + .chip-strip-label { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + min-width: 4.2rem; + align-self: flex-start; + padding-top: 0.4rem; + } + /* The chips wrap inside their own column so a second line lines up under the first + chip instead of sliding back under the strip label. */ + .chip-strip-items { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + /* flex-basis must be 0, not auto: a wrap container places items by their + hypothetical size, so a max-content basis pushed the whole topic strip onto its + own line instead of shrinking next to the label. */ + flex: 1 1 0; + min-width: 0; + } + /* On a phone the label column cost ~67 px of a 342 px content width, which wrapped + the topic chips onto more lines than needed. Stack the label above there. */ + @media (max-width: 700px) { + .chip-strip-label { + flex: 0 0 100%; + min-width: 0; + padding-top: 0; + margin-top: 0.3rem; + } + .chip-strip-items { margin-top: 0.3rem; } + } .chip { font: inherit; font-size: 0.78rem; @@ -147,11 +289,136 @@ const labels = isZh cursor: pointer; text-decoration: none; white-space: nowrap; + display: inline-flex; + align-items: center; + min-height: 32px; } .chip span { opacity: 0.55; margin-left: 0.15rem; } .chip:hover { color: var(--brand); border-color: var(--brand); } .chip.is-active { color: var(--ink); border-color: var(--brand); } + /* Featured block: collapses to zero height the moment the reader filters, and the + viewport goes to the results. `grid-template-rows: 1fr → 0fr` animates without + knowing the block's height (Chrome 117+, Safari 17.4+, FF 66+); older engines + simply jump, which is acceptable for a 200 ms affordance. */ + .archive-featured { + display: grid; + grid-template-rows: 1fr; + margin: 0 0 1.4rem; + transition: grid-template-rows 0.2s ease, opacity 0.2s ease, margin-bottom 0.2s ease; + } + .archive-featured.is-collapsed { + grid-template-rows: 0fr; + opacity: 0; + margin-bottom: 0; + pointer-events: none; + } + .archive-featured-inner { overflow: hidden; min-height: 0; } + .archive-featured-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + margin: 0.2rem 0 0.7rem; + } + .af-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--brand); + box-shadow: 0 0 8px rgba(109, 141, 255, 0.8); + } + .archive-featured-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 232px), 1fr)); + gap: 0.9rem; + } + .feat-card { + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + text-decoration: none; + color: inherit; + transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + } + .feat-card:hover, + .feat-card:focus-visible { + transform: translateY(-2px); + border-color: var(--brand); + box-shadow: 0 10px 28px -14px rgba(0, 0, 0, 0.5), 0 0 18px -6px rgba(109, 141, 255, 0.3); + color: inherit; + } + .feat-thumb { display: block; aspect-ratio: 16 / 9; background: var(--bg); } + .feat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } + .feat-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.7rem 0.8rem 0.85rem; } + .feat-top { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } + .feat-metric { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--muted); + } + .pulse-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--good); + } + .feat-title { font-size: 0.98rem; font-weight: 600; line-height: 1.3; color: var(--ink); } + .feat-desc { + font-size: 0.82rem; + line-height: 1.45; + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + .feat-cta { + margin-top: auto; + padding-top: 0.35rem; + font-size: 0.76rem; + font-weight: 600; + color: var(--brand); + display: flex; + align-items: baseline; + gap: 0.35rem; + flex-wrap: wrap; + } + .feat-min { color: var(--faint); font-weight: 400; } + + .archive-status { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--faint); + margin: 0 0 0.5rem; + min-height: 28px; + } + .archive-clear { + font: inherit; + font-size: 0.72rem; + color: var(--brand); + background: none; + border: 1px solid color-mix(in srgb, var(--brand) 45%, transparent); + border-radius: 999px; + padding: 0.15rem 0.55rem; + min-height: 28px; + cursor: pointer; + } + .archive-clear:hover { border-color: var(--brand); } + .archive { list-style: none; padding: 0; margin: 0; } .archive-year { font-size: 0.8rem; @@ -197,11 +464,17 @@ const labels = isZh .archive-empty { color: var(--muted); margin-top: 1.5rem; } [hidden] { display: none !important; } + @media (prefers-reduced-motion: reduce) { + .archive-featured, .feat-card { transition: none; } + .feat-card:hover, .feat-card:focus-visible { transform: none; } + } + @media (max-width: 600px) { .archive-row { flex-wrap: wrap; gap: 0.3rem 0.6rem; padding: 0.4rem 0; } .archive-title { flex: 1 1 100%; order: 2; } .archive-row time { flex: 0 0 auto; order: 1; } .archive-cat { order: 1; } + .chip { min-height: 36px; } } @@ -209,16 +482,29 @@ const labels = isZh // Client-side filter over an already-rendered list: zero network requests, works // with the browser's own Ctrl+F, and keeps the archive one static page. const q = document.getElementById('archive-q') as HTMLInputElement | null; - const list = document.getElementById('archive-list'); const empty = document.getElementById('archive-empty'); const chips = Array.from(document.querySelectorAll('.chip[data-cat]')); const overlayBtn = document.getElementById('archive-search'); + const featuredBlock = document.getElementById('archive-featured'); + const status = document.getElementById('archive-filter-status'); + const statusText = document.getElementById('archive-status-text'); + const clearBtn = document.getElementById('archive-clear') as HTMLButtonElement | null; let activeCat = ''; overlayBtn?.addEventListener('click', () => { document.getElementById('search-toggle')?.click(); }); + // A collapsed block with 0fr height still leaves its links focusable, so keyboard + // and screen-reader users would tab into invisible cards — `inert` closes that. + function setCollapsed(collapsed: boolean) { + if (!featuredBlock) return; + featuredBlock.classList.toggle('is-collapsed', collapsed); + featuredBlock.dataset.collapsed = String(collapsed); + featuredBlock.setAttribute('aria-hidden', String(collapsed)); + (featuredBlock as HTMLElement).inert = collapsed; + } + function apply() { const term = (q?.value ?? '').trim().toLowerCase(); const terms = term ? term.split(/\s+/) : []; @@ -239,6 +525,16 @@ const labels = isZh el.hidden = !any; }); if (empty) empty.hidden = shown !== 0; + + // Featured posts are rows in the full list too, so a hit is never lost while the + // block is collapsed — the results list stays the single source of truth. + const filtering = Boolean(term || activeCat); + setCollapsed(filtering); + if (clearBtn) clearBtn.hidden = !filtering; + if (statusText && status) { + const template = (filtering ? status.dataset.labelSome : status.dataset.labelAll) ?? ''; + statusText.textContent = template.replace('{n}', String(shown)); + } } q?.addEventListener('input', apply); @@ -249,4 +545,11 @@ const labels = isZh apply(); }); }); + clearBtn?.addEventListener('click', () => { + if (q) q.value = ''; + activeCat = ''; + chips.forEach((c) => c.classList.toggle('is-active', (c as HTMLElement).dataset.cat === '')); + apply(); + q?.focus(); + }); diff --git a/src/components/PortfolioHero.astro b/src/components/PortfolioHero.astro new file mode 100644 index 0000000..3245265 --- /dev/null +++ b/src/components/PortfolioHero.astro @@ -0,0 +1,165 @@ +--- +/** + * PortfolioHero — the compact two-column hero for /categories/case-studies/. + * + * Round-2 ruling: text on the left, a wide (21:9) architecture visual on the right, + * stacking naturally on mobile. The headline, blurb and facts are semantic HTML — the + * banner carries no text, so nothing in the image has to be an SEO or a11y duplicate. + */ +interface Props { + lang: 'en' | 'zh'; + title: string; + blurb?: string; + count: number; + /** How many of the listed write-ups are the flagship set. */ + flagshipCount: number; +} + +const { lang, title, blurb, count, flagshipCount } = Astro.props; +const isZh = lang === 'zh'; + +const copy = isZh + ? { + kicker: '实战作品集', + factsA: ['篇端到端复盘', '篇旗舰案例置顶'], + hosts: '3 台生产主机', + containers: '162 个容器', + bilingual: '中英双语', + cta: '先看旗舰案例', + } + : { + kicker: 'Working portfolio', + factsA: ['end-to-end write-ups', 'flagship cases pinned first'], + hosts: '3 production hosts', + containers: '162 containers', + bilingual: 'EN + 中文', + cta: 'Read the flagship cases', + }; + +const alt = isZh + ? '架构示意图:三台生产主机(Unraid、群晖 NAS、VPS)通过 Docker 容器栈接入 Prometheus 监控中枢,再流向自动化工作流' + : 'Architecture diagram: three production hosts (Unraid, Synology NAS, VPS) feeding Docker container stacks into a Prometheus monitoring hub, then into automation workflows'; +--- + +
+
+

{copy.kicker}

+

{title}

+ {blurb &&

{blurb}

} + +
    +
  • {count} {copy.factsA[0]}
  • +
  • {flagshipCount} {copy.factsA[1]}
  • +
  • {copy.hosts} · {copy.containers}
  • +
  • {copy.bilingual}
  • +
+ +

+ {copy.cta} +

+
+ +
+ + + {alt} + +
+
+ + diff --git a/src/components/PostList.astro b/src/components/PostList.astro index 6983446..7d1a2fb 100644 --- a/src/components/PostList.astro +++ b/src/components/PostList.astro @@ -9,9 +9,14 @@ interface Props { posts: CollectionEntry<'posts'>[]; limit?: number; lang?: 'en' | 'zh'; + /** + * Measured headline per base slug (see lib/portfolio.ts) — rendered as a pill on a + * portfolio card. Omitted on the home page and tag pages, where a bare list is right. + */ + metrics?: Record; } -const { posts, limit, lang = 'en' } = Astro.props; +const { posts, limit, lang = 'en', metrics } = Astro.props; const shown = limit ? posts.slice(0, limit) : posts; const isZh = lang === 'zh'; @@ -45,6 +50,20 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => { const base = post.slug.replace(/^zh\//, ''); return `/og/thumb/${base}-${w}.webp`; }; + +// A case study is a portfolio artefact, so the row's action says so. The same rule is +// applied in Archive.astro for the /posts/ Featured block. +const ctaLabel = (category: string) => { + if (category === 'case-studies') return isZh ? '阅读案例复盘' : 'Read case study'; + return isZh ? '阅读文章' : 'Read article'; +}; +const metricFor = (post: CollectionEntry<'posts'>) => + metrics?.[post.slug.replace(/^zh\//, '')]; + +// A card that lists every tag wraps to two metadata lines on some rows and one on +// others, so the grid loses its rhythm (and the summary/CTA drift). Show four and +// fold the rest into a "+N" chip whose tooltip names them. +const TAG_LIMIT = 4; --- \ No newline at end of file + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index a8705f2..603635a 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -64,6 +64,20 @@ const switchLabel = isZh ? 'EN' : '中文'; const nav = isZh ? { blueprints: '系统蓝图', caseStudies: '工程案例', posts: '全部文章', about: '关于与合作' } : { blueprints: 'Blueprints', caseStudies: 'Case Studies', posts: 'All Posts', about: 'Work With Me' }; + +// Which section is the reader in? Without this the four nav links looked identical on +// every page, so the new portfolio route had no visible "you are here". +const sectionPath = Astro.url.pathname.replace(/^\/zh(?=\/|$)/, '') || '/'; +const inSection = { + blueprints: sectionPath.startsWith('/blueprints'), + caseStudies: sectionPath.startsWith('/categories/case-studies'), + posts: + (sectionPath.startsWith('/posts') || + sectionPath.startsWith('/tags') || + sectionPath.startsWith('/categories')) && + !sectionPath.startsWith('/categories/case-studies'), + about: sectionPath.startsWith('/about') || sectionPath.startsWith('/work-with-me'), +}; --- @@ -184,10 +198,10 @@ const nav = isZh whole group onto a second row (`flex-basis: 100%`) instead of letting individual links wrap around the search / language / theme controls. */}