zRzRzRzRzRzRzR commited on
Commit
d22337b
·
1 Parent(s): 2eb87e3

feat: sync latest OpenVuln frontend

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -9,6 +9,7 @@ RUN pnpm install --frozen-lockfile
9
 
10
  COPY index.html postcss.config.js tailwind.config.js tsconfig.json vite.config.ts ./
11
  COPY src ./src
 
12
  ARG VITE_API_BASE_URL=https://openvuln.vulnhunter.pro
13
  ARG VITE_GITHUB_REPO_URL=https://github.com/Clouditera/OpenVuln
14
  ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
 
9
 
10
  COPY index.html postcss.config.js tailwind.config.js tsconfig.json vite.config.ts ./
11
  COPY src ./src
12
+ COPY public ./public
13
  ARG VITE_API_BASE_URL=https://openvuln.vulnhunter.pro
14
  ARG VITE_GITHUB_REPO_URL=https://github.com/Clouditera/OpenVuln
15
  ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
package.json CHANGED
@@ -8,6 +8,9 @@
8
  "build": "tsc -b && vite build"
9
  },
10
  "dependencies": {
 
 
 
11
  "@tanstack/react-query": "^5.62.7",
12
  "js-yaml": "^5.2.3",
13
  "lucide-react": "^0.469.0",
 
8
  "build": "tsc -b && vite build"
9
  },
10
  "dependencies": {
11
+ "@fontsource-variable/inter": "^5.3.0",
12
+ "@fontsource-variable/space-grotesk": "^5.3.0",
13
+ "@fontsource/ibm-plex-mono": "^5.3.0",
14
  "@tanstack/react-query": "^5.62.7",
15
  "js-yaml": "^5.2.3",
16
  "lucide-react": "^0.469.0",
pnpm-lock.yaml CHANGED
@@ -8,6 +8,15 @@ importers:
8
 
9
  .:
10
  dependencies:
 
 
 
 
 
 
 
 
 
11
  '@tanstack/react-query':
12
  specifier: ^5.62.7
13
  version: 5.101.4(react@18.3.1)
@@ -300,6 +309,15 @@ packages:
300
  cpu: [x64]
301
  os: [win32]
302
 
 
 
 
 
 
 
 
 
 
303
  '@jridgewell/gen-mapping@0.3.13':
304
  resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
305
 
@@ -321,6 +339,7 @@ packages:
321
  engines: {node: ^22.20 || ^24.12 || >=25}
322
  cpu: [x64]
323
  os: [linux]
 
324
 
325
  '@nodelib/fs.scandir@2.1.5':
326
  resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -375,66 +394,79 @@ packages:
375
  resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
376
  cpu: [arm]
377
  os: [linux]
 
378
 
379
  '@rollup/rollup-linux-arm-musleabihf@4.62.4':
380
  resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
381
  cpu: [arm]
382
  os: [linux]
 
383
 
384
  '@rollup/rollup-linux-arm64-gnu@4.62.4':
385
  resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
386
  cpu: [arm64]
387
  os: [linux]
 
388
 
389
  '@rollup/rollup-linux-arm64-musl@4.62.4':
390
  resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
391
  cpu: [arm64]
392
  os: [linux]
 
393
 
394
  '@rollup/rollup-linux-loong64-gnu@4.62.4':
395
  resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
396
  cpu: [loong64]
397
  os: [linux]
 
398
 
399
  '@rollup/rollup-linux-loong64-musl@4.62.4':
400
  resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
401
  cpu: [loong64]
402
  os: [linux]
 
403
 
404
  '@rollup/rollup-linux-ppc64-gnu@4.62.4':
405
  resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
406
  cpu: [ppc64]
407
  os: [linux]
 
408
 
409
  '@rollup/rollup-linux-ppc64-musl@4.62.4':
410
  resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
411
  cpu: [ppc64]
412
  os: [linux]
 
413
 
414
  '@rollup/rollup-linux-riscv64-gnu@4.62.4':
415
  resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
416
  cpu: [riscv64]
417
  os: [linux]
 
418
 
419
  '@rollup/rollup-linux-riscv64-musl@4.62.4':
420
  resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
421
  cpu: [riscv64]
422
  os: [linux]
 
423
 
424
  '@rollup/rollup-linux-s390x-gnu@4.62.4':
425
  resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
426
  cpu: [s390x]
427
  os: [linux]
 
428
 
429
  '@rollup/rollup-linux-x64-gnu@4.62.4':
430
  resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
431
  cpu: [x64]
432
  os: [linux]
 
433
 
434
  '@rollup/rollup-linux-x64-musl@4.62.4':
435
  resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
436
  cpu: [x64]
437
  os: [linux]
 
438
 
439
  '@rollup/rollup-openbsd-x64@4.62.4':
440
  resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
@@ -1162,6 +1194,12 @@ snapshots:
1162
  '@esbuild/win32-x64@0.25.12':
1163
  optional: true
1164
 
 
 
 
 
 
 
1165
  '@jridgewell/gen-mapping@0.3.13':
1166
  dependencies:
1167
  '@jridgewell/sourcemap-codec': 1.5.5
 
8
 
9
  .:
10
  dependencies:
11
+ '@fontsource-variable/inter':
12
+ specifier: ^5.3.0
13
+ version: 5.3.0
14
+ '@fontsource-variable/space-grotesk':
15
+ specifier: ^5.3.0
16
+ version: 5.3.0
17
+ '@fontsource/ibm-plex-mono':
18
+ specifier: ^5.3.0
19
+ version: 5.3.0
20
  '@tanstack/react-query':
21
  specifier: ^5.62.7
22
  version: 5.101.4(react@18.3.1)
 
309
  cpu: [x64]
310
  os: [win32]
311
 
312
+ '@fontsource-variable/inter@5.3.0':
313
+ resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
314
+
315
+ '@fontsource-variable/space-grotesk@5.3.0':
316
+ resolution: {integrity: sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==}
317
+
318
+ '@fontsource/ibm-plex-mono@5.3.0':
319
+ resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==}
320
+
321
  '@jridgewell/gen-mapping@0.3.13':
322
  resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
323
 
 
339
  engines: {node: ^22.20 || ^24.12 || >=25}
340
  cpu: [x64]
341
  os: [linux]
342
+ libc: [glibc]
343
 
344
  '@nodelib/fs.scandir@2.1.5':
345
  resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
 
394
  resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
395
  cpu: [arm]
396
  os: [linux]
397
+ libc: [glibc]
398
 
399
  '@rollup/rollup-linux-arm-musleabihf@4.62.4':
400
  resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
401
  cpu: [arm]
402
  os: [linux]
403
+ libc: [musl]
404
 
405
  '@rollup/rollup-linux-arm64-gnu@4.62.4':
406
  resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
407
  cpu: [arm64]
408
  os: [linux]
409
+ libc: [glibc]
410
 
411
  '@rollup/rollup-linux-arm64-musl@4.62.4':
412
  resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
413
  cpu: [arm64]
414
  os: [linux]
415
+ libc: [musl]
416
 
417
  '@rollup/rollup-linux-loong64-gnu@4.62.4':
418
  resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
419
  cpu: [loong64]
420
  os: [linux]
421
+ libc: [glibc]
422
 
423
  '@rollup/rollup-linux-loong64-musl@4.62.4':
424
  resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
425
  cpu: [loong64]
426
  os: [linux]
427
+ libc: [musl]
428
 
429
  '@rollup/rollup-linux-ppc64-gnu@4.62.4':
430
  resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
431
  cpu: [ppc64]
432
  os: [linux]
433
+ libc: [glibc]
434
 
435
  '@rollup/rollup-linux-ppc64-musl@4.62.4':
436
  resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
437
  cpu: [ppc64]
438
  os: [linux]
439
+ libc: [musl]
440
 
441
  '@rollup/rollup-linux-riscv64-gnu@4.62.4':
442
  resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
443
  cpu: [riscv64]
444
  os: [linux]
445
+ libc: [glibc]
446
 
447
  '@rollup/rollup-linux-riscv64-musl@4.62.4':
448
  resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
449
  cpu: [riscv64]
450
  os: [linux]
451
+ libc: [musl]
452
 
453
  '@rollup/rollup-linux-s390x-gnu@4.62.4':
454
  resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
455
  cpu: [s390x]
456
  os: [linux]
457
+ libc: [glibc]
458
 
459
  '@rollup/rollup-linux-x64-gnu@4.62.4':
460
  resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
461
  cpu: [x64]
462
  os: [linux]
463
+ libc: [glibc]
464
 
465
  '@rollup/rollup-linux-x64-musl@4.62.4':
466
  resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
467
  cpu: [x64]
468
  os: [linux]
469
+ libc: [musl]
470
 
471
  '@rollup/rollup-openbsd-x64@4.62.4':
472
  resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
 
1194
  '@esbuild/win32-x64@0.25.12':
1195
  optional: true
1196
 
1197
+ '@fontsource-variable/inter@5.3.0': {}
1198
+
1199
+ '@fontsource-variable/space-grotesk@5.3.0': {}
1200
+
1201
+ '@fontsource/ibm-plex-mono@5.3.0': {}
1202
+
1203
  '@jridgewell/gen-mapping@0.3.13':
1204
  dependencies:
1205
  '@jridgewell/sourcemap-codec': 1.5.5
public/hero-bg-poster.jpg ADDED
public/hero-bg.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:22578443c1f297aa6c9271d939f33ab8043bf98deb87f8c75b8a38948d11ee9d
3
+ size 1142870
public/logo.svg ADDED
src/components/AuthButton.tsx CHANGED
@@ -1,17 +1,17 @@
1
  import { useEffect, useRef, useState } from "react";
2
- import { useLocation, useNavigate } from "react-router-dom";
3
  import { Github, LogOut, ChevronDown, FolderGit2 } from "lucide-react";
4
- import { navigateToLogin, navigateToLoginPopup, isEmbedded, currentReturnTo } from "../shared/api/client";
5
  import { useLogout, useMe } from "../features/auth/useAuth";
6
 
7
  /**
8
  * 顶栏登录态:未登录 = Sign in with GitHub;已登录 = 头像 + 下拉(Logout)。
9
- * appearance="dark" 用于 zai 深着陆页头部(与 GitHub pill 同族样式
10
  */
