From 041471d25e221d0798934446b791bdefbf7619bb Mon Sep 17 00:00:00 2001 From: hoelee Date: Tue, 27 Oct 2026 04:06:54 +0800 Subject: [PATCH] feat(listing): portfolio-first archive, case-study hero, row action links /posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the complete year/month archive, with the flagship block collapsing (inert, h=0) as soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact two-column hero with a 21:9 deterministic topology banner, flagship ordering from lib/portfolio.ts instead of pubDate, and one measured pill per card. Also fixes what the screenshot review turned up: ZH archive rows and category chips were printing raw English slugs, the two chip strips were unlabelled and read as one list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav had no current-section indicator, and mobile title links were under the 44px tap minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport checks and the collapse test. --- .../categories/case-studies-banner-1200.webp | Bin 0 -> 14400 bytes .../images/categories/case-studies-banner.svg | 324 +++++++++++++++++ .../categories/case-studies-banner.webp | Bin 0 -> 34026 bytes scripts/gen-case-studies-banner.mjs | 239 +++++++++++++ scripts/probe-layout.mjs | 194 ++++++++++ src/components/Archive.astro | 337 +++++++++++++++++- src/components/PortfolioHero.astro | 165 +++++++++ src/components/PostList.astro | 42 ++- src/layouts/BaseLayout.astro | 22 +- src/lib/featured.ts | 99 +++++ src/lib/portfolio.ts | 73 ++++ src/pages/categories/[category].astro | 48 ++- src/pages/index.astro | 45 +-- src/pages/posts/index.astro | 8 +- src/pages/zh/categories/[category].astro | 45 ++- src/pages/zh/posts/index.astro | 4 +- src/styles/global.css | 63 ++++ 17 files changed, 1632 insertions(+), 76 deletions(-) create mode 100644 public/images/categories/case-studies-banner-1200.webp create mode 100644 public/images/categories/case-studies-banner.svg create mode 100644 public/images/categories/case-studies-banner.webp create mode 100644 scripts/gen-case-studies-banner.mjs create mode 100644 scripts/probe-layout.mjs create mode 100644 src/components/PortfolioHero.astro create mode 100644 src/lib/featured.ts create mode 100644 src/lib/portfolio.ts 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 0000000000000000000000000000000000000000..8c25e71e8fcb3255ec317c01c6a9d2ba24938eb6 GIT binary patch literal 14400 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..534b72d234a0f1ef9b38b23ce7d0cf35eddf30dd GIT binary patch literal 34026 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 I0000001^=hmjD0& literal 0 HcmV?d00001 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; ---
    @@ -76,13 +95,30 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => { )} · {categoryLabel(post.data.category, lang)} - {post.data.tags.map((t) => { + {post.data.tags.slice(0, TAG_LIMIT).map((t) => { const href = tagLink(t, counts, lang); return href ? {t} : {t}; })} + {post.data.tags.length > TAG_LIMIT && ( + + +{post.data.tags.length - TAG_LIMIT} + + )}

    {post.data.description}

    +
    + {/* A second visual target for the same destination — deliberately out of the + tab order so keyboard users get one stop per card, not two. */} + + {metricFor(post) && ( + + {metricFor(post)} + + )} +
    ))} -
\ 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. */}