design(qipaobuzz): premium broadsheet listing — hero + image-row, hairline dividers, scroll-reveal animations

- Replace bordered .post cards with a flowing newspaper-style listing.
- First post becomes a hero (16:7 cover + overlay-style headline).
- Subsequent posts are image-left rows with ink-stroke vertical accent.
- Hover: title underline-fill + image scale + saturate.
- IntersectionObserver scroll-reveal, no library deps.
- Mobile: 110px thumb, 3-line excerpt clamp.
This commit is contained in:
claude-code
2026-04-29 18:16:36 +08:00
parent b119b15760
commit 7bf6af98d1
2 changed files with 305 additions and 34 deletions

View File

@@ -1,25 +1,93 @@
---
import { getVisibleTags } from '../lib/markdown.js';
import { getVisibleTags, renderMarkdown, extractFirstImage } from '../lib/markdown.js';
const { posts, tag } = Astro.props;
const visible = getVisibleTags();
---
{tag && <h2 style="margin-bottom: 20px;">Tagged: {tag}</h2>}
{posts.map((post) => (
<article class="post">
<h2 class="post-title">
<a href={`/${post.slug}/`}>{post.title}</a>
</h2>
<div class="post-meta">
{new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
{post.tags.filter((t) => visible.has(t)).map((t) => (
<span class="tag"><a href={`/tag/${t}/`}>{t}</a></span>
))}
</div>
<p class="post-excerpt">{post.rawExcerpt}</p>
// Pre-compute first cover image per post (from rendered body) so the listing
// can show a real photo on the left without re-parsing inside the template.
const enriched = posts.map((p) => ({
...p,
cover: extractFirstImage(renderMarkdown(p.body)),
}));
const hero = enriched[0];
const rest = enriched.slice(1);
const fmt = (d) => new Date(d).toLocaleDateString('en-US',
{ year: 'numeric', month: 'long', day: 'numeric' });
---
{tag && (
<h2 class="tag-banner">
<span class="tag-banner-mark">栏目</span>
Tagged: <em>{tag}</em>
</h2>
)}
{enriched.length > 0 && !tag && (
<article class="post-hero" data-reveal>
<a class="post-hero-link" href={`/${hero.slug}/`}>
{hero.cover && (
<div class="post-hero-img">
<img src={hero.cover} alt="" loading="eager" />
</div>
)}
<div class="post-hero-body">
<div class="post-hero-flag">头条 · TOP STORY</div>
<h2 class="post-hero-title">{hero.title}</h2>
<p class="post-hero-excerpt">{hero.rawExcerpt}</p>
<div class="post-hero-meta">
<span>{fmt(hero.date)}</span>
{hero.tags.filter((t) => visible.has(t)).slice(0, 4).map((t) => (
<span class="post-hero-tag">{t}</span>
))}
</div>
</div>
</a>
</article>
)}
{(tag ? enriched : rest).map((post, i) => (
<article class="post-row" data-reveal style={`animation-delay:${Math.min(i * 60, 600)}ms`}>
<a class="post-row-link" href={`/${post.slug}/`}>
{post.cover && (
<div class="post-row-img">
<img src={post.cover} alt="" loading="lazy" />
</div>
)}
<div class="post-row-body">
<h3 class="post-row-title">{post.title}</h3>
<p class="post-row-excerpt">{post.rawExcerpt}</p>
<div class="post-row-meta">
<time>{fmt(post.date)}</time>
{post.tags.filter((t) => visible.has(t)).slice(0, 3).map((t) => (
<span class="post-row-tag">{t}</span>
))}
</div>
</div>
</a>
</article>
))}
{posts.length === 0 && (
<p style="color: #999; text-align: center; padding: 50px 0;">No posts yet.</p>
{enriched.length === 0 && (
<p class="post-empty">尚无文章 · No posts yet.</p>
)}
<script is:inline>
// Subtle scroll-reveal: fade + lift each post-row when it enters viewport.
(() => {
const els = document.querySelectorAll('[data-reveal]');
if (!('IntersectionObserver' in window)) {
els.forEach((e) => e.classList.add('is-visible'));
return;
}
const obs = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-visible');
obs.unobserve(e.target);
}
}
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });
els.forEach((e) => obs.observe(e));
})();
</script>