11
  export function AuthButton({ appearance = "light" }: { appearance?: "light" | "dark" }) {
 
12
  const meQ = useMe();
13
  const logoutM = useLogout();
14
- const { pathname, search } = useLocation();
15
  const nav = useNavigate();
16
  const [open, setOpen] = useState(false);
17
  const rootRef = useRef<HTMLDivElement>(null);
@@ -34,10 +34,6 @@ export function AuthButton({ appearance = "light" }: { appearance?: "light" | "d
34
 
35
  // 加载中/后端未接入 auth:按未登录渲染(不阻塞页面)
36
  const user = meQ.data?.authenticated ? meQ.data.user : null;
37
- const dark = appearance === "dark";
38
- // loginUrl for href fallback (non-iframe); navigateToLogin for onClick
39
- void pathname; void search;
40
-
41
  if (!user) {
42
  return (
43
  <button
@@ -47,16 +43,12 @@ export function AuthButton({ appearance = "light" }: { appearance?: "light" | "d
47
  navigateToLoginPopup();
48
  window.dispatchEvent(new Event("ov-oauth-popup-opened"));
49
  } else {
50
- navigateToLogin(currentReturnTo());
51
  }
52
  }}
53
- className={
54
- dark
55
- ? "inline-flex h-9 items-center gap-2 rounded-full border border-[#333] bg-[#030303] px-3.5 text-xs font-medium text-[#acacb0] transition hover:border-[#484a58] hover:bg-[#111216] hover:text-white focus-ring-dark"
56
- : "inline-flex h-9 items-center gap-1.5 rounded-md border border-line bg-surface px-3 text-[13px] font-medium text-ink transition-colors hover:bg-surface-sunken focus-ring"
57
- }
58
  >
59
- <Github size={dark ? 14 : 15} />
60
  Sign in
61
  </button>
62
  );
@@ -70,11 +62,7 @@ export function AuthButton({ appearance = "light" }: { appearance?: "light" | "d
70
  aria-expanded={open}
71
  aria-haspopup="menu"
72
  aria-label={`Signed in as ${user.login}`}
73
- className={
74
- dark
75
- ? "flex h-9 items-center gap-1.5 rounded-full border border-[#333] bg-[#030303] p-1 pr-2 transition hover:border-[#484a58] hover:bg-[#111216] focus-ring-dark"
76
- : "flex h-9 items-center gap-1 rounded-md border border-line bg-surface p-1 pr-1.5 transition-colors hover:bg-surface-sunken focus-ring"
77
- }
78
  >
79
  {user.avatar_url ? (
80
  <img
@@ -90,22 +78,16 @@ export function AuthButton({ appearance = "light" }: { appearance?: "light" | "d
90
  )}
91
  <ChevronDown
92
  size={13}
93
- className={`transition-transform ${dark ? "text-[#77787e]" : "text-ink-tertiary"} ${open ? "rotate-180" : ""}`}
94
  />
95
  </button>
96
 
97
  {open && (
98
  <div
99
  role="menu"
100
- className={
101
- dark
102
- ? "absolute right-0 top-full z-40 mt-2 w-44 overflow-hidden rounded-xl border border-[#333] bg-[#111216] shadow-[0_16px_50px_rgba(0,0,0,0.5)]"
103
- : "absolute right-0 top-full z-40 mt-1.5 w-44 overflow-hidden rounded-lg border border-line bg-surface shadow-lg"
104
- }
105
  >
106
- <div
107
- className={`border-b px-3.5 py-2.5 text-[13px] font-medium ${dark ? "border-[#26272c] text-[#f0f2f6]" : "border-line text-ink"}`}
108
- >
109
  {user.login}
110
  </div>
111
  <a
@@ -116,11 +98,7 @@ export function AuthButton({ appearance = "light" }: { appearance?: "light" | "d
116
  setOpen(false);
117
  nav("/my");
118
  }}
119
- className={`flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[13px] transition-colors ${
120
- dark
121
- ? "text-[#acacb0] hover:bg-[#1a1b20] hover:text-white"
122
- : "text-ink-secondary hover:bg-surface-sunken hover:text-ink"
123
- }`}
124
  >
125
  <FolderGit2 size={14} />
126
  My submissions
@@ -133,11 +111,7 @@ export function AuthButton({ appearance = "light" }: { appearance?: "light" | "d
133
  logoutM.mutate();
134
  }}
135
  disabled={logoutM.isPending}
136
- className={`flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[13px] transition-colors ${
137
- dark
138
- ? "text-[#acacb0] hover:bg-[#1a1b20] hover:text-white"
139
- : "text-ink-secondary hover:bg-surface-sunken hover:text-ink"
140
- }`}
141
  >
142
  <LogOut size={14} />
143
  {logoutM.isPending ? "Signing out…" : "Sign out"}
 
1
  import { useEffect, useRef, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
  import { Github, LogOut, ChevronDown, FolderGit2 } from "lucide-react";
4
+ import { navigateToLogin, navigateToLoginPopup, isEmbedded } from "../shared/api/client";
5
  import { useLogout, useMe } from "../features/auth/useAuth";
6
 
7
  /**
8
  * 顶栏登录态:未登录 = Sign in with GitHub;已登录 = 头像 + 下拉(Logout)。
9
+ * 体系统一样式;appearance prop 仅为调用方兼容保留
10
  */
11
  export function AuthButton({ appearance = "light" }: { appearance?: "light" | "dark" }) {
12
+ void appearance;
13
  const meQ = useMe();
14
  const logoutM = useLogout();
 
15
  const nav = useNavigate();
16
  const [open, setOpen] = useState(false);
17
  const rootRef = useRef<HTMLDivElement>(null);
 
34
 
35
  // 加载中/后端未接入 auth:按未登录渲染(不阻塞页面)
36
  const user = meQ.data?.authenticated ? meQ.data.user : null;
 
 
 
 
37
  if (!user) {
38
  return (
39
  <button
 
43
  navigateToLoginPopup();
44
  window.dispatchEvent(new Event("ov-oauth-popup-opened"));
45
  } else {
46
+ navigateToLogin();
47
  }
48
  }}
49
+ className="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-4 text-[13px] font-medium text-surface transition hover:bg-white focus-ring"
 
 
 
 
50
  >
51
+ <Github size={14} />
52
  Sign in
53
  </button>
54
  );
 
62
  aria-expanded={open}
63
  aria-haspopup="menu"
64
  aria-label={`Signed in as ${user.login}`}
65
+ className="flex h-9 items-center gap-1.5 rounded-full border border-line bg-surface-raised p-1 pr-2 transition-colors hover:border-line-strong hover:bg-surface-sunken focus-ring"
 
 
 
 
66
  >
67
  {user.avatar_url ? (
68
  <img
 
78
  )}
79
  <ChevronDown
80
  size={13}
81
+ className={`text-ink-tertiary transition-transform ${open ? "rotate-180" : ""}`}
82
  />
83
  </button>
84
 
85
  {open && (
86
  <div
87
  role="menu"
88
+ className="absolute right-0 top-full z-40 mt-2 w-44 overflow-hidden rounded-xl border border-line bg-surface-raised shadow-[0_16px_48px_-12px_rgba(0,0,0,0.7)]"
 
 
 
 
89
  >
90
+ <div className="border-b border-line px-3.5 py-2.5 text-[13px] font-medium text-ink">
 
 
91
  {user.login}
92
  </div>
93
  <a
 
98
  setOpen(false);
99
  nav("/my");
100
  }}
101
+ className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[13px] text-ink-secondary transition-colors hover:bg-surface-sunken hover:text-ink"
 
 
 
 
102
  >
103
  <FolderGit2 size={14} />
104
  My submissions
 
111
  logoutM.mutate();
112
  }}
113
  disabled={logoutM.isPending}
114
+ className="flex w-full items-center gap-2 px-3.5 py-2.5 text-left text-[13px] text-ink-secondary transition-colors hover:bg-surface-sunken hover:text-ink"
 
 
 
 
115
  >
116
  <LogOut size={14} />
117
  {logoutM.isPending ? "Signing out…" : "Sign out"}
src/components/Button.tsx CHANGED
@@ -4,11 +4,11 @@ type Variant = "primary" | "secondary" | "ghost" | "danger";
4
  type Size = "md" | "lg";
5
 
6
  const styles: Record<Variant, string> = {
7
- // 协作者深体系:主按钮 = 白底黑字(composer submit 同族
8
- primary: "bg-[#ebecf0] text-[#0d0d0f] hover:bg-white border border-transparent",
9
- secondary: "bg-surface-raised text-ink border border-[#333] hover:border-[#484a58] hover:bg-surface-sunken",
10
  ghost: "bg-transparent text-ink-secondary border border-transparent hover:bg-surface-sunken hover:text-ink",
11
- danger: "bg-danger text-white hover:bg-[#d63f3f] border border-transparent",
12
  };
13
 
14
  // 高度集中在 size prop,禁止 className 覆盖高度(Tailwind 同源类冲突靠 CSS 顺序,不可靠)
 
4
  type Size = "md" | "lg";
5
 
6
  const styles: Record<Variant, string> = {
7
+ // 控制台:主按钮 = 亮面(phosphor 白底 + 暗字
8
+ primary: "bg-ink text-surface hover:bg-white border border-transparent",
9
+ secondary: "bg-surface-raised text-ink border border-line hover:border-line-strong hover:bg-surface-sunken",
10
  ghost: "bg-transparent text-ink-secondary border border-transparent hover:bg-surface-sunken hover:text-ink",
11
+ danger: "bg-danger text-surface hover:bg-[#ff7d77] border border-transparent",
12
  };
13
 
14
  // 高度集中在 size prop,禁止 className 覆盖高度(Tailwind 同源类冲突靠 CSS 顺序,不可靠)
src/components/ConfirmDialog.tsx CHANGED
@@ -36,7 +36,7 @@ export function ConfirmDialog({
36
 
37
  return (
38
  <div
39
- className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
40
  role="dialog"
41
  aria-modal="true"
42
  aria-labelledby="confirm-title"
 
36
 
37
  return (
38
  <div
39
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
40
  role="dialog"
41
  aria-modal="true"
42
  aria-labelledby="confirm-title"
src/components/HeroStats.tsx DELETED
@@ -1,115 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { AlertTriangle, Bug, FolderGit2 } from "lucide-react";
3
- import type { OverviewStats } from "@/shared/types";
4
-
5
- function usePrefersReducedMotion(): boolean {
6
- const [reduced, setReduced] = useState(false);
7
- useEffect(() => {
8
- const media = window.matchMedia("(prefers-reduced-motion: reduce)");
9
- setReduced(media.matches);
10
- const update = () => setReduced(media.matches);
11
- media.addEventListener("change", update);
12
- return () => media.removeEventListener("change", update);
13
- }, []);
14
- return reduced;
15
- }
16
-
17
- function useCountUp(target: number, duration = 650): number {
18
- const reduced = usePrefersReducedMotion();
19
- const [value, setValue] = useState(reduced ? target : 0);
20
- useEffect(() => {
21
- if (reduced) {
22
- setValue(target);
23
- return;
24
- }
25
- let frame = 0;
26
- const start = performance.now();
27
- const tick = (time: number) => {
28
- const progress = Math.min(1, (time - start) / duration);
29
- setValue(Math.round(target * (1 - (1 - progress) ** 3)));
30
- if (progress < 1) frame = requestAnimationFrame(tick);
31
- };
32
- frame = requestAnimationFrame(tick);
33
- return () => cancelAnimationFrame(frame);
34
- }, [duration, reduced, target]);
35
- return value;
36
- }
37
-
38
- function AnimatedNumber({ value }: { value: number }) {
39
- return <>{useCountUp(value).toLocaleString()}</>;
40
- }
41
-
42
- export function HeroStats({
43
- stats,
44
- loading = false,
45
- failed = false,
46
- }: {
47
- stats: OverviewStats | undefined;
48
- loading?: boolean;
49
- failed?: boolean;
50
- }) {
51
- const unavailable = loading || failed || !stats;
52
- const scannedRepositories = stats
53
- ? stats.scanned_project_count ?? Math.min(stats.project_count, stats.scan_completed_count)
54
- : 0;
55
- const findingTotal = stats?.finding_total ?? 0;
56
- const highRiskTotal = stats?.severity_counts?.high ?? 0;
57
-
58
- const metrics = [
59
- {
60
- label: "Findings",
61
- detail: "Discovered issues",
62
- value: findingTotal,
63
- icon: Bug,
64
- color: "text-white",
65
- iconClass: "border-white/[0.08] bg-white/[0.05] text-[#b5b6bd]",
66
- },
67
- {
68
- label: "High risk",
69
- detail: "High severity findings",
70
- value: highRiskTotal,
71
- icon: AlertTriangle,
72
- color: "text-white",
73
- iconClass: "border-white/[0.08] bg-white/[0.05] text-[#b5b6bd]",
74
- },
75
- {
76
- label: "Repositories",
77
- detail: "Scanned projects",
78
- value: scannedRepositories,
79
- icon: FolderGit2,
80
- color: "text-white",
81
- iconClass: "border-white/[0.08] bg-white/[0.05] text-[#b5b6bd]",
82
- },
83
- ];
84
-
85
- return (
86
- <section aria-labelledby="overview-title" className="openvuln-panel min-w-0 p-5 sm:p-6">
87
- <div className="flex items-end justify-between gap-4">
88
- <div>
89
- <p className="font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-ink-tertiary">
90
- Security overview
91
- </p>
92
- <h2 id="overview-title" className="mt-2 font-display text-lg font-semibold tracking-tight text-white">
93
- Findings at a glance
94
- </h2>
95
- </div>
96
- <span className="mb-1 h-2 w-2 rounded-full bg-white/60" aria-hidden />
97
- </div>
98
-
99
- <div className="mt-5 grid grid-cols-3 divide-x divide-white/[0.07]">
100
- {metrics.map(({ label, detail, value, icon: Icon, color, iconClass }) => (
101
- <div key={label} className="min-w-0 px-3 first:pl-0 last:pr-0 sm:px-5">
102
- <div className={`flex h-8 w-8 items-center justify-center rounded-lg border ${iconClass}`} aria-hidden>
103
- <Icon size={15} />
104
- </div>
105
- <p className={`mt-4 font-display text-3xl font-semibold tabular-nums tracking-[-0.04em] sm:text-4xl ${color}`}>
106
- {unavailable ? "—" : <AnimatedNumber value={value} />}
107
- </p>
108
- <p className="mt-2 truncate text-[11px] font-medium text-[#d0d1d5] sm:text-xs">{label}</p>
109
- <p className="mt-0.5 hidden text-[10px] leading-4 text-ink-tertiary sm:block">{detail}</p>
110
- </div>
111
- ))}
112
- </div>
113
- </section>
114
- );
115
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/Layout.tsx CHANGED
@@ -13,28 +13,36 @@ export function Layout() {
13
  const isDeck = pathname === "/";
14
  return (
15
  <div className="flex min-h-screen flex-col">
16
- {!isDeck && <header className="app-shell-chrome sticky top-0 z-30 h-14 border-b border-line bg-surface-header/95 backdrop-blur">
17
  <div className="flex h-full items-center justify-between gap-4 px-5">
18
  <div className="flex items-center">
19
  <Link
20
  to="/"
21
- className="font-display text-[17px] font-bold tracking-tight focus-ring rounded-md text-ink"
22
  >
23
- OpenVuln
 
 
 
 
 
 
 
 
24
  </Link>
25
  </div>
26
 
27
  <div className="flex items-center gap-2">
28
- <NotificationBell appearance="light" />
29
- <AuthButton appearance="light" />
30
  <a
31
  href={OWN_REPO}
32
  target="_blank"
33
  rel="noreferrer"
34
- className="rounded-md p-2 text-ink-secondary hover:bg-surface-sunken hover:text-ink focus-ring"
35
  title="OpenVuln on GitHub"
36
  >
37
- <Github size={18} />
38
  </a>
39
  </div>
40
  </div>
@@ -46,7 +54,8 @@ export function Layout() {
46
 
47
  {!isDeck && (
48
  <footer className="app-shell-chrome mt-auto border-t border-line">
49
- <div className="flex justify-end px-5 py-6 text-[13px] text-ink-secondary">
 
50
  <div className="flex flex-wrap gap-x-5 gap-y-1">
51
  <a href={OWN_REPO} target="_blank" rel="noreferrer" className="hover:text-ink">
52
  GitHub
 
13
  const isDeck = pathname === "/";
14
  return (
15
  <div className="flex min-h-screen flex-col">
16
+ {!isDeck && <header className="app-shell-chrome sticky top-0 z-30 h-14 border-b border-line bg-surface/85 backdrop-blur">
17
  <div className="flex h-full items-center justify-between gap-4 px-5">
18
  <div className="flex items-center">
19
  <Link
20
  to="/"
21
+ className="flex items-center gap-2.5 rounded-md focus-ring"
22
  >
23
+ <img
24
+ src="/logo.svg"
25
+ alt=""
26
+ className="h-7 w-7 shrink-0 rounded-lg"
27
+ aria-hidden
28
+ />
29
+ <span className="font-display text-[15px] font-semibold tracking-tight text-ink">
30
+ OpenVuln
31
+ </span>
32
  </Link>
33
  </div>
34
 
35
  <div className="flex items-center gap-2">
36
+ <NotificationBell />
37
+ <AuthButton />
38
  <a
39
  href={OWN_REPO}
40
  target="_blank"
41
  rel="noreferrer"
42
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-line bg-surface-raised text-ink-secondary transition-colors hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring"
43
  title="OpenVuln on GitHub"
44
  >
45
+ <Github size={15} />
46
  </a>
47
  </div>
48
  </div>
 
54
 
55
  {!isDeck && (
56
  <footer className="app-shell-chrome mt-auto border-t border-line">
57
+ <div className="flex flex-col gap-2 px-5 py-6 text-[13px] text-ink-secondary sm:flex-row sm:items-center sm:justify-between">
58
+ <p className="text-ink-tertiary">© 2026 OpenVuln · Powered by VulnHunter</p>
59
  <div className="flex flex-wrap gap-x-5 gap-y-1">
60
  <a href={OWN_REPO} target="_blank" rel="noreferrer" className="hover:text-ink">
61
  GitHub
src/components/NotificationBell.tsx CHANGED
@@ -21,6 +21,7 @@ function summary(n: NotificationItem): string {
21
 
22
  /** 站内通知铃铛(task-78c9fb3a):未读红点 + 下拉列表,点击跳项目页并标已读。60s 轮询。 */
23
  export function NotificationBell({ appearance = "light" }: { appearance?: "light" | "dark" }) {
 
24
  const meQ = useMe();
25
  const authed = meQ.data?.authenticated === true;
26
  const nav = useNavigate();
@@ -59,7 +60,6 @@ export function NotificationBell({ appearance = "light" }: { appearance?: "light
59
 
60
  if (!authed) return null;
61
 
62
- const dark = appearance === "dark";
63
  const items = listQ.data?.notifications ?? [];
64
  const unread = listQ.data?.unread_count ?? 0;
65
 
@@ -78,15 +78,11 @@ export function NotificationBell({ appearance = "light" }: { appearance?: "light
78
  aria-expanded={open}
79
  aria-haspopup="menu"
80
  aria-label={unread > 0 ? `Notifications (${unread} unread)` : "Notifications"}
81
- className={
82
- dark
83
- ? "relative flex h-9 w-9 items-center justify-center rounded-full border border-[#333] bg-[#030303] text-[#acacb0] transition hover:border-[#484a58] hover:bg-[#111216] hover:text-white focus-ring-dark"
84
- : "relative flex h-9 w-9 items-center justify-center rounded-md border border-line bg-surface text-ink-secondary transition-colors hover:bg-surface-sunken hover:text-ink focus-ring"
85
- }
86
  >
87
- <Bell size={dark ? 14 : 16} />
88
  {unread > 0 && (
89
- <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-none text-white">
90
  {unread > 9 ? "9+" : unread}
91
  </span>
92
  )}
@@ -95,24 +91,16 @@ export function NotificationBell({ appearance = "light" }: { appearance?: "light
95
  {open && (
96
  <div
97
  role="menu"
98
- className={
99
- dark
100
- ? "absolute right-0 top-full z-40 mt-2 w-80 overflow-hidden rounded-xl border border-[#333] bg-[#111216] shadow-[0_16px_50px_rgba(0,0,0,0.5)]"
101
- : "absolute right-0 top-full z-40 mt-1.5 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-lg"
102
- }
103
  >
104
- <div
105
- className={`flex items-center justify-between border-b px-3.5 py-2.5 ${dark ? "border-[#26272c]" : "border-line"}`}
106
- >
107
- <span className={`text-[13px] font-medium ${dark ? "text-[#f0f2f6]" : "text-ink"}`}>
108
- Notifications
109
- </span>
110
  {unread > 0 && (
111
  <button
112
  type="button"
113
  onClick={() => readAllM.mutate()}
114
  disabled={readAllM.isPending}
115
- className={`inline-flex items-center gap-1 text-[12px] ${dark ? "text-[#acacb0] hover:text-white" : "text-ink-secondary hover:text-ink"}`}
116
  >
117
  <CheckCheck size={13} />
118
  Mark all read
@@ -122,11 +110,9 @@ export function NotificationBell({ appearance = "light" }: { appearance?: "light
122
 
123
  <ul className="max-h-80 overflow-y-auto">
124
  {listQ.isPending ? (
125
- <li className={`px-3.5 py-6 text-center text-[13px] ${dark ? "text-[#77787e]" : "text-ink-tertiary"}`}>
126
- Loading…
127
- </li>
128
  ) : items.length === 0 ? (
129
- <li className={`px-3.5 py-6 text-center text-[13px] ${dark ? "text-[#77787e]" : "text-ink-tertiary"}`}>
130
  No notifications yet
131
  </li>
132
  ) : (
@@ -138,22 +124,20 @@ export function NotificationBell({ appearance = "light" }: { appearance?: "light
138
  type="button"
139
  role="menuitem"
140
  onClick={() => openItem(n)}
141
- className={`flex w-full items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors ${
142
- dark ? "hover:bg-[#1a1b20]" : "hover:bg-surface-sunken"
143
- }`}
144
  >
145
  <span
146
  className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${isUnread ? "bg-accent-600" : "bg-transparent"}`}
147
  aria-hidden
148
  />
149
  <span className="min-w-0 flex-1">
150
- <span className={`block truncate text-[13px] font-medium ${dark ? "text-[#f0f2f6]" : "text-ink"}`}>
151
  {n.payload.full_name}
152
  </span>
153
- <span className={`mt-0.5 block text-[12px] leading-snug ${dark ? "text-[#acacb0]" : "text-ink-secondary"}`}>
154
  {summary(n)}
155
  </span>
156
- <span className={`mt-0.5 block font-mono text-[11px] ${dark ? "text-[#696a70]" : "text-ink-tertiary"}`}>
157
  {formatRelativeTime(n.created_at)}
158
  </span>
159
  </span>
 
21
 
22
  /** 站内通知铃铛(task-78c9fb3a):未读红点 + 下拉列表,点击跳项目页并标已读。60s 轮询。 */
23
  export function NotificationBell({ appearance = "light" }: { appearance?: "light" | "dark" }) {
24
+ void appearance;
25
  const meQ = useMe();
26
  const authed = meQ.data?.authenticated === true;
27
  const nav = useNavigate();
 
60
 
61
  if (!authed) return null;
62
 
 
63
  const items = listQ.data?.notifications ?? [];
64
  const unread = listQ.data?.unread_count ?? 0;
65
 
 
78
  aria-expanded={open}
79
  aria-haspopup="menu"
80
  aria-label={unread > 0 ? `Notifications (${unread} unread)` : "Notifications"}
81
+ className="relative flex h-9 w-9 items-center justify-center rounded-full border border-line bg-surface-raised text-ink-secondary transition-colors hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring"
 
 
 
 
82
  >
83
+ <Bell size={15} />
84
  {unread > 0 && (
85
+ <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-none text-surface">
86
  {unread > 9 ? "9+" : unread}
87
  </span>
88
  )}
 
91
  {open && (
92
  <div
93
  role="menu"
94
+ className="absolute right-0 top-full z-40 mt-2 w-80 overflow-hidden rounded-xl border border-line bg-surface-raised shadow-[0_16px_48px_-12px_rgba(0,0,0,0.7)]"
 
 
 
 
95
  >
96
+ <div className="flex items-center justify-between border-b border-line px-3.5 py-2.5">
97
+ <span className="text-[13px] font-medium text-ink">Notifications</span>
 
 
 
 
98
  {unread > 0 && (
99
  <button
100
  type="button"
101
  onClick={() => readAllM.mutate()}
102
  disabled={readAllM.isPending}
103
+ className="inline-flex items-center gap-1 text-[12px] text-ink-secondary hover:text-ink"
104
  >
105
  <CheckCheck size={13} />
106
  Mark all read
 
110
 
111
  <ul className="max-h-80 overflow-y-auto">
112
  {listQ.isPending ? (
113
+ <li className="px-3.5 py-6 text-center text-[13px] text-ink-tertiary">Loading…</li>
 
 
114
  ) : items.length === 0 ? (
115
+ <li className="px-3.5 py-6 text-center text-[13px] text-ink-tertiary">
116
  No notifications yet
117
  </li>
118
  ) : (
 
124
  type="button"
125
  role="menuitem"
126
  onClick={() => openItem(n)}
127
+ className="flex w-full items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors hover:bg-surface-sunken"
 
 
128
  >
129
  <span
130
  className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${isUnread ? "bg-accent-600" : "bg-transparent"}`}
131
  aria-hidden
132
  />
133
  <span className="min-w-0 flex-1">
134
+ <span className="block truncate text-[13px] font-medium text-ink">
135
  {n.payload.full_name}
136
  </span>
137
+ <span className="mt-0.5 block text-[12px] leading-snug text-ink-secondary">
138
  {summary(n)}
139
  </span>
140
+ <span className="mt-0.5 block font-mono text-[11px] text-ink-tertiary">
141
  {formatRelativeTime(n.created_at)}
142
  </span>
143
  </span>
src/components/ProjectRow.tsx CHANGED
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
3
  import type { ProjectCard } from "@/shared/types";
4
  import { formatRelativeTime, formatStars, totalFindings } from "../shared/lib/format";
5
  import { SeverityBar } from "./SeverityBar";
6
- import { StatusBadge } from "./StatusBadge";
7
 
8
  function OwnerAvatar({ owner, compact = false }: { owner: string; compact?: boolean }) {
9
  const [failed, setFailed] = useState(false);
@@ -11,7 +10,7 @@ function OwnerAvatar({ owner, compact = false }: { owner: string; compact?: bool
11
  if (failed) {
12
  return (
13
  <div
14
- className={`flex shrink-0 items-center justify-center rounded-full bg-white/[0.06] font-semibold text-ink-secondary ${sizeClass} ${compact ? "text-[10px]" : "text-xs"}`}
15
  aria-hidden
16
  >
17
  {owner.slice(0, 1).toUpperCase()}
@@ -46,7 +45,7 @@ export function ProjectRow({ project, compact = false }: { project: ProjectCard;
46
  onKeyDown={(e) => {
47
  if (e.key === "Enter") nav(`/p/${project.owner_login}/${project.name}`);
48
  }}
49
- className={`group block cursor-pointer overflow-hidden border-b border-line px-1 transition-colors hover:bg-white/[0.025] focus-ring rounded-sm ${
50
  compact ? "h-[104px] py-3" : "py-4"
51
  }`}
52
  >
@@ -89,7 +88,13 @@ export function ProjectRow({ project, compact = false }: { project: ProjectCard;
89
  </div>
90
  <div className={compact ? "mt-1.5" : "mt-2.5"}>
91
  {scanning ? (
92
- <span className={`${compact ? "text-[10px]" : "text-[13px]"} font-medium text-running-ink`}>
 
 
 
 
 
 
93
  {soFar > 0 ? `Scanning · ${soFar} findings so far` : "Scanning…"}
94
  </span>
95
  ) : waiting ? (
@@ -107,12 +112,6 @@ export function ProjectRow({ project, compact = false }: { project: ProjectCard;
107
  )}
108
  </div>
109
  </div>
110
- {/* completed 的扫描时间已在 meta 行,右侧 badge 只留给需要注意的状态(去重,fish v1.11) */}
111
- {state !== "completed" && (
112
- <div className={`shrink-0 pt-0.5 ${compact ? "scale-90 origin-top-right" : ""}`}>
113
- <StatusBadge state={state} finishedAt={project.latest_scan?.finished_at} />
114
- </div>
115
- )}
116
  </div>
117
  </div>
118
  </div>
 
3
  import type { ProjectCard } from "@/shared/types";
4
  import { formatRelativeTime, formatStars, totalFindings } from "../shared/lib/format";
5
  import { SeverityBar } from "./SeverityBar";
 
6
 
7
  function OwnerAvatar({ owner, compact = false }: { owner: string; compact?: boolean }) {
8
  const [failed, setFailed] = useState(false);
 
10
  if (failed) {
11
  return (
12
  <div
13
+ className={`flex shrink-0 items-center justify-center rounded-full bg-surface-sunken font-semibold text-ink-secondary ${sizeClass} ${compact ? "text-[10px]" : "text-xs"}`}
14
  aria-hidden
15
  >
16
  {owner.slice(0, 1).toUpperCase()}
 
45
  onKeyDown={(e) => {
46
  if (e.key === "Enter") nav(`/p/${project.owner_login}/${project.name}`);
47
  }}
48
+ className={`group block cursor-pointer overflow-hidden border-b border-line px-1 transition-colors hover:bg-surface-sunken/60 focus-ring rounded-sm ${
49
  compact ? "h-[104px] py-3" : "py-4"
50
  }`}
51
  >
 
88
  </div>
89
  <div className={compact ? "mt-1.5" : "mt-2.5"}>
90
  {scanning ? (
91
+ <span
92
+ className={`inline-flex items-center gap-1.5 font-medium text-running-ink ${compact ? "text-[10px]" : "text-[13px]"}`}
93
+ >
94
+ <span className="relative flex h-1.5 w-1.5">
95
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-running opacity-60 motion-reduce:animate-none" />
96
+ <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-running" />
97
+ </span>
98
  {soFar > 0 ? `Scanning · ${soFar} findings so far` : "Scanning…"}
99
  </span>
100
  ) : waiting ? (
 
112
  )}
113
  </div>
114
  </div>
 
 
 
 
 
 
115
  </div>
116
  </div>
117
  </div>
src/components/RepoSubmitForm.tsx CHANGED
@@ -49,10 +49,12 @@ export function RepoSubmitForm({
49
  size = "default",
50
  appearance = "default",
51
  className = "",
 
52
  }: {
53
  size?: "default" | "hero";
54
  appearance?: "default" | "dark";
55
  className?: string;
 
56
  }) {
57
  const nav = useNavigate();
58
  const meQ = useMe();
@@ -93,6 +95,8 @@ export function RepoSubmitForm({
93
  };
94
 
95
  if (dark) {
 
 
96
  return (
97
  <form
98
  onSubmit={(e) => void onSubmit(e)}
@@ -101,13 +105,13 @@ export function RepoSubmitForm({
101
  aria-busy={pending}
102
  >
103
  <div
104
- className={`openvuln-composer group flex min-h-[62px] items-center gap-3 rounded-[18px] border bg-[#141519] p-2 pl-5 shadow-[0_18px_60px_rgba(0,0,0,0.32)] transition focus-within:bg-[#17181d] focus-within:shadow-[0_22px_70px_rgba(68,70,88,0.16)] ${
105
- error ? "border-[#a94d55]" : "border-[#35363c] focus-within:border-[#5b5e6a]"
106
  }`}
107
  >
108
  <Github
109
- size={20}
110
- className="shrink-0 text-[#6d6f78] transition group-focus-within:text-[#acacb0]"
111
  />
112
  <input
113
  type="url"
@@ -118,25 +122,25 @@ export function RepoSubmitForm({
118
  }}
119
  placeholder="Paste a public GitHub repository URL"
120
  spellCheck={false}
121
- className="min-w-0 flex-1 bg-transparent py-3 text-[14px] text-[#f0f2f6] outline-none placeholder:text-[#696a70] sm:text-[15px]"
122
  aria-label="GitHub repository URL"
123
  aria-invalid={!!error}
124
  />
125
  <button
126
  type="submit"
127
  aria-label={pending ? "Submitting repository" : "Analyze repository"}
128
- className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[14px] border border-[#0d0d0f] bg-[#ebecf0] text-[#0d0d0f] shadow-sm transition hover:scale-[1.03] hover:bg-white active:scale-95 focus-ring-dark disabled:cursor-not-allowed disabled:bg-[#484a58] disabled:text-[#989aa5] disabled:opacity-100"
129
  disabled={!url.trim() || pending}
130
  >
131
  {pending ? (
132
- <LoaderCircle size={20} className="animate-spin motion-reduce:animate-none" />
133
  ) : (
134
- <ArrowUp size={21} strokeWidth={2.2} />
135
  )}
136
  </button>
137
  </div>
138
 
139
- <div className="mt-2.5 flex justify-center">
140
  {showRef ? (
141
  <input
142
  type="text"
@@ -144,28 +148,28 @@ export function RepoSubmitForm({
144
  onChange={(e) => setRef(e.target.value)}
145
  placeholder="Branch, tag, or commit SHA (optional)"
146
  spellCheck={false}
147
- className="h-8 w-72 rounded-full border border-[#333] bg-[#030303] px-3.5 font-mono text-xs text-[#f0f2f6] outline-none placeholder:text-[#696a70] focus:border-[#484a58]"
148
  aria-label="Version to scan (branch, tag, or commit SHA)"
149
  />
150
  ) : (
151
  <button
152
  type="button"
153
  onClick={() => setShowRef(true)}
154
- className="text-[11px] text-[#696a70] underline decoration-[#333] underline-offset-2 transition hover:text-[#acacb0]"
155
  >
156
  Scan a specific version
157
  </button>
158
  )}
159
  </div>
160
 
161
- <div className="mt-3 flex min-h-5 items-start justify-center gap-1.5 text-center text-xs text-[#77787e]">
162
  {error ? (
163
- <span className="inline-flex items-start gap-1.5 text-[#ff9ca5]" role="alert">
164
  <CircleAlert size={14} className="mt-px shrink-0" />
165
  <span>{error}</span>
166
  </span>
167
  ) : pending ? (
168
- <span className="text-[#b9bfd2]" role="status">Submitting repository…</span>
169
  ) : (
170
  <span className="inline-flex items-center gap-1.5">
171
  <CircleAlert size={13} strokeWidth={1.8} className="shrink-0" aria-hidden />
 
49
  size = "default",
50
  appearance = "default",
51
  className = "",
52
+ align = "center",
53
  }: {
54
  size?: "default" | "hero";
55
  appearance?: "default" | "dark";
56
  className?: string;
57
+ align?: "center" | "left";
58
  }) {
59
  const nav = useNavigate();
60
  const meQ = useMe();
 
95
  };
96
 
97
  if (dark) {
98
+ // hero composer(暗色控制台);appearance prop 名保留为调用方兼容
99
+ const centered = align === "center";
100
  return (
101
  <form
102
  onSubmit={(e) => void onSubmit(e)}
 
105
  aria-busy={pending}
106
  >
107
  <div
108
+ className={`openvuln-composer group flex min-h-[58px] items-center gap-3 rounded-xl border bg-surface-raised p-2 pl-4 shadow-[0_20px_48px_-20px_rgba(0,0,0,0.65)] transition focus-within:bg-surface-sunken/60 ${
109
+ error ? "border-danger/60" : "border-line focus-within:border-line-strong"
110
  }`}
111
  >
112
  <Github
113
+ size={18}
114
+ className="shrink-0 text-ink-tertiary transition group-focus-within:text-ink-secondary"
115
  />
116
  <input
117
  type="url"
 
122
  }}
123
  placeholder="Paste a public GitHub repository URL"
124
  spellCheck={false}
125
+ className="min-w-0 flex-1 bg-transparent py-3 font-mono text-[13px] text-ink outline-none placeholder:text-ink-tertiary sm:text-[14px]"
126
  aria-label="GitHub repository URL"
127
  aria-invalid={!!error}
128
  />
129
  <button
130
  type="submit"
131
  aria-label={pending ? "Submitting repository" : "Analyze repository"}
132
+ className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ink text-surface shadow-sm transition hover:bg-white active:scale-95 focus-ring disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-tertiary disabled:opacity-100"
133
  disabled={!url.trim() || pending}
134
  >
135
  {pending ? (
136
+ <LoaderCircle size={19} className="animate-spin motion-reduce:animate-none" />
137
  ) : (
138
+ <ArrowUp size={19} strokeWidth={2.2} />
139
  )}
140
  </button>
141
  </div>
142
 
143
+ <div className={`mt-2.5 flex ${centered ? "justify-center" : "justify-start"}`}>
144
  {showRef ? (
145
  <input
146
  type="text"
 
148
  onChange={(e) => setRef(e.target.value)}
149
  placeholder="Branch, tag, or commit SHA (optional)"
150
  spellCheck={false}
151
+ className="h-8 w-72 rounded-lg border border-line bg-surface-raised px-3 font-mono text-xs text-ink outline-none placeholder:text-ink-tertiary focus:border-line-strong"
152
  aria-label="Version to scan (branch, tag, or commit SHA)"
153
  />
154
  ) : (
155
  <button
156
  type="button"
157
  onClick={() => setShowRef(true)}
158
+ className="font-mono text-[11px] text-ink-tertiary underline decoration-line underline-offset-2 transition hover:text-ink-secondary"
159
  >
160
  Scan a specific version
161
  </button>
162
  )}
163
  </div>
164
 
165
+ <div className={`mt-2.5 flex min-h-5 items-start gap-1.5 text-xs text-ink-tertiary ${centered ? "justify-center text-center" : "justify-start text-left"}`}>
166
  {error ? (
167
+ <span className="inline-flex items-start gap-1.5 text-danger" role="alert">
168
  <CircleAlert size={14} className="mt-px shrink-0" />
169
  <span>{error}</span>
170
  </span>
171
  ) : pending ? (
172
+ <span className="text-ink-secondary" role="status">Submitting repository…</span>
173
  ) : (
174
  <span className="inline-flex items-center gap-1.5">
175
  <CircleAlert size={13} strokeWidth={1.8} className="shrink-0" aria-hidden />
src/components/ScanDurationNotice.tsx CHANGED
@@ -4,7 +4,7 @@ import { Clock3 } from "lucide-react";
4
  export function ScanDurationNotice({ className = "" }: { className?: string }) {
5
  return (
6
  <p
7
- className={`flex items-center justify-center gap-1.5 text-xs text-[#77787e] ${className}`}
8
  role="note"
9
  >
10
  <Clock3 size={13} strokeWidth={1.8} className="shrink-0" />
 
4
  export function ScanDurationNotice({ className = "" }: { className?: string }) {
5
  return (
6
  <p
7
+ className={`flex items-center justify-center gap-1.5 text-xs text-ink-tertiary ${className}`}
8
  role="note"
9
  >
10
  <Clock3 size={13} strokeWidth={1.8} className="shrink-0" />
src/components/SeverityBar.tsx CHANGED
@@ -1,17 +1,17 @@
1
  import type { Severity, SeverityCounts } from "@/shared/types";
2
  import { SEV_ORDER, severityLabel, totalFindings } from "../shared/lib/format";
3
 
4
- /** NVD four tiers — Critical uses R4 #C22828. */
5
  const BAR: Record<Severity, string> = {
6
- critical: "#C22828",
7
- high: "#F24F4F",
8
- medium: "#FF733C",
9
- low: "#F7C530",
10
  };
11
 
12
  const DOT: Record<Severity, string> = {
13
- critical: "bg-[#C22828]",
14
- high: "bg-[#F24F4F]",
15
  medium: "bg-sev-medium-bar",
16
  low: "bg-sev-low-bar",
17
  };
 
1
  import type { Severity, SeverityCounts } from "@/shared/types";
2
  import { SEV_ORDER, severityLabel, totalFindings } from "../shared/lib/format";
3
 
4
+ /** NVD four tiers — dark-console spectrum, red → orange → amber. */
5
  const BAR: Record<Severity, string> = {
6
+ critical: "#E5484D",
7
+ high: "#F2604F",
8
+ medium: "#FF8A3C",
9
+ low: "#F0C43A",
10
  };
11
 
12
  const DOT: Record<Severity, string> = {
13
+ critical: "bg-sev-critical-bar",
14
+ high: "bg-sev-high-bar",
15
  medium: "bg-sev-medium-bar",
16
  low: "bg-sev-low-bar",
17
  };
src/features/home/ProductHomePage.tsx CHANGED
@@ -1,37 +1,31 @@
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link } from "react-router-dom";
3
  import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
4
- import {
5
- Activity,
6
- ArrowRight,
7
- CheckCircle2,
8
- ChevronDown,
9
- ChevronUp,
10
- Clock3,
11
- Github,
12
- Radar,
13
- Search,
14
- Shield,
15
- } from "lucide-react";
16
- import type { LiveScanItem } from "@/shared/types";
17
  import { api } from "../../shared/api/client";
18
  import { Button } from "../../components/Button";
19
  import { EmptyState } from "../../components/EmptyState";
20
- import { ProjectRow } from "../../components/ProjectRow";
21
  import { RepoSubmitForm } from "../../components/RepoSubmitForm";
22
- import { HeroStats } from "../../components/HeroStats";
23
- import { ScanDurationNotice } from "../../components/ScanDurationNotice";
24
  import { AuthButton } from "../../components/AuthButton";
25
  import { NotificationBell } from "../../components/NotificationBell";
 
26
 
27
  const OWN_REPO =
28
  (import.meta.env.VITE_GITHUB_REPO_URL as string | undefined) ||
29
  "https://github.com/Clouditera/OpenVuln";
30
- const ZAI_HF_AVATAR = "https://huggingface.co/api/avatars/zai-org";
31
 
32
  /** List state survives project-detail navigation. */
33
  const listCache = { sort: "stars" as "newest" | "stars", q: "" };
34
 
 
 
 
 
 
 
 
35
  function formatElapsed(seconds: number): string {
36
  if (seconds < 60) return `${seconds}s`;
37
  const minutes = Math.floor(seconds / 60);
@@ -39,163 +33,365 @@ function formatElapsed(seconds: number): string {
39
  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
40
  }
41
 
42
- function BrandMark({ compact = false }: { compact?: boolean }) {
43
  return (
44
- <div
45
- className={`relative flex shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-white font-display font-bold text-black shadow-[0_8px_24px_rgba(0,0,0,0.24)] ${
46
- compact ? "h-8 w-8" : "h-9 w-9"
47
- }`}
48
  aria-hidden
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  >
50
- <span className={compact ? "text-sm" : "text-base"}>Z</span>
51
- <img
52
- src={ZAI_HF_AVATAR}
53
- alt=""
54
- className="absolute inset-0 h-full w-full object-cover"
55
- onError={(event) => {
56
- event.currentTarget.style.display = "none";
57
- }}
58
- />
59
  </div>
60
  );
61
  }
62
 
63
- function ScanRow({ scan }: { scan: LiveScanItem }) {
64
- const dispatching = scan.state === "dispatching";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  return (
67
- <Link
68
- to={`/p/${scan.full_name}`}
69
- className="group flex min-h-[72px] items-center gap-3 rounded-2xl border border-white/[0.07] bg-black/20 p-3.5 transition hover:border-white/[0.14] hover:bg-white/[0.045] focus-ring-dark"
70
- >
71
- <div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/[0.1] bg-white/[0.05] text-[#b8bac2]">
72
- <Radar size={18} className="motion-safe:animate-pulse" />
73
- <span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-[#15161a] bg-success" />
74
- </div>
75
- <div className="min-w-0 flex-1">
76
- <p className="truncate text-sm font-semibold text-white">
77
- {scan.full_name}
78
- </p>
79
- <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[10px] text-ink-tertiary">
80
- <span className="inline-flex items-center gap-1.5 text-[#b8bac2]">
81
- <Activity size={10} />
82
- {dispatching ? "Preparing scan" : "Scanning now"}
83
- </span>
84
- <span className="inline-flex items-center gap-1.5">
85
- <Clock3 size={10} />
86
- {formatElapsed(scan.elapsed_sec)}
87
  </span>
88
- </div>
 
 
 
 
 
 
89
  </div>
90
- <div className="shrink-0 text-right">
91
- <p className="font-display text-lg font-semibold tabular-nums text-white">
92
- {scan.findings_so_far}
 
 
 
 
 
 
 
93
  </p>
94
- <p className="font-mono text-[8px] uppercase tracking-[0.12em] text-ink-tertiary">
95
- found
 
96
  </p>
97
- </div>
98
- <ArrowRight
99
- size={14}
100
- className="shrink-0 text-ink-tertiary transition group-hover:translate-x-0.5 group-hover:text-white"
101
- />
102
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  );
104
  }
105
 
106
- function LiveScanPanel({
107
- scans,
108
- queued,
109
  loading,
110
  failed,
111
  }: {
112
- scans: LiveScanItem[];
113
- queued: number;
114
  loading: boolean;
115
  failed: boolean;
116
  }) {
117
- const active = scans.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  return (
120
- <section
121
- id="live-scans"
122
- aria-labelledby="live-scan-title"
123
- className="openvuln-panel relative min-w-0 overflow-hidden p-5 sm:p-6"
124
  >
125
- <div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-white/35 to-transparent" />
126
- <div className="flex items-start justify-between gap-4">
127
- <div>
128
- <div className="flex items-center gap-2 font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-[#a8aab1]">
129
- <span className="relative flex h-2 w-2">
130
- {active > 0 && (
131
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-50 motion-reduce:hidden" />
132
- )}
133
- <span
134
- className={`relative inline-flex h-2 w-2 rounded-full ${active > 0 ? "bg-success" : "bg-ink-tertiary"}`}
135
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  </span>
137
- Live scanner
138
- </div>
139
- <h2 id="live-scan-title" className="mt-2 font-display text-lg font-semibold tracking-tight text-white">
140
- Scanning now
141
- </h2>
142
- </div>
143
- {!loading && !failed && (
144
- <div className="rounded-full border border-white/[0.08] bg-black/25 px-3 py-1.5 font-mono text-[9px] text-ink-secondary">
145
- {active} active · {queued} queued
146
- </div>
147
  )}
148
- </div>
149
 
150
- <div className="mt-4 min-h-[130px] max-h-[228px] overflow-y-auto pr-0.5">
151
- {loading ? (
152
- <div className="space-y-2.5" aria-label="Loading live scans">
153
- {[0, 1].map((item) => (
154
- <div key={item} className="h-[72px] animate-pulse rounded-2xl bg-white/[0.04]" />
155
- ))}
156
- </div>
157
- ) : failed ? (
158
- <div className="flex min-h-[130px] items-center justify-center rounded-2xl border border-dashed border-white/[0.08] bg-black/15 px-5 text-center">
159
- <div>
160
- <Activity size={20} className="mx-auto text-ink-tertiary" />
161
- <p className="mt-2 text-sm font-medium text-ink-secondary">Live data is unavailable</p>
162
- <p className="mt-1 text-xs text-ink-tertiary">Scanner status will reconnect automatically.</p>
163
- </div>
164
- </div>
165
- ) : scans.length > 0 ? (
166
- <div className="space-y-2.5">
167
- {scans.map((scan) => (
168
- <ScanRow key={scan.project_id} scan={scan} />
169
- ))}
170
- </div>
171
  ) : (
172
- <div className="flex min-h-[130px] items-center justify-center rounded-2xl border border-dashed border-white/[0.08] bg-black/15 px-5 text-center">
173
- <div>
174
- <CheckCircle2 size={21} className="mx-auto text-success-ink" />
175
- <p className="mt-2 text-sm font-medium text-white">Scanner is ready</p>
176
- <p className="mt-1 text-xs leading-5 text-ink-tertiary">
177
- No repository is being scanned right now{queued > 0 ? ` · ${queued} waiting` : ""}.
178
- </p>
179
- </div>
180
- </div>
181
  )}
182
- </div>
183
 
184
- <div className="mt-4 h-0.5 overflow-hidden rounded-full bg-white/[0.045]" aria-hidden>
185
- <div
186
- className={`h-full rounded-full bg-white/60 ${
187
- active > 0 ? "live-scan-sweep w-1/3" : "w-0"
188
- }`}
189
- />
190
- </div>
191
- </section>
192
  );
193
  }
194
 
195
  export function ProductHomePage() {
196
  const [q, setQState] = useState(listCache.q);
197
  const [sort, setSortState] = useState<"newest" | "stars">(listCache.sort);
198
- const [repositoriesOpen, setRepositoriesOpen] = useState(false);
199
  const setQ = (value: string) => {
200
  listCache.q = value;
201
  setQState(value);
@@ -221,23 +417,21 @@ export function ProductHomePage() {
221
  });
222
  const allItems = useMemo(() => projects.data?.pages.flatMap((page) => page.items) ?? [], [projects.data]);
223
  const sentinelRef = useRef<HTMLDivElement>(null);
224
- const repositoryScrollRef = useRef<HTMLDivElement>(null);
225
 
226
  useEffect(() => {
227
  const sentinel = sentinelRef.current;
228
- const scrollContainer = repositoryScrollRef.current;
229
- if (!repositoriesOpen || !sentinel || !scrollContainer) return;
230
  const observer = new IntersectionObserver(
231
  (entries) => {
232
  if (entries.some((entry) => entry.isIntersecting) && projects.hasNextPage && !projects.isFetchingNextPage) {
233
  void projects.fetchNextPage();
234
  }
235
  },
236
- { root: scrollContainer, rootMargin: "240px" },
237
  );
238
  observer.observe(sentinel);
239
  return () => observer.disconnect();
240
- }, [repositoriesOpen, projects.hasNextPage, projects.isFetchingNextPage, projects.fetchNextPage]);
241
 
242
  const filtered = useMemo(() => {
243
  const needle = q.trim().toLowerCase();
@@ -250,210 +444,181 @@ export function ProductHomePage() {
250
  }, [allItems, q]);
251
 
252
  const scans = overview.data?.live?.scanning ?? [];
253
- const queued = overview.data?.live?.queued_count ?? 0;
254
 
255
  return (
256
- <div id="top" className="openvuln-home relative isolate min-h-screen overflow-hidden bg-[#08090b] text-white">
257
- <div className="openvuln-glow pointer-events-none fixed inset-0 -z-10" />
258
-
259
- <div className="min-h-screen">
260
- <header className="sticky top-0 z-30 border-b border-white/[0.06] bg-[#08090b]/80 backdrop-blur-xl">
261
- <div className="mx-auto flex h-[68px] w-full max-w-[1280px] items-center justify-between px-4 sm:px-7 lg:px-9 xl:px-12">
262
- <div className="flex items-center gap-3">
263
- <Link to="/" className="flex items-center gap-2.5 rounded-lg focus-ring-dark">
264
- <BrandMark compact />
265
- <span className="font-display text-sm font-semibold text-white">OpenVuln</span>
266
- </Link>
267
- </div>
268
- <div className="flex items-center gap-2">
269
- <NotificationBell appearance="dark" />
270
- <AuthButton appearance="dark" />
271
- <a
272
- href={OWN_REPO}
273
- target="_blank"
274
- rel="noreferrer"
275
- aria-label="OpenVuln on GitHub"
276
- className="hidden h-9 items-center gap-2 rounded-full border border-[#2a2b30] bg-[#101114] px-3.5 text-xs font-medium text-[#9c9da4] transition hover:border-[#41434b] hover:bg-[#17181d] hover:text-white focus-ring-dark sm:inline-flex"
277
- >
278
- <Github size={14} />
279
- GitHub
280
- </a>
281
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  </div>
283
- </header>
284
-
285
- <main className="mx-auto w-full max-w-[1280px] px-4 pb-16 pt-8 sm:px-7 sm:pt-11 lg:px-9 lg:pb-20 lg:pt-14 xl:px-12">
286
- <section id="submit" className="mx-auto flex max-w-[920px] scroll-mt-24 flex-col items-center text-center">
287
- <div className="openvuln-brand flex flex-col items-center">
288
- <div className="mb-4 flex items-center gap-2 font-mono text-[9px] font-semibold uppercase tracking-[0.2em] text-[#96989f]">
289
- <span className="h-1.5 w-1.5 rounded-full bg-white/60" />
290
- Open-source security scanner
291
- </div>
292
- <div className="flex items-center justify-center gap-4 sm:gap-5">
293
- <div className="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/20 bg-white font-display text-2xl font-bold text-black shadow-[0_14px_44px_rgba(0,0,0,0.4)] sm:h-16 sm:w-16">
294
- <span aria-hidden>Z</span>
295
- <img
296
- src={ZAI_HF_AVATAR}
297
- alt="Z.ai"
298
- className="absolute inset-0 h-full w-full object-cover"
299
- onError={(event) => {
300
- event.currentTarget.style.display = "none";
301
- }}
302
- />
303
- </div>
304
- <h1 className="openvuln-title font-display text-[40px] font-[450] leading-none tracking-normal sm:text-[56px]">
305
- OpenVuln
306
- </h1>
307
- </div>
308
- <p className="mt-4 max-w-2xl text-sm leading-6 text-ink-secondary sm:text-[15px]">
309
- GLM-powered vulnerability discovery for the open-source world.
310
- </p>
311
- </div>
312
 
313
- <RepoSubmitForm size="hero" appearance="dark" className="mt-7 w-full max-w-[820px]" />
314
- <div className="mt-1 w-full max-w-[820px] text-center">
315
- <p className="text-xs leading-5 text-[#777981]" role="note">
316
- Submitted repositories may require manual review before they are approved for scanning.
 
 
 
 
 
 
317
  </p>
318
- <ScanDurationNotice className="mt-1.5 w-full" />
319
  </div>
320
- </section>
321
-
322
- <div className="mt-9 grid items-stretch gap-4 xl:grid-cols-[0.88fr_1.12fr]">
323
- <HeroStats stats={overview.data} loading={overview.isLoading} failed={overview.isError} />
324
- <LiveScanPanel
325
- scans={scans}
326
- queued={queued}
327
- loading={overview.isLoading}
328
- failed={overview.isError}
329
- />
330
  </div>
 
331
 
332
- <section
333
- id="repositories"
334
- aria-labelledby="projects-title"
335
- className="mt-8 scroll-mt-24 rounded-[26px] border border-white/[0.08] bg-[#101115]/80 p-4 shadow-[0_24px_80px_rgba(0,0,0,0.22)] sm:p-6"
336
- >
337
- <div className="flex items-start justify-between gap-4">
338
- <div>
339
- <p className="font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-[#96989f]">
340
- Repository index
341
- </p>
342
- <h2 id="projects-title" className="mt-2 font-display text-xl font-semibold tracking-tight text-white sm:text-2xl">
343
- Scanned repositories
344
- </h2>
345
- <p className="mt-1.5 text-xs text-ink-tertiary">Browse public scan status and disclosed results.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  </div>
347
- <button
348
- type="button"
349
- onClick={() => setRepositoriesOpen((open) => !open)}
350
- aria-expanded={repositoriesOpen}
351
- aria-controls="repository-list-content"
352
- className="inline-flex h-9 shrink-0 items-center gap-2 rounded-xl border border-white/[0.09] bg-black/20 px-3 text-[11px] font-medium text-[#b9bac0] transition hover:border-white/[0.16] hover:bg-white/[0.05] hover:text-white focus-ring-dark"
353
  >
354
- {repositoriesOpen ? (
355
- <>
356
- Collapse
357
- <ChevronUp size={14} />
358
- </>
359
- ) : (
360
- <>
361
- Expand
362
- <ChevronDown size={14} />
363
- </>
364
- )}
365
- </button>
 
 
 
 
 
 
 
 
 
366
  </div>
 
367
 
368
- {repositoriesOpen && (
369
- <div id="repository-list-content">
370
- <div className="mt-5 flex flex-col gap-2.5 sm:flex-row sm:items-end sm:justify-end">
371
- <div className="relative">
372
- <Search size={14} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-tertiary" />
373
- <input
374
- type="search"
375
- value={q}
376
- onChange={(event) => setQ(event.target.value)}
377
- placeholder="Search repositories"
378
- aria-label="Search repositories"
379
- className="h-10 w-full rounded-xl border border-white/[0.09] bg-black/20 pl-10 pr-3 text-sm text-white placeholder:text-ink-tertiary focus-ring-dark sm:w-72"
380
- />
381
- </div>
382
- <div
383
- className="inline-flex self-start rounded-lg border border-white/[0.08] bg-black/20 p-1 sm:self-auto"
384
- aria-label="Sort repositories"
385
- >
386
- <button
387
- type="button"
388
- aria-pressed={sort === "stars"}
389
- onClick={() => setSort("stars")}
390
- className={`rounded-md px-3 py-1.5 text-[11px] font-medium transition focus-ring-dark ${
391
- sort === "stars" ? "bg-white text-black" : "text-ink-tertiary hover:text-white"
392
- }`}
393
- >
394
- Most starred
395
- </button>
396
- <button
397
- type="button"
398
- aria-pressed={sort === "newest"}
399
- onClick={() => setSort("newest")}
400
- className={`rounded-md px-3 py-1.5 text-[11px] font-medium transition focus-ring-dark ${
401
- sort === "newest" ? "bg-white text-black" : "text-ink-tertiary hover:text-white"
402
- }`}
403
- >
404
- Recently added
405
- </button>
406
- </div>
407
- </div>
408
-
409
- <div
410
- ref={repositoryScrollRef}
411
- className="openvuln-scrollbar mt-5 max-h-[520px] overflow-y-auto overscroll-contain border-y border-white/[0.07] pr-2"
412
- tabIndex={0}
413
- aria-label="Scanned repository list"
414
- >
415
- {projects.isLoading ? (
416
- <div className="space-y-3 py-6">
417
- {Array.from({ length: 5 }).map((_, index) => (
418
- <div key={index} className="h-24 animate-pulse rounded-xl bg-white/[0.035]" />
419
- ))}
420
- </div>
421
- ) : projects.isError ? (
422
- <EmptyState icon={Shield} title="Could not load repositories" description="The OpenVuln API may be unavailable." />
423
- ) : filtered.length === 0 ? (
424
- <EmptyState
425
- icon={Shield}
426
- title={q ? "No matching repositories" : "No repositories yet"}
427
- description={
428
- q
429
- ? "Try a different repository name."
430
- : "Submit the first open-source repository to be scanned by VulnHunter."
431
- }
432
- action={!q ? <Link to="/submit"><Button>Submit a repository</Button></Link> : undefined}
433
- />
434
- ) : (
435
- <>
436
- {filtered.map((project) => (
437
- <ProjectRow key={project.id} project={project} compact />
438
- ))}
439
- <div ref={sentinelRef} className="h-px" />
440
- {projects.isFetchingNextPage && (
441
- <div className="py-5 text-center font-mono text-xs text-ink-tertiary">Loading more…</div>
442
- )}
443
- </>
444
- )}
445
- </div>
446
-
447
- <div className="mt-4 flex items-center justify-between gap-3 font-mono text-[10px] text-ink-tertiary">
448
- <span>{(projects.data?.pages[0]?.total ?? 0).toLocaleString()} repositories indexed</span>
449
- <a href="#top" className="hover:text-white">Back to top ↑</a>
450
- </div>
451
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  )}
453
- </section>
 
 
 
 
 
 
 
454
 
455
- </main>
456
- </div>
457
  </div>
458
  );
459
  }
 
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
  import { Link } from "react-router-dom";
3
  import { useInfiniteQuery, 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);
 
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 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);
 
417
  });
418
  const allItems = useMemo(() => projects.data?.pages.flatMap((page) => page.items) ?? [], [projects.data]);
419
  const sentinelRef = useRef<HTMLDivElement>(null);
 
420
 
421
  useEffect(() => {
422
  const sentinel = sentinelRef.current;
423
+ if (!sentinel) return;
 
424
  const observer = new IntersectionObserver(
425
  (entries) => {
426
  if (entries.some((entry) => entry.isIntersecting) && projects.hasNextPage && !projects.isFetchingNextPage) {
427
  void projects.fetchNextPage();
428
  }
429
  },
430
+ { rootMargin: "240px" },
431
  );
432
  observer.observe(sentinel);
433
  return () => observer.disconnect();
434
+ }, [projects.hasNextPage, projects.isFetchingNextPage, projects.fetchNextPage]);
435
 
436
  const filtered = useMemo(() => {
437
  const needle = q.trim().toLowerCase();
 
444
  }, [allItems, q]);
445
 
446
  const scans = overview.data?.live?.scanning ?? [];
 
447
 
448
  return (
449
+ <div id="top" className="relative isolate min-h-screen overflow-hidden bg-surface text-ink">
450
+ <div className="openvuln-hero-video" aria-hidden>
451
+ <video src="/hero-bg.mp4" poster="/hero-bg-poster.jpg" autoPlay muted loop playsInline preload="auto" />
452
+ </div>
453
+
454
+ <header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur-xl">
455
+ <div className="mx-auto flex h-16 w-full max-w-[1200px] items-center justify-between px-5 sm:px-8">
456
+ <Link to="/" className="flex items-center gap-2.5 rounded-lg focus-ring">
457
+ <BrandMark />
458
+ <span className="font-display text-[15px] font-semibold tracking-tight text-ink">OpenVuln</span>
459
+ </Link>
460
+ <div className="flex items-center gap-2">
461
+ <NotificationBell />
462
+ <AuthButton />
463
+ <a
464
+ href={OWN_REPO}
465
+ target="_blank"
466
+ rel="noreferrer"
467
+ aria-label="OpenVuln on GitHub"
468
+ 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"
469
+ >
470
+ <Github size={14} />
471
+ GitHub
472
+ </a>
473
+ </div>
474
+ </div>
475
+ </header>
476
+
477
+ <main className="mx-auto w-full max-w-[1200px] px-5 pb-16 sm:px-8">
478
+ {/* ── Hero:大字报 —— 整幅展示字 + 出版物边注 ── */}
479
+ <section id="submit" className="scroll-mt-24 pt-10 sm:pt-14">
480
+ <div className="openvuln-brand">
481
+ <h1 className="mt-10 font-display font-medium uppercase leading-[0.94] tracking-[-0.015em] sm:mt-12">
482
+ <span className="ov-line-mask">
483
+ <span className="ov-line openvuln-outline block text-[15vw] sm:text-[clamp(64px,9.6vw,116px)]" style={{ animationDelay: "80ms" }}>
484
+ Open source<span className="openvuln-title-comma">,</span>
485
+ </span>
486
+ </span>
487
+ <span className="ov-line-mask">
488
+ <span className="ov-line openvuln-solid block text-[15vw] sm:text-[clamp(64px,9.6vw,116px)]" style={{ animationDelay: "200ms" }}>
489
+ audited by GLM<span className="sr-only">.</span><span className="openvuln-title-mark openvuln-title-period" aria-hidden />
490
+ </span>
491
+ </span>
492
+ </h1>
493
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
 
495
+ <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" }}>
496
+ <p className="max-w-[44ch] text-[15px] leading-7 text-ink-secondary">
497
+ Submit a public repository and OpenVuln hunts its vulnerabilities.
498
+ Maintainers see every finding the world sees only what they disclose.
499
+ </p>
500
+ <div className="w-full lg:max-w-[600px] lg:justify-self-end">
501
+ <RepoSubmitForm size="hero" appearance="dark" className="w-full" align="left" />
502
+ <p className="mt-2 flex items-center gap-1.5 text-xs leading-5 text-ink-tertiary" role="note">
503
+ <Clock3 size={12} strokeWidth={1.8} aria-hidden />
504
+ Submissions may require manual review · A full scan usually takes 12+ hours
505
  </p>
 
506
  </div>
 
 
 
 
 
 
 
 
 
 
507
  </div>
508
+ </section>
509
 
510
+ {/* ── 实时信息板 ── */}
511
+ <Reveal>
512
+ <LiveStrip scans={scans} loading={overview.isLoading} failed={overview.isError} />
513
+ </Reveal>
514
+
515
+ {/* ── 指标带 ── */}
516
+ <Reveal delay={60}>
517
+ <MetricsBand stats={overview.data} loading={overview.isLoading} failed={overview.isError} />
518
+ </Reveal>
519
+
520
+ {/* ── 仓库账本 ── */}
521
+ <section id="repositories" aria-labelledby="projects-title" className="scroll-mt-24 pt-12">
522
+ <Reveal className="flex flex-wrap items-end justify-between gap-4">
523
+ <div>
524
+ <p className="font-mono text-[10px] font-medium uppercase tracking-[0.22em] text-ink-tertiary">
525
+ Repository index
526
+ </p>
527
+ <h2 id="projects-title" className="mt-2 font-display text-2xl font-semibold tracking-tight text-ink">
528
+ Scanned repositories
529
+ </h2>
530
+ </div>
531
+ <div className="flex flex-col gap-2.5 sm:flex-row sm:items-center">
532
+ <div className="relative">
533
+ <Search size={14} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-tertiary" />
534
+ <input
535
+ type="search"
536
+ value={q}
537
+ onChange={(event) => setQ(event.target.value)}
538
+ placeholder="Search repositories"
539
+ aria-label="Search repositories"
540
+ 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"
541
+ />
542
  </div>
543
+ <div
544
+ className="inline-flex self-start rounded-lg border border-line bg-surface-raised p-0.5 sm:self-auto"
545
+ aria-label="Sort repositories"
 
 
 
546
  >
547
+ <button
548
+ type="button"
549
+ aria-pressed={sort === "stars"}
550
+ onClick={() => setSort("stars")}
551
+ className={`rounded-md px-3 py-1.5 font-mono text-[11px] font-medium transition focus-ring ${
552
+ sort === "stars" ? "bg-ink text-surface" : "text-ink-tertiary hover:text-ink"
553
+ }`}
554
+ >
555
+ Most starred
556
+ </button>
557
+ <button
558
+ type="button"
559
+ aria-pressed={sort === "newest"}
560
+ onClick={() => setSort("newest")}
561
+ className={`rounded-md px-3 py-1.5 font-mono text-[11px] font-medium transition focus-ring ${
562
+ sort === "newest" ? "bg-ink text-surface" : "text-ink-tertiary hover:text-ink"
563
+ }`}
564
+ >
565
+ Recently added
566
+ </button>
567
+ </div>
568
  </div>
569
+ </Reveal>
570
 
571
+ {/* 表头 */}
572
+ <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">
573
+ <span>Repository</span>
574
+ <span>Language</span>
575
+ <span>Stars</span>
576
+ <span>Last scan</span>
577
+ <span>Findings</span>
578
+ <span>Severity</span>
579
+ </div>
580
+ <div className="mt-6 border-b border-line md:hidden" aria-hidden />
581
+
582
+ <div aria-label="Scanned repository list">
583
+ {projects.isLoading ? (
584
+ <div className="space-y-2 py-5">
585
+ {Array.from({ length: 6 }).map((_, index) => (
586
+ <div key={index} className="h-14 animate-pulse rounded-lg bg-surface-sunken/70" />
587
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  </div>
589
+ ) : projects.isError ? (
590
+ <EmptyState icon={Shield} title="Could not load repositories" description="The OpenVuln API may be unavailable." />
591
+ ) : filtered.length === 0 ? (
592
+ <EmptyState
593
+ icon={Shield}
594
+ title={q ? "No matching repositories" : "No repositories yet"}
595
+ description={
596
+ q
597
+ ? "Try a different repository name."
598
+ : "Submit the first open-source repository to be scanned by VulnHunter."
599
+ }
600
+ action={!q ? <Link to="/submit"><Button>Submit a repository</Button></Link> : undefined}
601
+ />
602
+ ) : (
603
+ <>
604
+ {filtered.map((project) => (
605
+ <LedgerRow key={project.id} project={project} />
606
+ ))}
607
+ <div ref={sentinelRef} className="h-px" />
608
+ {projects.isFetchingNextPage && (
609
+ <div className="py-5 text-center font-mono text-xs text-ink-tertiary">Loading more…</div>
610
+ )}
611
+ </>
612
  )}
613
+ </div>
614
+
615
+ <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3.5 font-mono text-[10px] uppercase tracking-[0.1em] text-ink-tertiary">
616
+ <span>{(projects.data?.pages[0]?.total ?? 0).toLocaleString()} repositories indexed</span>
617
+ <a href="#top" className="transition-colors hover:text-ink">Back to top ↑</a>
618
+ </div>
619
+ </section>
620
+ </main>
621
 
 
 
622
  </div>
623
  );
624
  }
src/features/my/MyProjectsPage.tsx CHANGED
@@ -56,7 +56,7 @@ export function MyProjectsPage() {
56
  action={
57
  <Link
58
  to="/submit"
59
- className="inline-flex h-9 items-center rounded-md bg-[#ebecf0] px-3.5 text-sm font-medium text-[#0d0d0f] hover:bg-white"
60
  >
61
  Submit a project
62
  </Link>
 
56
  action={
57
  <Link
58
  to="/submit"
59
+ className="inline-flex h-9 items-center rounded-md bg-ink px-3.5 text-sm font-medium text-surface hover:bg-white"
60
  >
61
  Submit a project
62
  </Link>
src/features/project/OwnerFindings.tsx CHANGED
@@ -130,7 +130,7 @@ export function OwnerFindings({
130
  type="checkbox"
131
  checked={allChecked}
132
  onChange={toggleAll}
133
- className="h-4 w-4 rounded border-line accent-[#28D1FF]"
134
  />
135
  Select undisclosed ({disclosable.length})
136
  </label>
@@ -201,7 +201,7 @@ export function OwnerFindings({
201
  onClick={(e) => e.stopPropagation()}
202
  onChange={() => toggle(f.id)}
203
  aria-label={disclosed ? `${f.finding_key} already disclosed` : `Select ${f.finding_key}`}
204
- className="mt-1 h-4 w-4 shrink-0 rounded border-line accent-[#28D1FF] disabled:opacity-40"
205
  />
206
  <div className="min-w-0 flex-1">
207
  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
 
130
  type="checkbox"
131
  checked={allChecked}
132
  onChange={toggleAll}
133
+ className="h-4 w-4 rounded border-line accent-[#EDEFF4]"
134
  />
135
  Select undisclosed ({disclosable.length})
136
  </label>
 
201
  onClick={(e) => e.stopPropagation()}
202
  onChange={() => toggle(f.id)}
203
  aria-label={disclosed ? `${f.finding_key} already disclosed` : `Select ${f.finding_key}`}
204
+ className="mt-1 h-4 w-4 shrink-0 rounded border-line accent-[#EDEFF4] disabled:opacity-40"
205
  />
206
  <div className="min-w-0 flex-1">
207
  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
src/features/project/ProjectPage.tsx CHANGED
@@ -491,13 +491,13 @@ function ScanProgressPage({
491
  : "VulnHunter is analyzing the repository. Results will appear after the scan completes.";
492
 
493
  return (
494
- <div className="openvuln-home fixed inset-0 z-50 overflow-y-auto bg-black text-white">
495
  <div className="openvuln-glow pointer-events-none absolute inset-0 -z-10" />
496
 
497
  <header className="flex items-center justify-between px-5 py-5 sm:px-8 sm:py-7">
498
  <Link
499
  to="/"
500
- className="inline-flex items-center gap-2 rounded-full border border-[#333] bg-[#030303] px-3.5 py-2 text-xs font-medium text-[#acacb0] transition hover:border-[#484a58] hover:bg-[#111216] hover:text-white focus-ring-dark"
501
  >
502
  <ArrowLeft size={14} />
503
  OpenVuln
@@ -507,7 +507,7 @@ function ScanProgressPage({
507
  href={htmlUrl}
508
  target="_blank"
509
  rel="noreferrer"
510
- className="inline-flex items-center gap-2 rounded-full border border-[#333] bg-[#030303] px-3.5 py-2 text-xs font-medium text-[#acacb0] transition hover:border-[#484a58] hover:bg-[#111216] hover:text-white focus-ring-dark"
511
  >
512
  <Github size={14} />
513
  GitHub
@@ -516,45 +516,45 @@ function ScanProgressPage({
516
  </header>
517
 
518
  <main className="mx-auto flex min-h-[calc(100vh-88px)] w-full max-w-4xl flex-col items-center justify-center px-5 pb-16 pt-8 text-center sm:px-8">
519
- <div className="openvuln-brand flex h-16 w-16 items-center justify-center rounded-2xl border border-white/15 bg-[#151619] shadow-[0_18px_64px_rgba(0,0,0,0.5)]">
520
  <LoaderCircle
521
  size={28}
522
- className="animate-spin text-[#c9ccd6] motion-reduce:animate-none"
523
  strokeWidth={1.7}
524
  />
525
  </div>
526
 
527
- <p className="mt-7 font-mono text-[11px] uppercase tracking-[0.22em] text-[#85868d]">
528
  {justSubmitted ? "Repository added" : "Live scan status"}
529
  </p>
530
- <h1 className="openvuln-title mt-3 text-balance text-3xl font-medium leading-tight sm:text-5xl">
531
  {statusLabel}
532
  </h1>
533
 
534
- <div className="mt-5 flex flex-wrap items-center justify-center gap-2 text-sm text-[#acacb0]">
535
- <span className="text-[#f0f2f6]">{owner}</span>
536
- <span className="text-[#5f6067]">/</span>
537
- <span className="text-[#f0f2f6]">{repo}</span>
538
  {branch && (
539
  <>
540
- <span className="text-[#5f6067]">·</span>
541
- <span className="font-mono text-xs text-[#85868d]">{branch}</span>
542
  </>
543
  )}
544
  </div>
545
 
546
- <div className="openvuln-composer mt-9 w-full max-w-2xl rounded-[22px] border border-[#333] bg-[#111216] px-5 py-5 text-left shadow-[0_24px_90px_rgba(0,0,0,0.52)] sm:px-6">
547
  <div className="flex items-center gap-3">
548
  <span className="relative flex h-2.5 w-2.5 shrink-0">
549
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#aeb0ba] opacity-50 motion-reduce:animate-none" />
550
- <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[#d5d7df]" />
551
  </span>
552
- <p className="text-sm font-medium text-[#f0f2f6]">{statusLabel}</p>
553
  </div>
554
- <p className="mt-3 text-sm leading-6 text-[#85868d]">{detail}</p>
555
  {!loading && (
556
- <div className="mt-5 border-t border-[#27282e] pt-4">
557
- <div className="flex items-center justify-between text-xs text-[#66676e]">
558
  <span>Current stage</span>
559
  <span>
560
  {currentStage + 1} of {stages.length}
@@ -569,20 +569,20 @@ function ScanProgressPage({
569
  key={stageLabel}
570
  className={`flex min-w-0 flex-col items-center gap-2 rounded-xl border px-2 py-3 text-center ${
571
  current
572
- ? "border-[#4b4d58] bg-[#1a1b20] text-[#f0f2f6]"
573
  : complete
574
- ? "border-[#303139] bg-[#151619] text-[#acacb0]"
575
- : "border-[#24252a] bg-[#121317] text-[#5f6067]"
576
  }`}
577
  aria-current={current ? "step" : undefined}
578
  >
579
  <span
580
  className={`flex h-6 w-6 items-center justify-center rounded-full border text-[11px] ${
581
  current
582
- ? "border-[#b7bac5] bg-[#d5d7df] text-[#111216]"
583
  : complete
584
- ? "border-[#555762] bg-[#26272d] text-[#d5d7df]"
585
- : "border-[#303139] text-[#5f6067]"
586
  }`}
587
  >
588
  {complete ? <Check size={13} strokeWidth={2.2} /> : index + 1}
@@ -594,7 +594,7 @@ function ScanProgressPage({
594
  );
595
  })}
596
  </ol>
597
- <p className="mt-3 text-xs text-[#66676e]">
598
  This shows status stages, not elapsed-time progress. Refreshes every 5 seconds.
599
  </p>
600
  </div>
 
491
  : "VulnHunter is analyzing the repository. Results will appear after the scan completes.";
492
 
493
  return (
494
+ <div className="fixed inset-0 z-50 overflow-y-auto bg-surface text-ink">
495
  <div className="openvuln-glow pointer-events-none absolute inset-0 -z-10" />
496
 
497
  <header className="flex items-center justify-between px-5 py-5 sm:px-8 sm:py-7">
498
  <Link
499
  to="/"
500
+ className="inline-flex items-center gap-2 rounded-full border border-line bg-surface-raised px-3.5 py-2 text-xs font-medium text-ink-secondary transition hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring"
501
  >
502
  <ArrowLeft size={14} />
503
  OpenVuln
 
507
  href={htmlUrl}
508
  target="_blank"
509
  rel="noreferrer"
510
+ className="inline-flex items-center gap-2 rounded-full border border-line bg-surface-raised px-3.5 py-2 text-xs font-medium text-ink-secondary transition hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring"
511
  >
512
  <Github size={14} />
513
  GitHub
 
516
  </header>
517
 
518
  <main className="mx-auto flex min-h-[calc(100vh-88px)] w-full max-w-4xl flex-col items-center justify-center px-5 pb-16 pt-8 text-center sm:px-8">
519
+ <div className="openvuln-brand flex h-16 w-16 items-center justify-center rounded-2xl border border-line bg-surface-raised shadow-[0_20px_56px_-20px_rgba(0,0,0,0.7)]">
520
  <LoaderCircle
521
  size={28}
522
+ className="animate-spin text-accent-600 motion-reduce:animate-none"
523
  strokeWidth={1.7}
524
  />
525
  </div>
526
 
527
+ <p className="mt-7 font-mono text-[11px] uppercase tracking-[0.22em] text-ink-tertiary">
528
  {justSubmitted ? "Repository added" : "Live scan status"}
529
  </p>
530
+ <h1 className="openvuln-title mt-3 text-balance font-display text-3xl font-medium leading-tight sm:text-5xl">
531
  {statusLabel}
532
  </h1>
533
 
534
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-2 text-sm text-ink-secondary">
535
+ <span className="text-ink">{owner}</span>
536
+ <span className="text-ink-tertiary">/</span>
537
+ <span className="text-ink">{repo}</span>
538
  {branch && (
539
  <>
540
+ <span className="text-ink-tertiary">·</span>
541
+ <span className="font-mono text-xs text-ink-tertiary">{branch}</span>
542
  </>
543
  )}
544
  </div>
545
 
546
+ <div className="openvuln-composer mt-9 w-full max-w-2xl rounded-2xl border border-line bg-surface-raised px-5 py-5 text-left shadow-[0_24px_64px_-24px_rgba(0,0,0,0.7)] sm:px-6">
547
  <div className="flex items-center gap-3">
548
  <span className="relative flex h-2.5 w-2.5 shrink-0">
549
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent-600 opacity-40 motion-reduce:animate-none" />
550
+ <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-accent-600" />
551
  </span>
552
+ <p className="text-sm font-medium text-ink">{statusLabel}</p>
553
  </div>
554
+ <p className="mt-3 text-sm leading-6 text-ink-secondary">{detail}</p>
555
  {!loading && (
556
+ <div className="mt-5 border-t border-line pt-4">
557
+ <div className="flex items-center justify-between text-xs text-ink-tertiary">
558
  <span>Current stage</span>
559
  <span>
560
  {currentStage + 1} of {stages.length}
 
569
  key={stageLabel}
570
  className={`flex min-w-0 flex-col items-center gap-2 rounded-xl border px-2 py-3 text-center ${
571
  current
572
+ ? "border-accent-200 bg-accent-50 text-ink"
573
  : complete
574
+ ? "border-line bg-surface-sunken text-ink-secondary"
575
+ : "border-line bg-surface text-ink-tertiary"
576
  }`}
577
  aria-current={current ? "step" : undefined}
578
  >
579
  <span
580
  className={`flex h-6 w-6 items-center justify-center rounded-full border text-[11px] ${
581
  current
582
+ ? "border-transparent bg-ink text-surface"
583
  : complete
584
+ ? "border-line bg-surface text-ink-secondary"
585
+ : "border-line text-ink-tertiary"
586
  }`}
587
  >
588
  {complete ? <Check size={13} strokeWidth={2.2} /> : index + 1}
 
594
  );
595
  })}
596
  </ol>
597
+ <p className="mt-3 text-xs text-ink-tertiary">
598
  This shows status stages, not elapsed-time progress. Refreshes every 5 seconds.
599
  </p>
600
  </div>
src/features/project/VersionBar.tsx CHANGED
@@ -83,7 +83,7 @@ export function VersionBar({
83
  onClick={() => setOpen((v) => !v)}
84
  aria-expanded={open}
85
  aria-haspopup="menu"
86
- className="inline-flex h-8 items-center gap-1.5 rounded-full border border-line bg-surface-raised px-3 text-ink transition-colors hover:border-[#484a58] hover:bg-surface-sunken focus-ring"
87
  >
88
  <CircleCheck size={13} className="text-success" />
89
  <span className="font-mono text-[12px]">{viewing?.label ?? "—"}</span>
 
83
  onClick={() => setOpen((v) => !v)}
84
  aria-expanded={open}
85
  aria-haspopup="menu"
86
+ className="inline-flex h-8 items-center gap-1.5 rounded-full border border-line bg-surface-raised px-3 text-ink transition-colors hover:border-line-strong hover:bg-surface-sunken focus-ring"
87
  >
88
  <CircleCheck size={13} className="text-success" />
89
  <span className="font-mono text-[12px]">{viewing?.label ?? "—"}</span>
src/features/submit/SubmitPage.tsx CHANGED
@@ -39,15 +39,15 @@ export function SubmitPage() {
39
  every submission — only maintainers and admins can start a scan.
40
  </p>
41
  <a
42
- href={loginUrl("/submit")}
43
  onClick={(e) => {
44
  if (window.top && window.top !== window.self) {
45
  e.preventDefault();
46
- navigateToLogin("/submit");
47
  window.dispatchEvent(new Event("ov-oauth-popup-opened"));
48
  }
49
  }}
50
- className="mt-5 inline-flex h-10 items-center gap-2 rounded-md bg-[#ebecf0] px-4 text-sm font-medium text-[#0d0d0f] transition-colors hover:bg-white focus-ring"
51
  >
52
  <Github size={16} />
53
  Sign in with GitHub
 
39
  every submission — only maintainers and admins can start a scan.
40
  </p>
41
  <a
42
+ href={loginUrl()}
43
  onClick={(e) => {
44
  if (window.top && window.top !== window.self) {
45
  e.preventDefault();
46
+ navigateToLogin();
47
  window.dispatchEvent(new Event("ov-oauth-popup-opened"));
48
  }
49
  }}
50
+ className="mt-5 inline-flex h-10 items-center gap-2 rounded-md bg-ink px-4 text-sm font-medium text-surface transition-colors hover:bg-white focus-ring"
51
  >
52
  <Github size={16} />
53
  Sign in with GitHub
src/index.css CHANGED
@@ -3,7 +3,7 @@
3
  @tailwind utilities;
4
 
5
  @layer base {
6
- /* 色体系:原生控件(select 弹层/滚动条/checkbox)跟随色 */
7
  :root {
8
  color-scheme: dark;
9
  }
@@ -15,27 +15,29 @@
15
  }
16
 
17
  select option {
18
- background-color: #111216;
19
- color: #f0f2f6;
20
  }
21
 
22
  body {
23
  @apply bg-surface text-ink font-sans;
 
24
  }
25
 
26
  ::selection {
27
- background: rgba(40, 209, 255, 0.24);
28
- color: #f0f2f6;
29
  }
30
  }
31
 
32
  @layer utilities {
33
  .focus-ring {
34
- @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-black;
35
  }
36
 
 
37
  .focus-ring-dark {
38
- @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-black;
39
  }
40
 
41
  .text-balance {
@@ -44,38 +46,114 @@
44
  }
45
 
46
  @layer components {
47
- .openvuln-home {
48
- font-family:
49
- -apple-system,
50
- BlinkMacSystemFont,
51
- "Segoe UI",
52
- Roboto,
53
- "Helvetica Neue",
54
- Arial,
55
- "Noto Sans",
56
- sans-serif,
57
- "Apple Color Emoji",
58
- "Segoe UI Emoji",
59
- "Segoe UI Symbol",
60
- "Noto Color Emoji";
61
  }
62
 
63
- .openvuln-title {
64
- color: #f1f1f3;
 
 
 
65
  }
66
 
67
- .openvuln-glow {
68
- background: radial-gradient(ellipse 48% 38% at 50% 18%, rgba(255, 255, 255, 0.055), transparent 72%);
69
  }
70
 
71
- .openvuln-home::after {
72
- position: absolute;
73
- inset: 0;
74
- z-index: -1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  content: "";
76
- pointer-events: none;
77
- opacity: 0.04;
78
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.12'/%3E%3C/svg%3E");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
 
81
  .openvuln-brand {
@@ -87,31 +165,40 @@
87
  }
88
 
89
  .openvuln-panel {
90
- border: 1px solid rgba(255, 255, 255, 0.08);
91
- border-radius: 26px;
92
- background:
93
- linear-gradient(145deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
94
- rgba(16, 17, 21, 0.82);
95
- box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
96
- backdrop-filter: blur(18px);
97
- }
98
-
99
- .openvuln-scrollbar {
100
- scrollbar-width: thin;
101
- scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
102
  }
103
 
104
- .openvuln-scrollbar::-webkit-scrollbar {
105
- width: 6px;
 
 
 
 
 
 
106
  }
107
 
108
- .openvuln-scrollbar::-webkit-scrollbar-track {
109
- background: transparent;
 
 
110
  }
111
 
112
- .openvuln-scrollbar::-webkit-scrollbar-thumb {
113
- border-radius: 999px;
114
- background: rgba(255, 255, 255, 0.2);
 
 
 
 
 
 
 
 
 
115
  }
116
  }
117
 
@@ -149,11 +236,30 @@
149
  animation: none;
150
  transform: translateX(200%);
151
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
  body.openvuln-running {
155
  overflow: hidden;
156
- background: #000;
157
  }
158
 
159
  body.openvuln-running .app-shell-chrome {
 
3
  @tailwind utilities;
4
 
5
  @layer base {
6
+ /* 控制台体系:原生控件(select 弹层/滚动条/checkbox)跟随色 */
7
  :root {
8
  color-scheme: dark;
9
  }
 
15
  }
16
 
17
  select option {
18
+ background-color: #12151c;
19
+ color: #edeff4;
20
  }
21
 
22
  body {
23
  @apply bg-surface text-ink font-sans;
24
+ font-feature-settings: "cv05" 1, "ss01" 1;
25
  }
26
 
27
  ::selection {
28
+ background: rgba(237, 239, 244, 0.2);
29
+ color: #ffffff;
30
  }
31
  }
32
 
33
  @layer utilities {
34
  .focus-ring {
35
+ @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0C0E13];
36
  }
37
 
38
+ /* 历史命名保留:与 .focus-ring 一致 */
39
  .focus-ring-dark {
40
+ @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0C0E13];
41
  }
42
 
43
  .text-balance {
 
46
  }
47
 
48
  @layer components {
49
+ .openvuln-title {
50
+ color: #edeff4;
51
+ letter-spacing: -0.02em;
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
 
54
+ /* 大字报标题:第一行空心描边(hover 时微微填充) */
55
+ .openvuln-outline {
56
+ color: transparent;
57
+ -webkit-text-stroke: 1.5px rgba(237, 239, 244, 0.56);
58
+ transition: color 500ms cubic-bezier(0.22, 1, 0.36, 1);
59
  }
60
 
61
+ h1:hover .openvuln-outline {
62
+ color: rgba(237, 239, 244, 0.12);
63
  }
64
 
65
+ @supports not ((-webkit-text-stroke: 1px white)) {
66
+ .openvuln-outline {
67
+ color: rgba(237, 239, 244, 0.56);
68
+ }
69
+ }
70
+
71
+ /* 大字报标题整行印刷式抬升入场 */
72
+ .ov-line-mask {
73
+ display: block;
74
+ overflow: hidden;
75
+ padding-bottom: 0.12em;
76
+ }
77
+
78
+ .ov-line {
79
+ display: block;
80
+ transform: translateY(112%);
81
+ animation: ov-line-up 850ms cubic-bezier(0.19, 1, 0.22, 1) forwards;
82
+ }
83
+
84
+ .openvuln-title-mark {
85
+ position: relative;
86
+ display: inline-block;
87
+ -webkit-text-stroke: 0;
88
+ }
89
+
90
+ .openvuln-title-comma {
91
+ display: inline-block;
92
+ margin-left: 4px;
93
+ }
94
+
95
+ .openvuln-title-period {
96
+ width: 0.1em;
97
+ height: 0.1em;
98
+ margin-left: 0.1em;
99
+ border-radius: 999px;
100
+ background: rgba(237, 239, 244, 0.88);
101
+ }
102
+
103
+ @keyframes ov-line-up {
104
+ to {
105
+ transform: translateY(0);
106
+ }
107
+ }
108
+
109
+ /* 滚动进入视口的克制上浮 */
110
+ .ov-reveal {
111
+ opacity: 0;
112
+ transform: translateY(14px);
113
+ transition:
114
+ opacity 420ms cubic-bezier(0.22, 1, 0.36, 1),
115
+ transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
116
+ }
117
+
118
+ .ov-reveal.ov-in {
119
+ opacity: 1;
120
+ transform: none;
121
+ }
122
+
123
+ /* 顶栏导航的下划线扫入 */
124
+ .ov-navlink {
125
+ position: relative;
126
+ }
127
+
128
+ .ov-navlink::after {
129
  content: "";
130
+ position: absolute;
131
+ left: 0;
132
+ right: 0;
133
+ bottom: -4px;
134
+ height: 1px;
135
+ background: #edeff4;
136
+ transform: scaleX(0);
137
+ transform-origin: left;
138
+ transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
139
+ }
140
+
141
+ .ov-navlink:hover::after,
142
+ .ov-navlink:focus-visible::after {
143
+ transform: scaleX(1);
144
+ }
145
+
146
+ /* 大字报标题:第二行实心,带极轻的金属纵向光泽 */
147
+ .openvuln-solid {
148
+ background: linear-gradient(180deg, #ffffff 0%, #eceef2 52%, #c2c8d2 100%);
149
+ background-clip: text;
150
+ -webkit-background-clip: text;
151
+ color: transparent;
152
+ }
153
+
154
+ /* 极淡的顶部纵深,用于扫描进度页 */
155
+ .openvuln-glow {
156
+ background: radial-gradient(ellipse 52% 30% at 50% 0%, rgba(237, 239, 244, 0.04), transparent 70%);
157
  }
158
 
159
  .openvuln-brand {
 
165
  }
166
 
167
  .openvuln-panel {
168
+ border: 1px solid #232833;
169
+ border-radius: 16px;
170
+ background: #12151c;
171
+ box-shadow: 0 20px 48px -24px rgba(0, 0, 0, 0.6);
 
 
 
 
 
 
 
 
172
  }
173
 
174
+ /* hero 背景视频:原生暗色线网动画,向下淡入页面底色 */
175
+ .openvuln-hero-video {
176
+ position: absolute;
177
+ inset: 0 0 auto 0;
178
+ z-index: -10;
179
+ height: 720px;
180
+ overflow: hidden;
181
+ pointer-events: none;
182
  }
183
 
184
+ .openvuln-hero-video video {
185
+ height: 100%;
186
+ width: 100%;
187
+ object-fit: cover;
188
  }
189
 
190
+ .openvuln-hero-video::after {
191
+ content: "";
192
+ position: absolute;
193
+ inset: 0;
194
+ background: linear-gradient(
195
+ to bottom,
196
+ rgba(12, 14, 19, 0.38) 0%,
197
+ rgba(12, 14, 19, 0.18) 34%,
198
+ rgba(12, 14, 19, 0.55) 62%,
199
+ rgba(12, 14, 19, 0.94) 86%,
200
+ #0c0e13 100%
201
+ );
202
  }
203
  }
204
 
 
236
  animation: none;
237
  transform: translateX(200%);
238
  }
239
+
240
+ .openvuln-hero-video video {
241
+ display: none;
242
+ }
243
+
244
+ .ov-line {
245
+ animation: none;
246
+ transform: none;
247
+ }
248
+
249
+ .ov-reveal {
250
+ opacity: 1;
251
+ transform: none;
252
+ transition: none;
253
+ }
254
+
255
+ .ov-navlink::after {
256
+ transition: none;
257
+ }
258
  }
259
 
260
  body.openvuln-running {
261
  overflow: hidden;
262
+ background: #0c0e13;
263
  }
264
 
265
  body.openvuln-running .app-shell-chrome {
src/main.tsx CHANGED
@@ -1,5 +1,10 @@
1
  import { StrictMode } from "react";
2
  import { createRoot } from "react-dom/client";
 
 
 
 
 
3
  import "./index.css";
4
  import { App } from "./App";
5
 
 
1
  import { StrictMode } from "react";
2
  import { createRoot } from "react-dom/client";
3
+ import "@fontsource-variable/inter";
4
+ import "@fontsource-variable/space-grotesk";
5
+ import "@fontsource/ibm-plex-mono/400.css";
6
+ import "@fontsource/ibm-plex-mono/500.css";
7
+ import "@fontsource/ibm-plex-mono/600.css";
8
  import "./index.css";
9
  import { App } from "./App";
10
 
src/shared/api/client.ts CHANGED
@@ -76,7 +76,7 @@ export interface MeResponse {
76
  }
77
 
78
  /** GitHub OAuth login URL (full-page redirect). */
79
- export function loginUrl(returnTo: string): string {
80
  // 站内相对路径与白名单绝对地址(HF 部署回跳)都放行 —— 白名单校验在后端;
81
  // 仅协议相对 "//…" 一律拒绝(开放重定向的唯一真风险)。
82
  const safe = returnTo.startsWith("//") ? "/" : returnTo;
 
76
  }
77
 
78
  /** GitHub OAuth login URL (full-page redirect). */
79
+ export function loginUrl(returnTo: string = currentReturnTo()): string {
80
  // 站内相对路径与白名单绝对地址(HF 部署回跳)都放行 —— 白名单校验在后端;
81
  // 仅协议相对 "//…" 一律拒绝(开放重定向的唯一真风险)。
82
  const safe = returnTo.startsWith("//") ? "/" : returnTo;
tailwind.config.js CHANGED
@@ -4,48 +4,50 @@ export default {
4
  theme: {
5
  extend: {
6
  colors: {
7
- // ── 色视觉语言(对齐协作者着陆页task-5ee34751 fish 定稿)──
8
  surface: {
9
- DEFAULT: "#000000", // 页面底(openvuln-home bg-black
10
- raised: "#111216", // 卡片(composer bg
11
- sunken: "#15161b", // 井/hover(focus-within bg)
12
- header: "#030303", // 顶栏(pill bg)
 
 
 
 
13
  },
14
- line: "#26272c", // 分隔线/边框(#26272c ~ #2f3037)
15
  ink: {
16
- DEFAULT: "#f0f2f6", // 主文字
17
- secondary: "#acacb0", // 次文字
18
- tertiary: "#696a70", // 辅助/placeholder
19
  },
20
- // 交互蓝:深链接/聚焦/进行中(Clouditera 规范深底 accent)
21
  accent: {
22
- 50: "rgba(40, 209, 255, 0.10)",
23
- 100: "rgba(40, 209, 255, 0.16)",
24
- 200: "rgba(40, 209, 255, 0.28)",
25
- 600: "#28D1FF", // 深底主色(原 accent-dark)
26
- 700: "#5CDDFF", // hover 亮一档
27
- 800: "#8AE6FF",
28
  },
29
- // 功能色深底版 ink + 半透明 bg,bar 用亮色
30
  sev: {
31
- critical: { ink: "#FF6B6B", bg: "rgba(242, 79, 79, 0.16)", bar: "#F24F4F" },
32
- high: { ink: "#F24F4F", bg: "rgba(242, 79, 79, 0.12)", bar: "#F24F4F" },
33
- medium: { ink: "#FF8A5C", bg: "rgba(255, 115, 60, 0.13)", bar: "#FF733C" },
34
- low: { ink: "#F7C530", bg: "rgba(247, 197, 48, 0.12)", bar: "#F7C530" },
35
  // info:VH 兜底档,公众 UI 不展示,仅防御保留
36
- info: { ink: "#acacb0", bg: "rgba(172, 172, 176, 0.10)", bar: "#696a70" },
37
  },
38
- ai: { DEFAULT: "#9285FF", bg: "rgba(146, 133, 255, 0.13)", ink: "#A79DFF" }, // AI 紫(深底)
39
- success: { DEFAULT: "#3AD186", bg: "rgba(58, 209, 134, 0.12)", ink: "#3AD186" },
40
- danger: "#F24F4F",
41
- warning: "#FF733C",
42
- running: { DEFAULT: "#28D1FF", bg: "rgba(40, 209, 255, 0.12)", ink: "#28D1FF" },
43
  },
44
  fontFamily: {
45
- // 协作者体系:系统字栈(着陆页 openvuln-home 同族)
46
- display: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'],
47
- sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'],
48
- mono: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
49
  },
50
  maxWidth: {
51
  "6xl": "72rem",
 
4
  theme: {
5
  extend: {
6
  colors: {
7
+ // ── 控制台视觉语言:石墨蓝黑分层彩色只留给风险数据 ──
8
  surface: {
9
+ DEFAULT: "#0C0E13", // 页面底(void
10
+ raised: "#12151C", // 卡片/面板graphite
11
+ sunken: "#191D26", // 井/hover
12
+ header: "#0C0E13", // 顶栏
13
+ },
14
+ line: {
15
+ DEFAULT: "#232833", // hairline 分隔线/边框
16
+ strong: "#37404F", // hover 边框
17
  },
 
18
  ink: {
19
+ DEFAULT: "#EDEFF4", // 主文字(暖白)
20
+ secondary: "#9BA3B0", // 次文字
21
+ tertiary: "#646C7A", // 辅助/placeholder
22
  },
23
+ // 交互(phosphor 白):链接/聚焦/进行中一律无彩,彩色专属数据
24
  accent: {
25
+ 50: "rgba(237, 239, 244, 0.05)",
26
+ 100: "rgba(237, 239, 244, 0.09)",
27
+ 200: "rgba(237, 239, 244, 0.18)",
28
+ 600: "#D7DBE2",
29
+ 700: "#FFFFFF",
30
+ 800: "#FFFFFF",
31
  },
32
+ // 功能色(数据语义专用)NVD 四档 红→橙→琥珀
33
  sev: {
34
+ critical: { ink: "#FF7A72", bg: "rgba(229, 72, 77, 0.14)", bar: "#E5484D" },
35
+ high: { ink: "#FF9A8A", bg: "rgba(242, 96, 79, 0.12)", bar: "#F2604F" },
36
+ medium: { ink: "#FFB37E", bg: "rgba(255, 138, 60, 0.12)", bar: "#FF8A3C" },
37
+ low: { ink: "#F2CE60", bg: "rgba(240, 196, 58, 0.12)", bar: "#F0C43A" },
38
  // info:VH 兜底档,公众 UI 不展示,仅防御保留
39
+ info: { ink: "#9BA3B0", bg: "rgba(155, 163, 176, 0.10)", bar: "#646C7A" },
40
  },
41
+ ai: { DEFAULT: "#9285FF", bg: "rgba(146, 133, 255, 0.12)", ink: "#A79DFF" },
42
+ success: { DEFAULT: "#34B87C", bg: "rgba(52, 184, 124, 0.12)", ink: "#57D69C" },
43
+ danger: "#F0655F",
44
+ warning: "#FF8A3C",
45
+ running: { DEFAULT: "#D7DBE2", bg: "rgba(237, 239, 244, 0.08)", ink: "#D7DBE2" },
46
  },
47
  fontFamily: {
48
+ display: ['"Space Grotesk Variable"', '"Inter Variable"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'],
49
+ sans: ['"Inter Variable"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'],
50
+ mono: ['"IBM Plex Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
 
51
  },
52
  maxWidth: {
53
  "6xl": "72rem",