Make project cards easier to scan in dense three-column grids

Constraint: Keep projects page in a 3-column grid while reducing line-wrap pressure in card metadata/action areas
Rejected: Keeping metadata and actions split into side-by-side columns | caused unstable wrapping and noisy visual rhythm
Confidence: high
Scope-risk: narrow
Directive: Keep project-card internals on project-specific classes and preserve one metadata row per semantic purpose
Tested: npm run build; ./scripts/deploy-homepage.sh
Not-tested: Manual cross-browser visual checks beyond current deployed environment
This commit is contained in:
SepComet 2026-05-10 15:03:48 +08:00
parent 7a091a9b44
commit 84159db417
3 changed files with 144 additions and 43 deletions

View File

@ -1,7 +1,6 @@
---
import { site } from '../config';
import type { ProjectDownload } from '../data/loaders';
import { formatDateTime } from '../utils/datetime';
interface Props {
name: string;
@ -30,60 +29,70 @@ const {
downloadLink = '',
downloads = [],
updatedAt = '',
source,
source: _source,
} = Astro.props;
const fallbackRepoUrl = repo && site.gitea.url ? `${site.gitea.url}/${repo}` : '';
const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
const visibleDownloads = downloads.filter((item) => item.name || item.url);
const sourceLabels = {
seed: '本地种子',
gitea: 'Gitea',
'gitea+seafile': 'Gitea + Seafile',
} as const;
const updatedLabel = formatDateTime(updatedAt);
const updatedLabel = formatProjectUpdateLabel(updatedAt);
function formatProjectUpdateLabel(value: string) {
if (!value) {
return '';
}
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) {
return '';
}
const date = new Date(parsed);
const now = new Date();
if (date.getFullYear() < now.getFullYear()) {
return '很久之前';
}
return `${date.getMonth() + 1}/${date.getDate()}`;
}
---
<article class="card">
<article class={`card project-card${featured ? ' project-card--featured' : ''}`}>
<div class="project-cover">
<img src={coverImage} alt={`${name} 项目封面`} loading="lazy" />
{featured ? <span class="project-featured-badge" aria-label="Featured 项目">★</span> : null}
</div>
<div class="project-meta">
<span class="mono">{repo}</span>
{featured ? <span class="tag">featured</span> : null}
{source ? <span class="tag">{sourceLabels[source]}</span> : null}
{updatedAt ? <span class="mono">更新于 {updatedLabel}</span> : null}
<div class="project-card__content">
<h3 class="project-card__title">{name}</h3>
<p class="project-card__description">{description}</p>
</div>
<h3 class="project-title">{name}</h3>
<p class="project-description">{description}</p>
<div class="project-card__meta">
<div class="project-card__tags tag-list">
{tags.map((tag) => <span class="tag tag--tech">{tag}</span>)}
</div>
<div class="tag-list" style="margin: 1rem 0 0.95rem;">
{tags.map((tag) => <span class="tag tag--tech">{tag}</span>)}
</div>
<div class="project-card__footer">
{updatedLabel ? <span class="project-card__time mono">更新时间 {updatedLabel}</span> : null}
<div class="project-links">
{resolvedRepoUrl ? (
<a class="section-link" href={resolvedRepoUrl} target="_blank" rel="noreferrer">
仓库
</a>
) : null}
{demoVideo ? (
<a class="section-link" href={demoVideo} target="_blank" rel="noreferrer">
Demo
</a>
) : null}
{downloadLink && visibleDownloads.length === 0 ? (
<a class="section-link" href={downloadLink} target="_blank" rel="noreferrer">
下载
</a>
) : null}
</div>
{
visibleDownloads.length > 0 ? (
<div class="project-links" style="margin-top: 0.75rem; flex-wrap: wrap; gap: 0.6rem 0.8rem;">
<div class="project-links project-card__actions">
{resolvedRepoUrl ? (
<a class="section-link" href={resolvedRepoUrl} target="_blank" rel="noreferrer">
仓库
</a>
) : null}
{demoVideo ? (
<a class="section-link" href={demoVideo} target="_blank" rel="noreferrer">
Demo
</a>
) : null}
{downloadLink && visibleDownloads.length === 0 ? (
<a class="section-link" href={downloadLink} target="_blank" rel="noreferrer">
下载
</a>
) : null}
{visibleDownloads.map((item) =>
item.url ? (
<a class="section-link" href={item.url} target="_blank" rel="noreferrer">
@ -94,6 +103,6 @@ const updatedLabel = formatDateTime(updatedAt);
),
)}
</div>
) : null
}
</div>
</div>
</article>

View File

@ -7,6 +7,17 @@ import Layout from '../../layouts/Layout.astro';
import { getLatestDate } from '../../utils/datetime';
const projects = await loadProjects();
const sortedProjects = [...projects].sort((a, b) => {
if (a.featured !== b.featured) {
return Number(b.featured) - Number(a.featured);
}
const leftUpdated = Date.parse(a.updated_at || '');
const rightUpdated = Date.parse(b.updated_at || '');
const leftTime = Number.isNaN(leftUpdated) ? 0 : leftUpdated;
const rightTime = Number.isNaN(rightUpdated) ? 0 : rightUpdated;
return rightTime - leftTime;
});
const projectSources = [...new Set(projects.map((project) => project.source).filter(Boolean))];
const projectsUpdatedAt = getLatestDate(projects.map((project) => project.updated_at).filter(Boolean));
---
@ -33,7 +44,7 @@ const projectsUpdatedAt = getLatestDate(projects.map((project) => project.update
<div class="card-grid">
{
projects.map((project) => (
sortedProjects.map((project) => (
<ProjectCard
name={project.name}
description={project.description}

View File

@ -612,6 +612,59 @@ main {
margin-left: auto;
}
.project-card {
display: grid;
gap: 1rem;
}
.project-card--featured {
border-color: rgba(245, 158, 11, 0.45);
box-shadow: 0 12px 28px rgba(245, 158, 11, 0.12);
}
.project-card__content {
display: grid;
gap: 0.5rem;
}
.project-card__title {
margin: 0;
font-family: 'Archivo', sans-serif;
font-size: 1.26rem;
line-height: 1.3;
}
.project-card__description {
margin: 0;
color: var(--muted-strong);
line-height: 1.8;
}
.project-card__meta {
display: grid;
gap: 0.8rem;
}
.project-card__tags {
min-width: 0;
}
.project-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem 1rem;
}
.project-card__time {
color: var(--muted);
font-size: 0.84rem;
}
.project-card__actions {
gap: 0.6rem 0.8rem;
}
.card:hover,
.card:focus-within {
transform: translateY(-2px);
@ -682,6 +735,7 @@ main {
}
.project-cover {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
margin-bottom: 1rem;
@ -690,6 +744,23 @@ main {
background: #eef2ff;
}
.project-featured-badge {
position: absolute;
top: 0.55rem;
right: 0.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65rem;
height: 1.65rem;
border-radius: 999px;
background: rgba(245, 158, 11, 0.95);
color: #ffffff;
font-size: 0.95rem;
line-height: 1;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.22);
}
.activity-panel {
padding: 1.5rem;
color: #f8fafc;
@ -963,6 +1034,16 @@ main {
margin-left: 0;
}
.project-card__footer {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.project-card__actions {
justify-content: flex-start;
}
.section-heading,
.footer-card {
align-items: flex-start;