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:
parent
7a091a9b44
commit
84159db417
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue