Spaces:
Running
Running
Delete src/features/home
#3
by vcsb - opened
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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|