Delete src/features/home

#3
by vcsb - opened
Files changed (1) hide show
  1. src/features/home/ProductHomePage.tsx +0 -605
src/features/home/ProductHomePage.tsx DELETED
@@ -1,605 +0,0 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
2
- import { Link } from "react-router-dom";
3
- import { useQuery } from "@tanstack/react-query";
4
- import { Clock3, Github, Search, Shield } from "lucide-react";
5
- import type { LiveScanItem, OverviewStats, ProjectCard, Severity } from "@/shared/types";
6
- import { api } from "../../shared/api/client";
7
- import { Button } from "../../components/Button";
8
- import { EmptyState } from "../../components/EmptyState";
9
- import { RepoSubmitForm } from "../../components/RepoSubmitForm";
10
- import { SeverityBar } from "../../components/SeverityBar";
11
- import { AuthButton } from "../../components/AuthButton";
12
- import { NotificationBell } from "../../components/NotificationBell";
13
- import { formatRelativeTime, formatStars, totalFindings } from "../../shared/lib/format";
14
-
15
- const OWN_REPO =
16
- (import.meta.env.VITE_GITHUB_REPO_URL as string | undefined) ||
17
- "https://github.com/Clouditera/OpenVuln";
18
-
19
- /** List state survives project-detail navigation. */
20
- const listCache = { sort: "stars" as "newest" | "stars", q: "" };
21
-
22
- const SEV_BARS: Array<{ level: Severity; color: string; label: string; short: string }> = [
23
- { level: "critical", color: "#E5484D", label: "Critical", short: "Crit" },
24
- { level: "high", color: "#F2604F", label: "High", short: "High" },
25
- { level: "medium", color: "#FF8A3C", label: "Medium", short: "Med" },
26
- { level: "low", color: "#F0C43A", label: "Low", short: "Low" },
27
- ];
28
-
29
- function formatElapsed(seconds: number): string {
30
- if (seconds < 60) return `${seconds}s`;
31
- const minutes = Math.floor(seconds / 60);
32
- if (minutes < 60) return `${minutes}m`;
33
- return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
34
- }
35
-
36
- function BrandMark() {
37
- return (
38
- <img
39
- src="/logo.svg"
40
- alt=""
41
- className="h-8 w-8 shrink-0 rounded-lg"
42
- aria-hidden
43
- />
44
- );
45
- }
46
-
47
- /* ── 滚动进入视口时的克制上浮(一次性) ── */
48
- function Reveal({
49
- children,
50
- delay = 0,
51
- className = "",
52
- }: {
53
- children: React.ReactNode;
54
- delay?: number;
55
- className?: string;
56
- }) {
57
- const ref = useRef<HTMLDivElement>(null);
58
- const [inView, setInView] = useState(false);
59
- useEffect(() => {
60
- const el = ref.current;
61
- if (!el) return;
62
- const observer = new IntersectionObserver(
63
- (entries) => {
64
- if (entries.some((entry) => entry.isIntersecting)) {
65
- setInView(true);
66
- observer.disconnect();
67
- }
68
- },
69
- { rootMargin: "0px 0px -6% 0px" },
70
- );
71
- observer.observe(el);
72
- return () => observer.disconnect();
73
- }, []);
74
- return (
75
- <div
76
- ref={ref}
77
- style={delay ? { transitionDelay: `${delay}ms` } : undefined}
78
- className={`ov-reveal ${inView ? "ov-in" : ""} ${className}`}
79
- >
80
- {children}
81
- </div>
82
- );
83
- }
84
-
85
- function usePrefersReducedMotion(): boolean {
86
- const [reduced, setReduced] = useState(false);
87
- useEffect(() => {
88
- const media = window.matchMedia("(prefers-reduced-motion: reduce)");
89
- setReduced(media.matches);
90
- const update = () => setReduced(media.matches);
91
- media.addEventListener("change", update);
92
- return () => media.removeEventListener("change", update);
93
- }, []);
94
- return reduced;
95
- }
96
-
97
- function useCountUp(target: number, duration = 700): number {
98
- const reduced = usePrefersReducedMotion();
99
- const [value, setValue] = useState(reduced ? target : 0);
100
- useEffect(() => {
101
- if (reduced) {
102
- setValue(target);
103
- return;
104
- }
105
- let frame = 0;
106
- const start = performance.now();
107
- const tick = (time: number) => {
108
- const progress = Math.min(1, (time - start) / duration);
109
- setValue(Math.round(target * (1 - (1 - progress) ** 3)));
110
- if (progress < 1) frame = requestAnimationFrame(tick);
111
- };
112
- frame = requestAnimationFrame(tick);
113
- return () => cancelAnimationFrame(frame);
114
- }, [duration, reduced, target]);
115
- return value;
116
- }
117
-
118
- function AnimatedNumber({ value }: { value: number }) {
119
- return <>{useCountUp(value).toLocaleString()}</>;
120
- }
121
-
122
- /* ── 控制台带子共用网格:三条带子的竖向 hairline 严格同轴 ── */
123
- const BAND_GRID = "grid divide-y divide-line lg:grid-cols-4 lg:divide-y-0 lg:divide-x";
124
- const BAND_CELL = "py-5 lg:px-6 lg:first:pl-0";
125
-
126
- /* ── 实时信息板(departures strip):正在扫描的仓库,全宽横条 ── */
127
- function LiveStrip({
128
- scans,
129
- loading,
130
- failed,
131
- }: {
132
- scans: LiveScanItem[];
133
- loading: boolean;
134
- failed: boolean;
135
- }) {
136
- const active = scans.length;
137
- // 单行 4 列:1 列标签 + 3 列条目;超过 3 个时末列汇总
138
- const shown = active > 3 ? scans.slice(0, 2) : scans;
139
- const overflow = active - shown.length;
140
- const fillers = Math.max(0, 3 - shown.length - (overflow > 0 ? 1 : 0));
141
-
142
- return (
143
- <section aria-labelledby="ops-title" className={`border-t border-line ${BAND_GRID}`}>
144
- <div className={`flex items-center justify-between gap-3 lg:flex-col lg:items-start lg:justify-center ${BAND_CELL}`}>
145
- <h2
146
- id="ops-title"
147
- className="flex items-center gap-2.5 font-mono text-[10px] font-medium uppercase tracking-[0.22em] text-ink-secondary"
148
- >
149
- <span className="relative flex h-1.5 w-1.5">
150
- {active > 0 && (
151
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-60 motion-reduce:hidden" />
152
- )}
153
- <span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${active > 0 ? "bg-success" : "bg-ink-tertiary"}`} />
154
- </span>
155
- Live — scanning now
156
- </h2>
157
- {!loading && !failed && (
158
- <p className="font-mono text-[10px] tabular-nums text-ink-tertiary lg:mt-2">
159
- {active} active
160
- </p>
161
- )}
162
- </div>
163
-
164
- {loading ? (
165
- [0, 1, 2].map((item) => (
166
- <div key={item} className={`flex items-center ${BAND_CELL}`} aria-hidden>
167
- <div className="h-9 w-full animate-pulse rounded-md bg-surface-sunken/70" />
168
- </div>
169
- ))
170
- ) : failed ? (
171
- <p className={`flex items-center font-mono text-[11px] uppercase tracking-[0.14em] text-ink-tertiary lg:col-span-3 ${BAND_CELL}`}>
172
- Signal lost — live data reconnects automatically
173
- </p>
174
- ) : active === 0 ? (
175
- <p className={`flex items-center font-mono text-[11px] uppercase tracking-[0.14em] text-ink-tertiary lg:col-span-3 ${BAND_CELL}`}>
176
- Standing by — no repository is being scanned right now
177
- </p>
178
- ) : (
179
- <>
180
- {shown.map((scan) => (
181
- <Link
182
- key={scan.project_id}
183
- to={`/p/${scan.full_name}`}
184
- className={`group flex min-w-0 items-center justify-between gap-4 transition-colors hover:bg-surface-sunken/60 focus-ring ${BAND_CELL}`}
185
- >
186
- <span className="min-w-0">
187
- <span className="block truncate font-mono text-[13px] font-medium text-ink group-hover:text-white">
188
- {scan.full_name}
189
- </span>
190
- <span className="mt-1 block font-mono text-[10px] uppercase tracking-[0.1em] text-ink-tertiary">
191
- {scan.state === "dispatching" ? "preparing" : "scanning"} · {formatElapsed(scan.elapsed_sec)}
192
- </span>
193
- </span>
194
- <span className="shrink-0 text-right">
195
- <span className="block font-mono text-[16px] font-semibold tabular-nums text-ink">
196
- {scan.findings_so_far}
197
- </span>
198
- <span className="block font-mono text-[9px] uppercase tracking-[0.12em] text-ink-tertiary">found</span>
199
- </span>
200
- </Link>
201
- ))}
202
- {overflow > 0 && (
203
- <p className={`flex items-center font-mono text-[11px] uppercase tracking-[0.14em] text-ink-tertiary ${BAND_CELL}`}>
204
- +{overflow} more scanning
205
- </p>
206
- )}
207
- {Array.from({ length: fillers }).map((_, index) => (
208
- <div key={`filler-${index}`} className="hidden lg:block" aria-hidden />
209
- ))}
210
- </>
211
- )}
212
- </section>
213
- );
214
- }
215
-
216
- /* ── 指标带:账本数字 + severity 光谱,与顶部饰带互为图例 ── */
217
- function MetricsBand({
218
- stats,
219
- loading,
220
- failed,
221
- }: {
222
- stats: OverviewStats | undefined;
223
- loading: boolean;
224
- failed: boolean;
225
- }) {
226
- const unavailable = loading || failed || !stats;
227
- const scanned = stats
228
- ? stats.scanned_project_count ?? Math.min(stats.project_count, stats.scan_completed_count)
229
- : 0;
230
- const counts = stats?.severity_counts ?? { critical: 0, high: 0, medium: 0, low: 0 };
231
-
232
- const cells: Array<{ label: string; value: number }> = [
233
- { label: "Repositories scanned", value: scanned },
234
- { label: "Findings discovered", value: stats?.finding_total ?? 0 },
235
- { label: "Scans in progress", value: stats?.scan_in_progress_count ?? stats?.live?.scanning?.length ?? 0 },
236
- ];
237
-
238
- return (
239
- <section aria-label="Platform metrics" className={`border-y border-line ${BAND_GRID}`}>
240
- {cells.map((cell) => (
241
- <div key={cell.label} className={BAND_CELL}>
242
- <p className="font-mono text-3xl font-medium tabular-nums tracking-tight text-ink sm:text-[36px]">
243
- {unavailable ? "—" : <AnimatedNumber value={cell.value} />}
244
- </p>
245
- <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.16em] text-ink-tertiary">{cell.label}</p>
246
- </div>
247
- ))}
248
- <div className={`flex flex-col justify-between gap-4 ${BAND_CELL}`}>
249
- <div className="space-y-2" role="img" aria-label="Findings by severity">
250
- {SEV_BARS.map((s) => {
251
- const count = counts[s.level] ?? 0;
252
- const max = Math.max(1, ...SEV_BARS.map((x) => counts[x.level] ?? 0));
253
- return (
254
- <div key={s.level} className="flex items-center gap-2.5">
255
- <span className="w-8 shrink-0 font-mono text-[9px] uppercase tracking-[0.08em] text-ink-tertiary">
256
- {s.short}
257
- </span>
258
- <span className="h-[3px] flex-1 overflow-hidden rounded-full bg-surface-sunken">
259
- <span
260
- className="block h-full rounded-full transition-[width] duration-700 ease-out"
261
- style={{
262
- width: unavailable ? "0%" : `${(count / max) * 100}%`,
263
- backgroundColor: s.color,
264
- }}
265
- />
266
- </span>
267
- <span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-ink">
268
- {unavailable ? "—" : count.toLocaleString()}
269
- </span>
270
- </div>
271
- );
272
- })}
273
- </div>
274
- <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-ink-tertiary">
275
- Findings by severity
276
- </p>
277
- </div>
278
- </section>
279
- );
280
- }
281
-
282
- /* ── 仓库账本行 ── */
283
- function LedgerAvatar({ owner }: { owner: string }) {
284
- const [failed, setFailed] = useState(false);
285
- if (failed) {
286
- return (
287
- <span
288
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-surface-sunken font-mono text-[10px] font-semibold text-ink-secondary"
289
- aria-hidden
290
- >
291
- {owner.slice(0, 1).toUpperCase()}
292
- </span>
293
- );
294
- }
295
- return (
296
- <img
297
- src={`https://github.com/${owner}.png?size=64`}
298
- alt=""
299
- width={28}
300
- height={28}
301
- className="h-7 w-7 shrink-0 rounded-full border border-line bg-surface-sunken"
302
- onError={() => setFailed(true)}
303
- />
304
- );
305
- }
306
-
307
- function LedgerRow({ project }: { project: ProjectCard }) {
308
- const state = project.latest_scan?.state;
309
- const scanning = state === "scanning" || state === "dispatching";
310
- const waiting = state !== "completed" && !scanning;
311
- const soFar = project.latest_scan?.findings_so_far ?? 0;
312
- const findings = totalFindings(project.severity_counts);
313
-
314
- return (
315
- <Link
316
- to={`/p/${project.owner_login}/${project.name}`}
317
- className="group grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2 border-b border-line/70 px-2 py-3.5 transition-colors last:border-b-0 hover:bg-surface-sunken/60 focus-ring md:grid-cols-[minmax(0,2.4fr)_110px_78px_150px_70px_150px] md:gap-x-5"
318
- >
319
- {/* repository */}
320
- <span className="flex min-w-0 items-center gap-3">
321
- <LedgerAvatar owner={project.owner_login} />
322
- <span className="min-w-0">
323
- <span className="block truncate font-mono text-[13px] font-medium text-ink group-hover:text-white">
324
- {project.full_name}
325
- </span>
326
- {project.description && (
327
- <span className="mt-0.5 block truncate text-[12px] text-ink-tertiary">{project.description}</span>
328
- )}
329
- </span>
330
- </span>
331
-
332
- {/* language */}
333
- <span className="hidden min-w-0 items-center gap-1.5 truncate text-[12px] text-ink-secondary md:flex">
334
- {project.language ? (
335
- <>
336
- <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-ink/40" />
337
- <span className="truncate">{project.language}</span>
338
- </>
339
- ) : (
340
- <span className="text-ink-tertiary">—</span>
341
- )}
342
- </span>
343
-
344
- {/* stars */}
345
- <span className="hidden font-mono text-[12px] tabular-nums text-ink-secondary md:block">
346
- ★ {formatStars(project.stars)}
347
- </span>
348
-
349
- {/* status */}
350
- <span className="hidden md:block">
351
- {scanning ? (
352
- <span className="inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.06em] text-ink">
353
- <span className="relative flex h-1.5 w-1.5">
354
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-ink opacity-40 motion-reduce:animate-none" />
355
- <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-ink" />
356
- </span>
357
- scanning{soFar > 0 ? ` · ${soFar}` : ""}
358
- </span>
359
- ) : waiting ? (
360
- <span className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-tertiary">pending</span>
361
- ) : (
362
- <span className="font-mono text-[11px] text-ink-secondary">
363
- {project.latest_scan?.finished_at ? formatRelativeTime(project.latest_scan.finished_at) : "done"}
364
- </span>
365
- )}
366
- </span>
367
-
368
- {/* findings count */}
369
- <span className="text-right font-mono text-[13px] font-medium tabular-nums text-ink md:text-left">
370
- {waiting || (scanning && findings === 0) ? <span className="text-ink-tertiary">—</span> : findings}
371
- </span>
372
-
373
- {/* severity */}
374
- <span className="col-span-2 md:col-span-1">
375
- {!waiting && !scanning && findings > 0 ? (
376
- <SeverityBar counts={project.severity_counts} showLegend={false} widthClass="w-full" heightClass="h-1" />
377
- ) : (
378
- <span className="block h-1 w-full rounded-full bg-surface-sunken md:w-full" aria-hidden />
379
- )}
380
- </span>
381
-
382
- {/* mobile meta line */}
383
- <span className="col-span-2 flex flex-wrap items-center gap-x-3 font-mono text-[10px] uppercase tracking-[0.06em] text-ink-tertiary md:hidden">
384
- {project.language && <span>{project.language}</span>}
385
- <span>★ {formatStars(project.stars)}</span>
386
- {scanning ? <span className="text-ink">scanning{soFar > 0 ? ` · ${soFar}` : ""}</span> : waiting ? <span>pending</span> : project.latest_scan?.finished_at ? <span>{formatRelativeTime(project.latest_scan.finished_at)}</span> : null}
387
- </span>
388
- </Link>
389
- );
390
- }
391
-
392
- export function ProductHomePage() {
393
- const [q, setQState] = useState(listCache.q);
394
- const [sort, setSortState] = useState<"newest" | "stars">(listCache.sort);
395
- const setQ = (value: string) => {
396
- listCache.q = value;
397
- setQState(value);
398
- };
399
- const setSort = (value: "newest" | "stars") => {
400
- listCache.sort = value;
401
- setSortState(value);
402
- };
403
-
404
- const overview = useQuery({
405
- queryKey: ["public", "overview"],
406
- queryFn: api.overview,
407
- refetchInterval: 15_000,
408
- });
409
-
410
- const projects = useQuery({
411
- queryKey: ["public", "projects", sort],
412
- queryFn: () => api.listProjects({ sort, page: 1, page_size: 100 }),
413
- });
414
- const allItems = useMemo(() => projects.data?.items ?? [], [projects.data]);
415
-
416
- const filtered = useMemo(() => {
417
- const needle = q.trim().toLowerCase();
418
- return allItems
419
- .filter((project) => {
420
- const state = project.latest_scan?.state;
421
- const visibleState = state === "dispatching" || state === "scanning" || state === "completed";
422
- if (!visibleState) return false;
423
- if (!needle) return true;
424
- return (
425
- project.full_name.toLowerCase().includes(needle) ||
426
- (project.description ?? "").toLowerCase().includes(needle)
427
- );
428
- });
429
- }, [allItems, q]);
430
-
431
- const scans = overview.data?.live?.scanning ?? [];
432
-
433
- return (
434
- <div id="top" className="relative isolate min-h-screen overflow-hidden bg-surface text-ink">
435
- <div className="openvuln-hero-video" aria-hidden>
436
- <video src="/hero-bg.mp4" poster="/hero-bg-poster.jpg" autoPlay muted loop playsInline preload="auto" />
437
- </div>
438
-
439
- <header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur-xl">
440
- <div className="mx-auto flex h-16 w-full max-w-[1200px] items-center justify-between px-5 sm:px-8">
441
- <Link to="/" className="flex items-center gap-2.5 rounded-lg focus-ring">
442
- <BrandMark />
443
- <span className="font-display text-[15px] font-semibold tracking-tight text-ink">OpenVuln</span>
444
- </Link>
445
- <div className="flex items-center gap-2">
446
- <NotificationBell />
447
- <AuthButton />
448
- <a
449
- href={OWN_REPO}
450
- target="_blank"
451
- rel="noreferrer"
452
- aria-label="OpenVuln on GitHub"
453
- className="hidden h-9 items-center gap-2 rounded-full border border-line bg-surface-raised px-3.5 text-xs font-medium text-ink-secondary transition hover:border-line-strong hover:text-ink focus-ring sm:inline-flex"
454
- >
455
- <Github size={14} />
456
- GitHub
457
- </a>
458
- </div>
459
- </div>
460
- </header>
461
-
462
- <main className="mx-auto w-full max-w-[1200px] px-5 pb-16 sm:px-8">
463
- {/* ── Hero:大字报 —— 整幅展示字 + 出版物边注 ── */}
464
- <section id="submit" className="scroll-mt-24 pt-10 sm:pt-14">
465
- <div className="openvuln-brand">
466
- <h1 className="mt-10 font-display font-medium uppercase leading-[0.94] tracking-[-0.015em] sm:mt-12">
467
- <span className="ov-line-mask">
468
- <span className="ov-line openvuln-outline block text-[15vw] sm:text-[clamp(64px,9.6vw,116px)]" style={{ animationDelay: "80ms" }}>
469
- Open source<span className="openvuln-title-comma">,</span>
470
- </span>
471
- </span>
472
- <span className="ov-line-mask">
473
- <span className="ov-line openvuln-solid block text-[15vw] sm:text-[clamp(64px,9.6vw,116px)]" style={{ animationDelay: "200ms" }}>
474
- audited by GLM<span className="sr-only">.</span><span className="openvuln-title-mark openvuln-title-period" aria-hidden />
475
- </span>
476
- </span>
477
- </h1>
478
- </div>
479
-
480
- <div className="openvuln-composer mt-10 grid items-end gap-8 pb-12 sm:mt-14 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16 lg:pb-16" style={{ animationDelay: "380ms" }}>
481
- <p className="max-w-[44ch] text-[15px] leading-7 text-ink-secondary">
482
- Submit a public repository and OpenVuln hunts its vulnerabilities.
483
- Protect your open-source repository, together with GLM.
484
- </p>
485
- <div className="w-full lg:max-w-[600px] lg:justify-self-end">
486
- <RepoSubmitForm size="hero" appearance="dark" className="w-full" align="left" />
487
- <p className="mt-2 flex items-center gap-1.5 text-xs leading-5 text-ink-tertiary" role="note">
488
- <Clock3 size={12} strokeWidth={1.8} aria-hidden />
489
- Submissions may require manual review · A full scan usually takes 12+ hours
490
- </p>
491
- </div>
492
- </div>
493
- </section>
494
-
495
- {/* ── 实时信息板 ── */}
496
- <Reveal>
497
- <LiveStrip scans={scans} loading={overview.isLoading} failed={overview.isError} />
498
- </Reveal>
499
-
500
- {/* ── 指标带 ── */}
501
- <Reveal delay={60}>
502
- <MetricsBand stats={overview.data} loading={overview.isLoading} failed={overview.isError} />
503
- </Reveal>
504
-
505
- {/* ── 仓库账本 ── */}
506
- <section id="repositories" aria-labelledby="projects-title" className="scroll-mt-24 pt-12">
507
- <Reveal className="flex flex-wrap items-end justify-between gap-4">
508
- <div>
509
- <p className="font-mono text-[10px] font-medium uppercase tracking-[0.22em] text-ink-tertiary">
510
- Repository index
511
- </p>
512
- <h2 id="projects-title" className="mt-2 font-display text-2xl font-semibold tracking-tight text-ink">
513
- Scanned repositories
514
- </h2>
515
- </div>
516
- <div className="flex flex-col gap-2.5 sm:flex-row sm:items-center">
517
- <div className="relative">
518
- <Search size={14} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-tertiary" />
519
- <input
520
- type="search"
521
- value={q}
522
- onChange={(event) => setQ(event.target.value)}
523
- placeholder="Search repositories"
524
- aria-label="Search repositories"
525
- className="h-9 w-full rounded-lg border border-line bg-surface-raised pl-9 pr-3 font-mono text-[13px] text-ink placeholder:text-ink-tertiary focus-ring sm:w-64"
526
- />
527
- </div>
528
- <div
529
- className="inline-flex self-start rounded-lg border border-line bg-surface-raised p-0.5 sm:self-auto"
530
- aria-label="Sort repositories"
531
- >
532
- <button
533
- type="button"
534
- aria-pressed={sort === "stars"}
535
- onClick={() => setSort("stars")}
536
- className={`rounded-md px-3 py-1.5 font-mono text-[11px] font-medium transition focus-ring ${
537
- sort === "stars" ? "bg-ink text-surface" : "text-ink-tertiary hover:text-ink"
538
- }`}
539
- >
540
- Most starred
541
- </button>
542
- <button
543
- type="button"
544
- aria-pressed={sort === "newest"}
545
- onClick={() => setSort("newest")}
546
- className={`rounded-md px-3 py-1.5 font-mono text-[11px] font-medium transition focus-ring ${
547
- sort === "newest" ? "bg-ink text-surface" : "text-ink-tertiary hover:text-ink"
548
- }`}
549
- >
550
- Recently added
551
- </button>
552
- </div>
553
- </div>
554
- </Reveal>
555
-
556
- {/* 表头 */}
557
- <div className="mt-6 hidden grid-cols-[minmax(0,2.4fr)_110px_78px_150px_70px_150px] gap-x-5 border-b border-line px-2 pb-2.5 font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-ink-tertiary md:grid">
558
- <span>Repository</span>
559
- <span>Language</span>
560
- <span>Stars</span>
561
- <span>Last scan</span>
562
- <span>Findings</span>
563
- <span>Severity</span>
564
- </div>
565
- <div className="mt-6 border-b border-line md:hidden" aria-hidden />
566
-
567
- <div
568
- className="openvuln-scrollbar max-h-[360px] overflow-y-auto overscroll-contain pr-2"
569
- aria-label="Scanned repository list"
570
- tabIndex={0}
571
- >
572
- {projects.isLoading ? (
573
- <div className="space-y-2 py-5">
574
- {Array.from({ length: 5 }).map((_, index) => (
575
- <div key={index} className="h-14 animate-pulse rounded-lg bg-surface-sunken/70" />
576
- ))}
577
- </div>
578
- ) : projects.isError ? (
579
- <EmptyState icon={Shield} title="Could not load repositories" description="The OpenVuln API may be unavailable." />
580
- ) : filtered.length === 0 ? (
581
- <EmptyState
582
- icon={Shield}
583
- title={q ? "No matching repositories" : "No repositories yet"}
584
- description={
585
- q
586
- ? "Try a different repository name."
587
- : "Submit the first open-source repository to be scanned by VulnHunter."
588
- }
589
- action={!q ? <Link to="/submit"><Button>Submit a repository</Button></Link> : undefined}
590
- />
591
- ) : (
592
- <>
593
- {filtered.map((project) => (
594
- <LedgerRow key={project.id} project={project} />
595
- ))}
596
- </>
597
- )}
598
- </div>
599
-
600
- </section>
601
- </main>
602
-
603
- </div>
604
- );
605
- }