Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app/[lang]/cases/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ export default function CasesLoading() {
))}
</section>

<section className="grid border-l border-t border-[var(--hair)] md:grid-cols-2 2xl:grid-cols-3">
<section className="grid border-l border-t border-[var(--hair)] md:grid-cols-2 xl:grid-cols-3">
{SKELETON_KEYS.map((key) => (
<article
key={key}
className="flex h-full flex-col overflow-hidden border border-[var(--hair)] bg-white"
>
<div className="aspect-[4/3] animate-pulse border-b border-[var(--hair)] bg-[var(--concrete)]" />
<div className="aspect-[16/10] animate-pulse border-b border-[var(--hair)] bg-[var(--concrete)]" />
<div className="flex flex-1 flex-col gap-4 p-5 sm:p-6">
<div className="flex flex-wrap gap-2">
<div className="h-6 w-16 animate-pulse bg-black/10" />
Expand Down
3 changes: 2 additions & 1 deletion src/app/[lang]/cases/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,11 @@ export default async function CasesPage({
</div>
</section>
) : (
<section className="grid gap-0 border-l border-t border-[var(--hair)] md:grid-cols-2 2xl:grid-cols-3">
<section className="grid gap-0 border-l border-t border-[var(--hair)] md:grid-cols-2 xl:grid-cols-3">
{caseItems.map((item) => (
<CaseCard
key={item.slug}
variant="gallery"
item={{
...item,
skills: getCaseSkillLinks(skillCatalog, item.slug),
Expand Down
22 changes: 18 additions & 4 deletions src/components/case-card-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export function CaseCardPrompt({
contentLocale = "en",
promptTranslationZh,
promptTranslationEn,
compact = false,
}: {
promptPreview?: string | null;
contentLocale?: "zh-CN" | "en";
promptTranslationZh?: string | null;
promptTranslationEn?: string | null;
compact?: boolean;
}) {
const locale = useLocale();
const messages = useMessages();
Expand Down Expand Up @@ -58,7 +60,11 @@ export function CaseCardPrompt({

if (prompt.resourceUrl) {
return (
<div className="mt-5 border-t border-[var(--hair)] bg-[var(--paper-2)] px-4 py-3">
<div
className={`border-t border-[var(--hair)] bg-[var(--paper-2)] px-4 py-3 ${
compact ? "mt-4" : "mt-5"
}`}
>
<p className="font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--orange)]">
{messages.card.method} / {messages.card.code}
</p>
Expand All @@ -75,12 +81,16 @@ export function CaseCardPrompt({
}

return (
<div className="mt-5 border-t border-[var(--hair)] bg-[var(--paper-2)] px-4 py-3">
<div
className={`border-t border-[var(--hair)] bg-[var(--paper-2)] px-4 py-3 ${
compact ? "mt-4" : "mt-5"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-[9px] uppercase tracking-[0.12em] text-[var(--orange)]">
{messages.card.prompt}
</p>
{availableLanguages.length > 1 ? (
{!compact && availableLanguages.length > 1 ? (
<div className="flex border border-[var(--hair)] bg-white">
{availableLanguages.map((value) => (
<button
Expand All @@ -104,7 +114,11 @@ export function CaseCardPrompt({
</div>
) : null}
</div>
<p className="mt-2 line-clamp-3 font-mono text-[11px] leading-5 text-[var(--ink)]">
<p
className={`mt-2 font-mono text-[11px] leading-5 text-[var(--ink)] ${
compact ? "line-clamp-2" : "line-clamp-3"
}`}
>
{prompt.text}
</p>
</div>
Expand Down
145 changes: 121 additions & 24 deletions src/components/case-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,84 +32,156 @@ export type CaseCardItem = {
export function CaseCard({
item,
actions,
variant = "default",
}: {
item: CaseCardItem;
actions?: ReactNode;
variant?: "default" | "gallery";
}) {
const messages = useMessages();
const isGallery = variant === "gallery";
const label =
messages.category[item.category as keyof typeof messages.category] ||
item.category;
const summary = getCaseCardSummary(item.summary);
const creatorSlug = item.creator ? slugifyCreatorName(item.creator) : "";
const galleryBackdropUrl = isGallery
? item.mediaType === "image"
? item.mediaUrl
: item.posterUrl
: null;

return (
<article className="gc-card group flex h-full flex-col overflow-hidden">
<Link
href={`/cases/${item.slug}`}
className="relative block aspect-[4/3] overflow-hidden border-b border-[var(--hair)] bg-[var(--ink)]"
className={`relative block overflow-hidden border-b border-[var(--hair)] bg-[var(--ink)] ${
isGallery ? "aspect-[16/10]" : "aspect-[4/3]"
}`}
>
{galleryBackdropUrl ? (
<>
<Image
src={galleryBackdropUrl}
alt=""
aria-hidden="true"
fill
sizes="(min-width: 1280px) 31vw, (min-width: 768px) 48vw, 100vw"
className="pointer-events-none z-0 scale-125 object-cover opacity-70 blur-2xl saturate-125"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.10)_0%,transparent_55%),linear-gradient(to_top,rgba(0,0,0,0.38)_0%,rgba(0,0,0,0.03)_35%,rgba(0,0,0,0.16)_100%)]"
/>
</>
) : null}

{item.mediaUrl ? (
item.mediaType === "image" ? (
<Image
src={item.mediaUrl}
alt={item.title}
fill
sizes="(min-width: 1536px) 31vw, (min-width: 768px) 48vw, 100vw"
className="object-cover grayscale transition duration-300 group-hover:grayscale-0"
sizes="(min-width: 1280px) 31vw, (min-width: 768px) 48vw, 100vw"
className={`transition duration-300 ${
isGallery
? "z-20 object-contain group-hover:scale-[1.015]"
: "object-cover grayscale group-hover:grayscale-0"
}`}
/>
) : (
<video
muted
playsInline
preload="none"
poster={item.posterUrl || undefined}
className="h-full w-full object-cover grayscale transition duration-300 group-hover:grayscale-0"
className={`h-full w-full transition duration-300 ${
isGallery
? "relative z-20 object-contain group-hover:scale-[1.015]"
: "object-cover grayscale group-hover:grayscale-0"
}`}
>
<source src={item.mediaUrl} type="video/mp4" />
</video>
)
) : null}
</Link>

<div className="flex flex-1 flex-col p-5">
<div
className={`flex flex-1 flex-col ${isGallery ? "p-4 sm:p-5" : "p-5"}`}
>
<div className="flex flex-wrap items-center gap-2">
<span className="gc-chip gc-chip-accent">{label}</span>
<span className="gc-chip">{item.source}</span>
{item.creator && creatorSlug ? (
{!isGallery && item.creator && creatorSlug ? (
<Link
href={`/creators/${creatorSlug}`}
className="gc-chip transition hover:border-[var(--ink)] hover:text-[var(--ink)]"
>
{item.creator} →
</Link>
) : item.creator ? (
) : !isGallery && item.creator ? (
<span className="gc-chip">{item.creator}</span>
) : null}
</div>

<Link href={`/cases/${item.slug}`} className="mt-5 block">
<h2 className="text-2xl font-semibold leading-[1.02] tracking-[-0.035em] text-[var(--ink)] sm:text-3xl">
<Link
href={`/cases/${item.slug}`}
className={isGallery ? "mt-4 block" : "mt-5 block"}
>
<h2
className={`font-semibold tracking-[-0.035em] text-[var(--ink)] ${
isGallery
? "line-clamp-2 min-h-[2.35em] text-xl leading-[1.16] sm:text-2xl"
: "text-2xl leading-[1.02] sm:text-3xl"
}`}
>
{item.title}
</h2>
</Link>

{isGallery && item.creator ? (
<p className="mt-3 truncate font-mono text-[10px] uppercase tracking-[0.08em] text-[var(--muted)]">
{messages.common.creator}{" "}
{creatorSlug ? (
<Link
href={`/creators/${creatorSlug}`}
className="font-semibold text-[var(--ink)] hover:text-[var(--orange)]"
>
{item.creator} →
</Link>
) : (
<span className="font-semibold text-[var(--ink)]">{item.creator}</span>
)}
</p>
) : null}

{summary ? (
<p className="mt-3 line-clamp-3 text-sm leading-7 text-[var(--muted)]">
<p
className={`mt-3 text-sm text-[var(--muted)] ${
isGallery ? "line-clamp-2 leading-6" : "line-clamp-3 leading-7"
}`}
>
{summary}
</p>
) : null}

{item.skills?.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{item.skills.map((skill) => (
<Link
key={skill.slug}
href={`/skills/${skill.slug}`}
className="gc-chip transition hover:border-[var(--orange)] hover:text-[var(--orange)]"
>
Skill · {skill.title}
</Link>
))}
{item.skills
.slice(0, isGallery ? 1 : item.skills.length)
.map((skill) => (
<Link
key={skill.slug}
href={`/skills/${skill.slug}`}
className="gc-chip transition hover:border-[var(--orange)] hover:text-[var(--orange)]"
>
Skill · {skill.title}
</Link>
))}
{isGallery && item.skills.length > 1 ? (
<span className="gc-chip">+{item.skills.length - 1}</span>
) : null}
</div>
) : null}

Expand All @@ -118,16 +190,41 @@ export function CaseCard({
contentLocale={item.contentLocale}
promptTranslationZh={item.promptTranslationZh}
promptTranslationEn={item.promptTranslationEn}
compact={isGallery}
/>

<div className="mt-5 grid grid-cols-2 gap-2">
<div className="gc-stat">
<div
className={`grid grid-cols-2 ${
isGallery
? "mt-4 gap-px border border-[var(--concrete)] bg-[var(--concrete)]"
: "mt-5 gap-2"
}`}
>
<div
className={
isGallery ? "bg-[var(--paper-2)] px-3 py-2" : "gc-stat"
}
>
<div className="gc-stat-label">{messages.card.sourceHeat}</div>
<div className="gc-stat-value">{item.sourceHeatScore ?? "—"}</div>
<div
className={
isGallery ? "mt-1 text-sm font-semibold" : "gc-stat-value"
}
>
{item.sourceHeatScore ?? "—"}
</div>
</div>
<div className="gc-stat">
<div
className={
isGallery ? "bg-[var(--paper-2)] px-3 py-2" : "gc-stat"
}
>
<div className="gc-stat-label">{messages.card.stability}</div>
<div className="gc-stat-value">
<div
className={
isGallery ? "mt-1 text-sm font-semibold" : "gc-stat-value"
}
>
{formatStabilityScore(item.stabilityScore)}
</div>
</div>
Expand Down