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.
This commit is contained in:
1 parent
68771101ea
commit
041471d25e
17 files changed
+1632
-76
No files matched your search
+320
-17
@@ -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
|
||||
</div>
|
||||
|
||||
<div class="archive-chips" role="group" aria-label={isZh ? '按分类筛选' : 'Filter by category'}>
|
||||
<button class="chip is-active" data-cat="" type="button">{labels.all} <span>{posts.length}</span></button>
|
||||
{categories.map((c) => (
|
||||
<button class="chip" data-cat={c.name} type="button">{c.name} <span>{c.n}</span></button>
|
||||
))}
|
||||
<span class="chip-strip-label" aria-hidden="true">{isZh ? '分类' : 'Category'}</span>
|
||||
<span class="chip-strip-items">
|
||||
<button class="chip is-active" data-cat="" type="button">{labels.all} <span>{posts.length}</span></button>
|
||||
{categories.map((c) => (
|
||||
<button class="chip" data-cat={c.name} type="button">
|
||||
{categoryLabel(c.name, lang)} <span>{c.n}</span>
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-chips archive-tags" role="group" aria-label={isZh ? '按标签筛选' : 'Filter by tag'}>
|
||||
{chips.map((c) => (
|
||||
<a
|
||||
class:list={['chip', 'tag-chip', { 'is-active': c.tag === activeTag }]}
|
||||
href={tagHref(c.tag, lang)}
|
||||
aria-current={c.tag === activeTag ? 'page' : undefined}
|
||||
>{c.tag} <span>{c.n}</span></a>
|
||||
))}
|
||||
<span class="chip-strip-label" aria-hidden="true">{isZh ? '话题' : 'Topic'}</span>
|
||||
<span class="chip-strip-items">
|
||||
{chips.map((c) => (
|
||||
<a
|
||||
class:list={['chip', 'tag-chip', { 'is-active': c.tag === activeTag }]}
|
||||
href={tagHref(c.tag, lang)}
|
||||
aria-current={c.tag === activeTag ? 'page' : undefined}
|
||||
>{c.tag} <span>{c.n}</span></a>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!chrome && <p class="archive-meta">{posts.length} {labels.count}</p>}
|
||||
|
||||
{featured && featured.length > 0 && (
|
||||
<section
|
||||
id="archive-featured"
|
||||
class="archive-featured"
|
||||
aria-labelledby="archive-featured-title"
|
||||
data-collapsed="false"
|
||||
>
|
||||
<div class="archive-featured-inner">
|
||||
<h2 id="archive-featured-title" class="archive-featured-title">
|
||||
<span class="af-dot" aria-hidden="true"></span>{labels.featured}
|
||||
</h2>
|
||||
<div class="archive-featured-grid">
|
||||
{featured.map((f) => (
|
||||
<a class="feat-card" href={f.href}>
|
||||
<span class="feat-thumb">
|
||||
<img
|
||||
src={f.thumb800}
|
||||
srcset={`${f.thumb400} 400w, ${f.thumb800} 800w`}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="800"
|
||||
height="420"
|
||||
/>
|
||||
</span>
|
||||
<span class="feat-body">
|
||||
<span class="feat-top">
|
||||
<span class="featured-badge">{f.categoryLabel}</span>
|
||||
<span class="feat-metric"><span class="pulse-dot" aria-hidden="true"></span>{f.metric}</span>
|
||||
</span>
|
||||
<span class="feat-title">{f.title}</span>
|
||||
<span class="feat-desc">{f.description}</span>
|
||||
<span class="feat-cta">
|
||||
{ctaLabel(f.category)} → <span class="feat-min">{f.minutes} {labels.minutes}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{chrome && (
|
||||
<p
|
||||
class="archive-status"
|
||||
id="archive-filter-status"
|
||||
data-label-all={labels.statusAll}
|
||||
data-label-some={labels.statusSome}
|
||||
data-label-clear={labels.clear}
|
||||
>
|
||||
<span id="archive-status-text">{labels.statusAll}</span>
|
||||
<button class="archive-clear" id="archive-clear" type="button" hidden>{labels.clear}</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol class="archive" id="archive-list">
|
||||
{years.map((y) => (
|
||||
<li class="archive-year-block" data-year={y}>
|
||||
@@ -91,7 +194,9 @@ const labels = isZh
|
||||
>
|
||||
<time datetime={p.data.pubDate.toISOString()}>{shortDate(p.data.pubDate)}</time>
|
||||
<a class="archive-title" href={`/posts/${p.slug}/`}>{p.data.title}</a>
|
||||
<a class="archive-cat" href={`${isZh ? '/zh' : ''}/categories/${p.data.category}/`}>{p.data.category}</a>
|
||||
<a class="archive-cat" href={`${isZh ? '/zh' : ''}/categories/${p.data.category}/`}>
|
||||
{categoryLabel(p.data.category, lang)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
</script>
|
||||
@@ -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';
|
||||
---
|
||||
|
||||
<section class="portfolio-hero">
|
||||
<div class="ph-copy">
|
||||
<p class="ph-kicker">{copy.kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
{blurb && <p class="ph-lede">{blurb}</p>}
|
||||
|
||||
<ul class="ph-facts">
|
||||
<li><b>{count}</b> {copy.factsA[0]}</li>
|
||||
<li><b>{flagshipCount}</b> {copy.factsA[1]}</li>
|
||||
<li>{copy.hosts} · {copy.containers}</li>
|
||||
<li>{copy.bilingual}</li>
|
||||
</ul>
|
||||
|
||||
<p class="ph-actions">
|
||||
<a class="ph-cta" href="#case-list">{copy.cta} <span aria-hidden="true">↓</span></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<figure class="ph-art">
|
||||
<picture>
|
||||
<source
|
||||
srcset="/images/categories/case-studies-banner-1200.webp 1200w, /images/categories/case-studies-banner.webp 2400w"
|
||||
sizes="(max-width: 959px) 100vw, 620px"
|
||||
type="image/webp"
|
||||
/>
|
||||
<img
|
||||
src="/images/categories/case-studies-banner.webp"
|
||||
alt={alt}
|
||||
width="2400"
|
||||
height="1029"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.portfolio-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.ph-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-hero h1 { margin: 0 0 0.6rem; }
|
||||
|
||||
.ph-lede {
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem;
|
||||
max-width: 44ch;
|
||||
}
|
||||
|
||||
.ph-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
.ph-facts li {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.ph-facts b { color: var(--ink); font-weight: 600; }
|
||||
|
||||
.ph-actions { margin: 0; }
|
||||
.ph-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0 0.1rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
.ph-cta:hover { text-decoration: underline; }
|
||||
|
||||
.ph-art { margin: 0; }
|
||||
.ph-art picture { display: block; }
|
||||
.ph-art img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0b1613;
|
||||
aspect-ratio: 21 / 9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Desktop: copy left, art right. The art column is sized so the 21:9 band lands in
|
||||
the 240–300 px range the consultant specified (620 px wide → ~266 px tall). */
|
||||
@media (min-width: 960px) {
|
||||
.portfolio-hero {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
|
||||
gap: 2.25rem;
|
||||
}
|
||||
.ph-art img { max-height: 300px; }
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
---
|
||||
|
||||
<ul class="post-list">
|
||||
@@ -76,13 +95,30 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
|
||||
)}
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={catHref(post.data.category)}>{categoryLabel(post.data.category, lang)}</a>
|
||||
{post.data.tags.map((t) => {
|
||||
{post.data.tags.slice(0, TAG_LIMIT).map((t) => {
|
||||
const href = tagLink(t, counts, lang);
|
||||
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
|
||||
})}
|
||||
{post.data.tags.length > TAG_LIMIT && (
|
||||
<span class="tag tag-more" title={post.data.tags.slice(TAG_LIMIT).join(', ')}>
|
||||
+{post.data.tags.length - TAG_LIMIT}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{post.data.description}</p>
|
||||
<div class="row-foot">
|
||||
{/* A second visual target for the same destination — deliberately out of the
|
||||
tab order so keyboard users get one stop per card, not two. */}
|
||||
<a class="row-cta" href={`/posts/${post.slug}/`} tabindex="-1" aria-hidden="true">
|
||||
{ctaLabel(post.data.category)} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{metricFor(post) && (
|
||||
<span class="row-metric">
|
||||
<span class="pulse-dot" aria-hidden="true"></span>{metricFor(post)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
Reference in new issue
Block a user