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

View File

@ -7,6 +7,17 @@ import Layout from '../../layouts/Layout.astro';
import { getLatestDate } from '../../utils/datetime'; import { getLatestDate } from '../../utils/datetime';
const projects = await loadProjects(); 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 projectSources = [...new Set(projects.map((project) => project.source).filter(Boolean))];
const projectsUpdatedAt = getLatestDate(projects.map((project) => project.updated_at).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"> <div class="card-grid">
{ {
projects.map((project) => ( sortedProjects.map((project) => (
<ProjectCard <ProjectCard
name={project.name} name={project.name}
description={project.description} description={project.description}

View File

@ -612,6 +612,59 @@ main {
margin-left: auto; 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:hover,
.card:focus-within { .card:focus-within {
transform: translateY(-2px); transform: translateY(-2px);
@ -682,6 +735,7 @@ main {
} }
.project-cover { .project-cover {
position: relative;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
overflow: hidden; overflow: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
@ -690,6 +744,23 @@ main {
background: #eef2ff; 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 { .activity-panel {
padding: 1.5rem; padding: 1.5rem;
color: #f8fafc; color: #f8fafc;
@ -963,6 +1034,16 @@ main {
margin-left: 0; 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, .section-heading,
.footer-card { .footer-card {
align-items: flex-start; align-items: flex-start;