259 lines
9.7 KiB
TypeScript
259 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
CodeXml, Copy, Eye, MessageSquare, GitFork, Users,
|
||
Plus, Zap, ShieldCheck, Binary,
|
||
} from "lucide-react";
|
||
import type { SnippetStats } from "@/lib/api";
|
||
import { snippetLangColor } from "@/lib/snippetLangColors";
|
||
|
||
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
||
|
||
function useRollIn(target: number, duration = 1000) {
|
||
const [value, setValue] = useState(0);
|
||
useEffect(() => {
|
||
const start = performance.now();
|
||
let raf = 0;
|
||
const tick = (t: number) => {
|
||
const p = Math.min((t - start) / duration, 1);
|
||
const ease = 1 - Math.pow(1 - p, 3);
|
||
setValue(Math.round(target * ease));
|
||
if (p < 1) raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(raf);
|
||
}, [target, duration]);
|
||
return value;
|
||
}
|
||
|
||
function formatK(n: number) {
|
||
if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`;
|
||
if (n >= 10_000) return `${(n / 1000).toFixed(0)}k`;
|
||
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
||
return `${n}`;
|
||
}
|
||
|
||
function StatBlock({
|
||
icon,
|
||
label,
|
||
value,
|
||
delay,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
value: string;
|
||
delay: number;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="j13-hero-stat flex items-center gap-2.5 px-3 py-2 rounded-md"
|
||
style={{
|
||
animationDelay: `${delay}ms`,
|
||
background: "var(--hero-chip-bg)",
|
||
border: "1px solid var(--hero-line)",
|
||
}}
|
||
>
|
||
<span
|
||
className="w-7 h-7 flex items-center justify-center rounded-md shrink-0"
|
||
style={{ background: "var(--hero-chip-icon)" }}
|
||
>
|
||
<span style={{ color: "var(--accent)" }}>{icon}</span>
|
||
</span>
|
||
<div className="flex flex-col leading-none">
|
||
<span
|
||
className="text-[10px] font-medium tracking-wide uppercase"
|
||
style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}
|
||
>
|
||
{label}
|
||
</span>
|
||
<span
|
||
className="text-[17px] font-extrabold tabular-nums mt-0.5"
|
||
style={{ color: "var(--hero-ink)", fontFamily: MONO }}
|
||
>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LangMatrix({
|
||
topLanguages,
|
||
total,
|
||
}: {
|
||
topLanguages: SnippetStats["top_languages"];
|
||
total: number;
|
||
}) {
|
||
const max = Math.max(1, ...topLanguages.map((l) => l.count));
|
||
return (
|
||
<div className="mt-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-[10px] font-medium tracking-wide uppercase" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||
Language Matrix
|
||
</span>
|
||
<div className="flex items-center gap-1" style={{ color: "var(--hero-ink-3)" }}>
|
||
<ShieldCheck size={9} />
|
||
<span className="text-[9px]" style={{ fontFamily: MONO }}>
|
||
SECURE
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
{topLanguages.map((l, i) => {
|
||
const c = snippetLangColor(l.language);
|
||
const pct = total > 0 ? Math.round((l.count / total) * 100) : 0;
|
||
return (
|
||
<div key={l.language} className="flex items-center gap-2.5">
|
||
<span
|
||
className="w-[72px] shrink-0 text-[10px] font-semibold text-right tabular-nums truncate"
|
||
style={{ color: c, fontFamily: MONO }}
|
||
>
|
||
{l.language}
|
||
</span>
|
||
<div className="flex-1 h-[5px] rounded-full overflow-hidden" style={{ background: "var(--hero-track)" }}>
|
||
<div
|
||
className="j13-hero-langbar h-full rounded-full"
|
||
style={{
|
||
width: `${Math.max(3, (l.count / max) * 100)}%`,
|
||
background: `linear-gradient(90deg, ${c}, color-mix(in srgb, ${c}, transparent 40%))`,
|
||
animationDelay: `${300 + i * 80}ms`,
|
||
}}
|
||
/>
|
||
</div>
|
||
<span className="w-7 shrink-0 text-[10px] tabular-nums text-right" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||
{pct}%
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 语言跑马灯:10 种热门语言 ×2 循环 */
|
||
function LangTicker({ langs }: { langs: string[] }) {
|
||
const doubled = [...langs, ...langs];
|
||
return (
|
||
<div className="j13-hero-ticker py-2" style={{ borderTop: "1px solid var(--hero-track)", borderBottom: "1px solid var(--hero-track)" }}>
|
||
<div className="j13-hero-ticker-track">
|
||
{doubled.map((l, i) => (
|
||
<span key={`${l}-${i}`} className="flex items-center gap-1.5 text-[11px] font-medium shrink-0" style={{ fontFamily: MONO, color: "var(--hero-ink-2)" }}>
|
||
<span className="inline-block w-[5px] h-[5px] rounded-full" style={{ background: snippetLangColor(l) }} />
|
||
{l}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function SnippetHero({
|
||
stats,
|
||
isLoggedIn,
|
||
}: {
|
||
stats: SnippetStats | null;
|
||
isLoggedIn: boolean;
|
||
}) {
|
||
const s = stats;
|
||
const total = useRollIn(s?.total_snippets ?? 0);
|
||
const copies = useRollIn(s?.total_copies ?? 0);
|
||
const views = useRollIn(s?.total_views ?? 0);
|
||
const comments = useRollIn(s?.total_comments ?? 0);
|
||
const forks = useRollIn(s?.total_forks ?? 0);
|
||
const contributors = useRollIn(s?.contributors ?? 0);
|
||
|
||
// 跑马灯展示的热门语言(去重后取前 10,兜底硬编码)
|
||
const tickerLangs = Array.from(new Set([
|
||
...topLangNames(s?.top_languages),
|
||
"go", "rust", "typescript", "python", "javascript", "bash", "dockerfile", "kotlin", "yaml", "sql",
|
||
])).slice(0, 10);
|
||
|
||
return (
|
||
<section className="j13-hero relative mb-6 rounded-2xl overflow-hidden">
|
||
{/* 网格底纹 */}
|
||
<div className="absolute inset-0 j13-hero-grid pointer-events-none" />
|
||
|
||
{/* 扫描光束 */}
|
||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ opacity: 0.5 }}>
|
||
<div className="j13-hero-scan" />
|
||
</div>
|
||
|
||
{/* 内容层 */}
|
||
<div className="relative px-5 pt-5 pb-5 sm:px-7 sm:pt-6 sm:pb-6">
|
||
{/* 顶部栏 */}
|
||
<div className="flex items-start justify-between gap-3 mb-5">
|
||
<div className="flex items-start gap-3 min-w-0">
|
||
<span
|
||
className="inline-flex items-center justify-center w-10 h-10 rounded-xl shrink-0"
|
||
style={{ background: "rgba(148,163,205,0.09)", border: "1px solid var(--hero-line)" }}
|
||
>
|
||
<CodeXml size={19} style={{ color: "var(--accent)" }} />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<span className="text-[11px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||
$ jiang13/snippets
|
||
</span>
|
||
<span className="text-[11px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||
--public --summary
|
||
</span>
|
||
<span className="j13-hero-caret inline-block w-[6px] h-[3px] rounded-sm" style={{ background: "var(--accent)" }} />
|
||
</div>
|
||
<h1 className="j13-hero-title text-[22px] sm:text-[26px] font-extrabold tracking-tight leading-none mt-0.5">
|
||
代码片段广场
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex items-center gap-2">
|
||
<span className="flex items-center gap-1.5 text-[10px] px-2 py-1 rounded-full" style={{ color: "var(--hero-ink-3)", fontFamily: MONO, background: "var(--hero-chip-bg)", border: "1px solid var(--hero-line)" }}>
|
||
<span className="inline-block w-2 h-2 rounded-full j13-hero-pulse" style={{ background: "#22c55e" }} />
|
||
ONLINE
|
||
</span>
|
||
{isLoggedIn && (
|
||
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5 shrink-0">
|
||
<Plus size={14} /> 新建
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计区 */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||
<StatBlock icon={<Binary size={12} />} label="Snippets" value={formatK(total)} delay={0} />
|
||
<StatBlock icon={<Eye size={12} />} label="Views" value={formatK(views)} delay={60} />
|
||
<StatBlock icon={<Copy size={12} />} label="Copies" value={formatK(copies)} delay={120} />
|
||
<StatBlock icon={<GitFork size={12} />} label="Forks" value={formatK(forks)} delay={180} />
|
||
<StatBlock icon={<MessageSquare size={12} />} label="Comments" value={formatK(comments)} delay={240} />
|
||
<StatBlock icon={<Users size={12} />} label="Devs" value={formatK(contributors)} delay={300} />
|
||
</div>
|
||
|
||
{/* 语言矩阵 */}
|
||
{s && s.top_languages.length > 0 && <LangMatrix topLanguages={s.top_languages} total={s.total_snippets} />}
|
||
|
||
{/* 底栏 */}
|
||
<div className="mt-4 flex items-center justify-between">
|
||
<span className="flex items-center gap-1.5 text-[10px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||
<Zap size={9} />
|
||
{s ? "DATA_SYNCED" : "OFFLINE"}
|
||
</span>
|
||
<span className="text-[10px] tabular-nums" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
|
||
LAT<span style={{ color: "var(--accent)" }}> 37.7749°N</span> LNG<span style={{ color: "var(--accent)" }}> 122.4194°W</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 语言跑马灯 */}
|
||
<LangTicker langs={tickerLangs} />
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function topLangNames(arr?: { language: string; count: number }[]) {
|
||
if (!arr) return [];
|
||
return arr.map((a) => a.language).filter(Boolean);
|
||
}
|