From 846c77557f508519913318275e67a93ef4a6e5c1 Mon Sep 17 00:00:00 2001
From: cybaka520
Date: Sun, 11 Oct 2026 17:08:27 +0800
Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E7=BC=96?=
=?UTF-8?q?=E8=BE=91=E5=99=A8=E5=89=8D=E7=AB=AF=20UI=20=E7=BB=84=E4=BB=B6?=
=?UTF-8?q?=E5=BA=93=E5=92=8C=E4=B8=BB=E9=A2=98=E7=B3=BB=E7=BB=9F=EF=BC=8C?=
=?UTF-8?q?=E7=A7=BB=E9=99=A4=20i18n=20=E4=BE=9D=E8=B5=96=E5=B9=B6?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=A0=B7=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/handler/upload.go | 1 +
frontend/package.json | 7 +-
frontend/pnpm-lock.yaml | 816 ++++--------------
frontend/src/atoms/theme.ts | 47 +-
frontend/src/components/UserMenu.tsx | 8 +-
.../components/review/AudioSourcePicker.tsx | 92 ++
.../src/components/review/EditorTopMenu.tsx | 156 ++--
.../src/components/review/EditorWorkbench.tsx | 42 +-
.../components/review/LyricEditorHeader.tsx | 82 +-
.../src/components/review/editor-theme.css | 59 +-
frontend/src/components/ui/checkbox.tsx | 15 +-
frontend/src/components/ui/context-menu.tsx | 103 +++
frontend/src/components/ui/hover-card.tsx | 30 +
frontend/src/components/ui/kbd.tsx | 18 +
frontend/src/components/ui/radio-group.tsx | 25 +
frontend/src/components/ui/select.tsx | 19 +
frontend/src/components/ui/slider.tsx | 83 ++
.../src/editor/components/ConfirmDialog.tsx | 64 ++
.../editor/components/KeyBinding/index.tsx | 2 +-
.../editor/components/MasterDetail/index.tsx | 50 +-
.../components/Menus/lyric-line-menu.tsx | 59 +-
.../editor/components/ReduceStutterDialog.tsx | 181 ++++
.../editor/components/RibbonBar/common.tsx | 58 +-
.../editor/components/RibbonBar/edit-mode.tsx | 442 ++++------
.../src/editor/components/RibbonBar/index.tsx | 57 +-
.../components/RibbonBar/preview-mode.tsx | 42 +-
.../editor/components/RibbonBar/sync-mode.tsx | 121 +--
.../editor/components/Sidebar/BpmPanel.tsx | 362 +++-----
.../components/Sidebar/OutlinePanel.tsx | 71 +-
.../Sidebar/ResizableSidebar.module.css | 2 +
.../components/Sidebar/ResizableSidebar.tsx | 20 +-
.../components/Sidebar/RightSidebar.tsx | 4 +-
.../components/Sidebar/SidebarTabBar.tsx | 12 +-
.../src/editor/components/Sidebar/index.tsx | 7 +-
.../components/SuspensePlaceHolder/index.tsx | 30 +-
frontend/src/editor/hooks/useFileOpener.ts | 30 +-
frontend/src/editor/hooks/useRelativeTime.ts | 26 +-
frontend/src/editor/i18n/index.ts | 16 -
frontend/src/editor/index.css | 14 +-
.../src/editor/modules/audio/audio-engine.ts | 204 ++++-
.../modules/audio/components/AudioSlider.tsx | 13 +-
.../editor/modules/audio/components/index.tsx | 273 +++---
.../modules/audio/hooks/useAudioFeedback.ts | 10 +-
.../modules/lyric-drag/DragGhostRenderer.tsx | 39 +-
.../TouchSyncPanel/index.module.css | 6 -
.../components/TouchSyncPanel/index.tsx | 48 +-
.../lyric-editor/components/index.module.css | 14 +-
.../modules/lyric-editor/components/index.tsx | 76 +-
.../components/line-marker-icons.tsx | 37 +
.../components/lyric-line-view.tsx | 223 ++---
.../components/lyric-word-menu.tsx | 51 +-
.../components/lyric-word-view.tsx | 206 +++--
.../components/roman-word-view.module.css | 27 -
.../components/roman-word-view.tsx | 26 +-
.../lyric-editor/tools/ReplaceWordDialog.tsx | 89 +-
.../modules/lyric-editor/tools/RubyEditor.tsx | 40 +-
.../modules/lyric-editor/tools/TimeShift.tsx | 218 +++--
.../modules/lyric-editor/tools/i18nEditor.tsx | 142 ++-
.../project/modals/GlobalDragOverlay.tsx | 21 +-
.../project/modals/MetadataEditor.module.css | 7 +-
.../modules/project/modals/MetadataEditor.tsx | 350 ++++----
.../project/modals/VocalTagsEditor.tsx | 79 +-
.../project/modals/metadata-dialog-theme.ts | 40 +-
.../review/modals/ReportFormatEditor.tsx | 326 ++-----
.../review/modals/ReviewReportDialog.tsx | 409 ++++-----
.../services/report-service/format-service.ts | 10 +
.../report-service/format-template.ts | 37 +
.../report-service/metadata-report-builder.ts | 68 +-
.../services/report-service/render-service.ts | 2 +
.../report-service/selection-service.ts | 3 +
.../structured-report-builder.ts | 1 +
.../review/services/report-service/types.ts | 12 +
.../services/review-template-service.tsx | 125 +--
.../components/AdvancedSegmentation.tsx | 482 +++++------
.../components/ManualWordSplitter.tsx | 17 +-
.../segmentation/components/split-word.tsx | 97 +--
.../components/AudioSpectrogram.tsx | 90 +-
frontend/src/editor/states/dialogs.ts | 1 +
frontend/src/editor/states/main.ts | 13 +-
frontend/src/editor/states/notifications.ts | 68 +-
frontend/src/index.css | 218 +++--
.../__tests__/editor-audio-sources.test.ts | 98 +++
.../__tests__/editor-save-bundle-diff.test.ts | 28 +-
frontend/src/lib/review/audio-sources.ts | 49 ++
frontend/src/pages/BindAccount.tsx | 4 +-
frontend/src/pages/LyricEditorPage.tsx | 114 ++-
86 files changed, 3787 insertions(+), 3997 deletions(-)
create mode 100644 frontend/src/components/review/AudioSourcePicker.tsx
create mode 100644 frontend/src/components/ui/context-menu.tsx
create mode 100644 frontend/src/components/ui/hover-card.tsx
create mode 100644 frontend/src/components/ui/kbd.tsx
create mode 100644 frontend/src/components/ui/radio-group.tsx
create mode 100644 frontend/src/components/ui/select.tsx
create mode 100644 frontend/src/components/ui/slider.tsx
create mode 100644 frontend/src/editor/components/ConfirmDialog.tsx
create mode 100644 frontend/src/editor/components/ReduceStutterDialog.tsx
delete mode 100644 frontend/src/editor/i18n/index.ts
delete mode 100644 frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.module.css
create mode 100644 frontend/src/editor/modules/lyric-editor/components/line-marker-icons.tsx
delete mode 100644 frontend/src/editor/modules/lyric-editor/components/roman-word-view.module.css
create mode 100644 frontend/src/lib/__tests__/editor-audio-sources.test.ts
create mode 100644 frontend/src/lib/review/audio-sources.ts
diff --git a/backend/internal/handler/upload.go b/backend/internal/handler/upload.go
index 2f09b90..bdaab58 100644
--- a/backend/internal/handler/upload.go
+++ b/backend/internal/handler/upload.go
@@ -229,6 +229,7 @@ func (h *UploadHandler) ServeFile(c *gin.Context) {
c.Header("Content-Type", fileContentType(key))
c.Header("Cache-Control", "public, max-age=3600")
+ c.Header("Cross-Origin-Resource-Policy", "cross-origin")
// http.ServeContent 支持 Range 请求,浏览器可流式播放与 seek
http.ServeContent(c.Writer, c.Request, path.Base(key), modTime, obj)
_ = size
diff --git a/frontend/package.json b/frontend/package.json
index dfc6054..46da66b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,14 +19,15 @@
"@applemusic-like-lyrics/core": "^0.5.2",
"@applemusic-like-lyrics/lyric": "^1.0.1",
"@applemusic-like-lyrics/react-full": "^0.4.2",
- "@fluentui/react-icons": "^2.0.333",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-collapsible": "^1.1.20",
+ "@radix-ui/react-context-menu": "^2.3.8",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
+ "@radix-ui/react-hover-card": "^1.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-scroll-area": "^1.2.18",
@@ -34,17 +35,14 @@
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
- "@radix-ui/themes": "^3.3.0",
"@tanstack/react-query": "^5.102.8",
"browser-id3-writer": "^4.4.0",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
- "classnames": "^2.5.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"framer-motion": "^11.15.0",
"hyphen": "^1.14.1",
- "i18next": "^26.3.6",
"idb": "^8.0.3",
"immer": "^11.1.15",
"jotai": "^2.12.0",
@@ -60,7 +58,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.86.0",
- "react-i18next": "^17.0.10",
"react-router-dom": "^7.1.0",
"react-viewport-list": "^7.1.2",
"sonner": "^2.0.8",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 6f0c2e6..29a6e82 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -142,9 +142,6 @@ importers:
'@applemusic-like-lyrics/react-full':
specifier: ^0.4.2
version: 0.4.2(@pixi/app@7.4.3(@pixi/core@7.4.3)(@pixi/display@7.4.3(@pixi/core@7.4.3)))(@pixi/core@7.4.3)(@pixi/display@7.4.3(@pixi/core@7.4.3))(@pixi/filter-blur@7.4.3(@pixi/core@7.4.3))(@pixi/filter-bulge-pinch@5.1.1(@pixi/core@7.4.3))(@pixi/filter-color-matrix@7.4.3(@pixi/core@7.4.3))(@pixi/sprite@7.4.3(@pixi/core@7.4.3)(@pixi/display@7.4.3(@pixi/core@7.4.3)))(jotai@2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@fluentui/react-icons':
- specifier: ^2.0.333
- version: 2.0.343(react@19.2.8)
'@hookform/resolvers':
specifier: ^5.9.1
version: 5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.86.0(react@19.2.8))(zod@4.4.3)
@@ -160,12 +157,18 @@ importers:
'@radix-ui/react-collapsible':
specifier: ^1.1.20
version: 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-context-menu':
+ specifier: ^2.3.8
+ version: 2.3.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-dialog':
specifier: ^1.1.23
version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.24
version: 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-hover-card':
+ specifier: ^1.1.24
+ version: 1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-label':
specifier: ^2.1.15
version: 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -187,9 +190,6 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.16
version: 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/themes':
- specifier: ^3.3.0
- version: 3.3.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tanstack/react-query':
specifier: ^5.102.8
version: 5.102.8(react@19.2.8)
@@ -202,9 +202,6 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
- classnames:
- specifier: ^2.5.1
- version: 2.5.1
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -217,9 +214,6 @@ importers:
hyphen:
specifier: ^1.14.1
version: 1.14.1
- i18next:
- specifier: ^26.3.6
- version: 26.4.2(typescript@5.9.3)
idb:
specifier: ^8.0.3
version: 8.0.3
@@ -265,9 +259,6 @@ importers:
react-hook-form:
specifier: ^7.86.0
version: 7.86.0(react@19.2.8)
- react-i18next:
- specifier: ^17.0.10
- version: 17.0.15(i18next@26.4.2(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
react-router-dom:
specifier: ^7.1.0
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -495,10 +486,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/runtime@7.29.7':
- resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
- engines: {node: '>=6.9.0'}
-
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -558,9 +545,6 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
- '@emotion/hash@0.9.2':
- resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
-
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -780,11 +764,6 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
- '@fluentui/react-icons@2.0.343':
- resolution: {integrity: sha512-CjwAZrog38QNPeIjBfMs0e7tPUW/Bxgge5qbhbfdhXJwXOSm5lDLCKkx4xyBSu1Mg/e/8wLocQvWcBZlS6/Axg==}
- peerDependencies:
- react: '>=16.8.0 <20.0.0'
-
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
@@ -800,17 +779,6 @@ packages:
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
- '@griffel/core@1.21.4':
- resolution: {integrity: sha512-Pt6tjaBCOYYC76L7U5yl3r+UqOGXEbeskvYuqUsUzaVQR2ueoMErA2+bRjvVPFr8NvvhPVU+QNBqd9Deg5st2A==}
-
- '@griffel/react@1.7.8':
- resolution: {integrity: sha512-kTegyzZoRqpRNUQ8Fd6QWLC8MUqSyO+/GDC/ffbEG8Y6JDole98rBrOVA/NN9PDD61/ktsd2UqtsTNwtAG6q3w==}
- peerDependencies:
- react: '>=16.14.0 <20.0.0'
-
- '@griffel/style-types@1.4.3':
- resolution: {integrity: sha512-sFxMK19oFUPQSmyCKT1Pvj5Nu/ODvhMczqhGjVAoksDTXUj1za40WA0CXqV3o+xxKWPec+K/rbS78jJLmomsQA==}
-
'@hookform/resolvers@5.9.1':
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
peerDependencies:
@@ -1017,30 +985,14 @@ packages:
engines: {node: '>=18'}
hasBin: true
- '@radix-ui/colors@3.0.0':
- resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==}
-
'@radix-ui/number@1.1.3':
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
'@radix-ui/primitive@1.1.7':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
- '@radix-ui/react-accessible-icon@1.1.15':
- resolution: {integrity: sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-accordion@1.2.20':
- resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==}
+ '@radix-ui/react-alert-dialog@1.1.23':
+ resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1052,8 +1004,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-alert-dialog@1.1.23':
- resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==}
+ '@radix-ui/react-arrow@1.1.15':
+ resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1065,8 +1017,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-arrow@1.1.15':
- resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==}
+ '@radix-ui/react-arrow@1.1.16':
+ resolution: {integrity: sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1104,8 +1056,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-checkbox@1.3.11':
- resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==}
+ '@radix-ui/react-collapsible@1.1.20':
+ resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1117,8 +1069,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-collapsible@1.1.20':
- resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==}
+ '@radix-ui/react-collection@1.1.15':
+ resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1130,8 +1082,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-collection@1.1.15':
- resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
+ '@radix-ui/react-collection@1.1.16':
+ resolution: {integrity: sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1152,8 +1104,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-context-menu@2.3.7':
- resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==}
+ '@radix-ui/react-context-menu@2.3.8':
+ resolution: {integrity: sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1196,6 +1148,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-direction@1.1.5':
+ resolution: {integrity: sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dismissable-layer@1.1.19':
resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==}
peerDependencies:
@@ -1209,6 +1170,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-dismissable-layer@1.1.20':
+ resolution: {integrity: sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-dropdown-menu@2.1.24':
resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==}
peerDependencies:
@@ -1244,8 +1218,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-form@0.1.16':
- resolution: {integrity: sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==}
+ '@radix-ui/react-focus-scope@1.2.0':
+ resolution: {integrity: sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1257,8 +1231,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-hover-card@1.1.23':
- resolution: {integrity: sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==}
+ '@radix-ui/react-hover-card@1.1.24':
+ resolution: {integrity: sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1305,21 +1279,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-menubar@1.1.24':
- resolution: {integrity: sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-navigation-menu@1.2.22':
- resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==}
+ '@radix-ui/react-menu@2.1.25':
+ resolution: {integrity: sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1331,8 +1292,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-one-time-password-field@0.1.16':
- resolution: {integrity: sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==}
+ '@radix-ui/react-popover@1.1.23':
+ resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1344,8 +1305,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-password-toggle-field@0.1.11':
- resolution: {integrity: sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==}
+ '@radix-ui/react-popper@1.3.7':
+ resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1357,8 +1318,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-popover@1.1.23':
- resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==}
+ '@radix-ui/react-popper@1.3.8':
+ resolution: {integrity: sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1370,8 +1331,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-popper@1.3.7':
- resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==}
+ '@radix-ui/react-portal@1.1.17':
+ resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1383,8 +1344,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-portal@1.1.17':
- resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==}
+ '@radix-ui/react-portal@1.1.18':
+ resolution: {integrity: sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1409,8 +1370,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-primitive@2.1.10':
- resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
+ '@radix-ui/react-presence@1.1.11':
+ resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1422,8 +1383,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-progress@1.1.16':
- resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==}
+ '@radix-ui/react-primitive@2.1.10':
+ resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1435,8 +1396,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-radio-group@1.4.7':
- resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==}
+ '@radix-ui/react-primitive@2.1.11':
+ resolution: {integrity: sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1461,8 +1422,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-scroll-area@1.2.18':
- resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==}
+ '@radix-ui/react-roving-focus@1.1.20':
+ resolution: {integrity: sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1474,8 +1435,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-select@2.3.7':
- resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==}
+ '@radix-ui/react-scroll-area@1.2.18':
+ resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1500,19 +1461,6 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-slider@1.4.7':
- resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
'@radix-ui/react-slot@1.3.3':
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
peerDependencies:
@@ -1522,18 +1470,14 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-switch@1.3.7':
- resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==}
+ '@radix-ui/react-slot@1.4.0':
+ resolution: {integrity: sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==}
peerDependencies:
'@types/react': '*'
- '@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
- '@types/react-dom':
- optional: true
'@radix-ui/react-tabs@1.1.21':
resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==}
@@ -1548,58 +1492,6 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-toast@1.2.23':
- resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-toggle-group@1.1.19':
- resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-toggle@1.1.18':
- resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-toolbar@1.1.19':
- resolution: {integrity: sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
'@radix-ui/react-tooltip@1.2.16':
resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==}
peerDependencies:
@@ -1640,15 +1532,6 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-escape-keydown@1.1.5':
- resolution: {integrity: sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
'@radix-ui/react-use-is-hydrated@0.1.3':
resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==}
peerDependencies:
@@ -1667,8 +1550,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-previous@1.1.4':
- resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==}
+ '@radix-ui/react-use-rect@1.1.4':
+ resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1676,8 +1559,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-rect@1.1.4':
- resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
+ '@radix-ui/react-use-size@1.1.4':
+ resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1685,8 +1568,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-size@1.1.4':
- resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==}
+ '@radix-ui/react-use-size@1.1.5':
+ resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1710,19 +1593,6 @@ packages:
'@radix-ui/rect@1.1.3':
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
- '@radix-ui/themes@3.3.0':
- resolution: {integrity: sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: 16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: 16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -2913,9 +2783,6 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
- html-parse-stringify@4.0.1:
- resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
-
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -2935,14 +2802,6 @@ packages:
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
- i18next@26.4.2:
- resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==}
- peerDependencies:
- typescript: ^5 || ^6 || ^7
- peerDependenciesMeta:
- typescript:
- optional: true
-
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -3611,19 +3470,6 @@ packages:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
- radix-ui@1.6.7:
- resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -3643,22 +3489,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
- react-i18next@17.0.15:
- resolution: {integrity: sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==}
- peerDependencies:
- i18next: '>= 26.2.0'
- react: '>= 16.8.0'
- react-dom: '*'
- react-native: '*'
- typescript: ^5 || ^6 || ^7
- peerDependenciesMeta:
- react-dom:
- optional: true
- react-native:
- optional: true
- typescript:
- optional: true
-
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -3762,9 +3592,6 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
- rtl-css-js@1.16.1:
- resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
-
run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
@@ -3932,9 +3759,6 @@ packages:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
- stylis@4.4.0:
- resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
-
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@@ -4133,11 +3957,6 @@ packages:
'@types/react':
optional: true
- use-sync-external-store@1.7.0:
- resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -4562,8 +4381,6 @@ snapshots:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.29.7
- '@babel/runtime@7.29.7': {}
-
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -4619,8 +4436,6 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
- '@emotion/hash@0.9.2': {}
-
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -4752,12 +4567,6 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
- '@fluentui/react-icons@2.0.343(react@19.2.8)':
- dependencies:
- '@griffel/react': 1.7.8(react@19.2.8)
- react: 19.2.8
- tslib: 2.8.1
-
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
@@ -4784,25 +4593,6 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@griffel/core@1.21.4':
- dependencies:
- '@emotion/hash': 0.9.2
- '@griffel/style-types': 1.4.3
- csstype: 3.2.3
- rtl-css-js: 1.16.1
- stylis: 4.4.0
- tslib: 2.8.1
-
- '@griffel/react@1.7.8(react@19.2.8)':
- dependencies:
- '@griffel/core': 1.21.4
- react: 19.2.8
- tslib: 2.8.1
-
- '@griffel/style-types@1.4.3':
- dependencies:
- csstype: 3.2.3
-
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.86.0(react@19.2.8))(zod@4.4.3)':
dependencies:
'@standard-schema/utils': 0.3.0
@@ -4990,44 +4780,25 @@ snapshots:
- react-native-b4a
- supports-color
- '@radix-ui/colors@3.0.0': {}
-
'@radix-ui/number@1.1.3': {}
'@radix-ui/primitive@1.1.7': {}
- '@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
- '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
@@ -5035,9 +4806,9 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-arrow@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
@@ -5067,43 +4838,40 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-collection@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
@@ -5116,12 +4884,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-context-menu@2.3.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
@@ -5164,6 +4932,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
+ '@radix-ui/react-direction@1.1.5(@types/react@19.2.18)(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+ optionalDependencies:
+ '@types/react': 19.2.18
+
'@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
@@ -5177,6 +4951,19 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
+ '@radix-ui/react-dismissable-layer@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.7
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.4(@types/react@19.2.18)
+
'@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
@@ -5209,30 +4996,28 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-focus-scope@1.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-hover-card@1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
@@ -5282,126 +5067,113 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-menu@2.1.25(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ aria-hidden: 1.2.6
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
+ react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ aria-hidden: 1.2.6
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
+ react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/number': 1.1.3
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/rect': 1.1.3
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-popper@1.3.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/primitive': 1.1.7
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-arrow': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/rect': 1.1.3
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- aria-hidden: 1.2.6
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-portal@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/rect': 1.1.3
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
@@ -5409,7 +5181,7 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-presence@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
@@ -5427,27 +5199,9 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-primitive@2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
- '@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
@@ -5473,16 +5227,18 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-roving-focus@1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
- '@radix-ui/number': 1.1.3
'@radix-ui/primitive': 1.1.7
+ '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
@@ -5490,32 +5246,19 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/number': 1.1.3
'@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- aria-hidden: 1.2.6
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
@@ -5529,25 +5272,6 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/number': 1.1.3
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
'@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
@@ -5555,19 +5279,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- '@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@radix-ui/react-slot@1.4.0(@types/react@19.2.18)(react@19.2.8)':
dependencies:
- '@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
'@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
@@ -5585,67 +5302,6 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
- '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
- '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
- '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
- '@radix-ui/react-toolbar@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
'@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
@@ -5689,13 +5345,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- '@radix-ui/react-use-escape-keydown@1.1.5(@types/react@19.2.18)(react@19.2.8)':
- dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- react: 19.2.8
- optionalDependencies:
- '@types/react': 19.2.18
-
'@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
@@ -5708,20 +5357,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)':
+ '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
+ '@radix-ui/rect': 1.1.3
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
- '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)':
+ '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
- '@radix-ui/rect': 1.1.3
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
- '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)':
+ '@radix-ui/react-use-size@1.1.5(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
@@ -5739,18 +5389,6 @@ snapshots:
'@radix-ui/rect@1.1.3': {}
- '@radix-ui/themes@3.3.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
- dependencies:
- '@radix-ui/colors': 3.0.0
- classnames: 2.5.1
- radix-ui: 1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.62.4':
@@ -6969,8 +6607,6 @@ snapshots:
html-escaper@2.0.2: {}
- html-parse-stringify@4.0.1: {}
-
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -6997,10 +6633,6 @@ snapshots:
hyphen@1.14.1: {}
- i18next@26.4.2(typescript@5.9.3):
- optionalDependencies:
- typescript: 5.9.3
-
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -7641,69 +7273,6 @@ snapshots:
es-define-property: 1.0.1
side-channel: 1.1.1
- radix-ui@1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
- dependencies:
- '@radix-ui/primitive': 1.1.7
- '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-menubar': 1.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-select': 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-slider': 1.4.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-switch': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-toast': 1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
- '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
- react: 19.2.8
- react-dom: 19.2.8(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
- '@types/react-dom': 19.2.4(@types/react@19.2.18)
-
range-parser@1.2.1: {}
raw-body@2.5.3:
@@ -7722,17 +7291,6 @@ snapshots:
dependencies:
react: 19.2.8
- react-i18next@17.0.15(i18next@26.4.2(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
- dependencies:
- '@babel/runtime': 7.29.7
- html-parse-stringify: 4.0.1
- i18next: 26.4.2(typescript@5.9.3)
- react: 19.2.8
- use-sync-external-store: 1.7.0(react@19.2.8)
- optionalDependencies:
- react-dom: 19.2.8(react@19.2.8)
- typescript: 5.9.3
-
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8):
@@ -7851,10 +7409,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.4
fsevents: 2.3.3
- rtl-css-js@1.16.1:
- dependencies:
- '@babel/runtime': 7.29.7
-
run-async@2.4.1: {}
rxjs@6.6.7:
@@ -8035,8 +7589,6 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
- stylis@4.4.0: {}
-
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
@@ -8230,10 +7782,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- use-sync-external-store@1.7.0(react@19.2.8):
- dependencies:
- react: 19.2.8
-
util-deprecate@1.0.2: {}
utils-merge@1.0.1: {}
diff --git a/frontend/src/atoms/theme.ts b/frontend/src/atoms/theme.ts
index 0c7a0ed..e3746ef 100644
--- a/frontend/src/atoms/theme.ts
+++ b/frontend/src/atoms/theme.ts
@@ -16,18 +16,6 @@ function readStoredPreference(): ThemePreference {
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system';
}
-/** 主题偏好,初值取 localStorage */
-export const themeAtom = atom(readStoredPreference());
-
-/** 解析后的实际主题,供 UI 显示当前态 */
-export const resolvedThemeAtom = atom<'light' | 'dark'>(
- readStoredPreference() === 'dark' || (readStoredPreference() === 'system' && systemPrefersDark())
- ? 'dark'
- : 'light'
-);
-
-const store = getDefaultStore();
-
/** 把解析后的主题写到 */
function applyTheme(resolved: 'light' | 'dark') {
document.documentElement.classList.toggle('dark', resolved === 'dark');
@@ -43,17 +31,42 @@ function withTransition(fn: () => void) {
window.setTimeout(() => root.classList.remove('theme-transitioning'), 320);
}
+function resolve(preference: ThemePreference): 'light' | 'dark' {
+ return preference === 'dark' || (preference === 'system' && systemPrefersDark())
+ ? 'dark'
+ : 'light';
+}
+
+const themePreferenceBaseAtom = atom(readStoredPreference());
+
+/** 解析后的实际主题 */
+export const resolvedThemeAtom = atom<'light' | 'dark'>(
+ readStoredPreference() === 'dark' || (readStoredPreference() === 'system' && systemPrefersDark())
+ ? 'dark'
+ : 'light'
+);
+
+export const themeAtom = atom(
+ (get) => get(themePreferenceBaseAtom),
+ (_get, set, preference: ThemePreference) => {
+ localStorage.setItem(STORAGE_KEY, preference);
+ set(themePreferenceBaseAtom, preference);
+ const next = resolve(preference);
+ withTransition(() => {
+ applyTheme(next);
+ set(resolvedThemeAtom, next);
+ });
+ }
+);
+
+const store = getDefaultStore();
+
// 模块加载即应用一次
applyTheme(store.get(resolvedThemeAtom));
/** 切换主题偏好 */
export function setTheme(preference: ThemePreference) {
- localStorage.setItem(STORAGE_KEY, preference);
- const resolved: 'light' | 'dark' =
- preference === 'dark' || (preference === 'system' && systemPrefersDark()) ? 'dark' : 'light';
store.set(themeAtom, preference);
- store.set(resolvedThemeAtom, resolved);
- withTransition(() => applyTheme(resolved));
}
/** 系统偏好变化时调用 */
diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx
index 1244aca..464e130 100644
--- a/frontend/src/components/UserMenu.tsx
+++ b/frontend/src/components/UserMenu.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
-import { useAtomValue } from 'jotai';
+import { useAtom } from 'jotai';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
@@ -27,7 +27,7 @@ import {
User,
} from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
-import { themeAtom, setTheme, type ThemePreference } from '@/atoms/theme';
+import { themeAtom, type ThemePreference } from '@/atoms/theme';
import { preloadRoute } from '@/routes';
/**
@@ -37,7 +37,7 @@ import { preloadRoute } from '@/routes';
*/
export function UserMenu() {
const { user, logout } = useAuth();
- const theme = useAtomValue(themeAtom);
+ const [theme, setThemePreference] = useAtom(themeAtom);
const [open, setOpen] = useState(false);
const closeTimer = useRef(0);
@@ -117,7 +117,7 @@ export function UserMenu() {
>
setTheme(v as ThemePreference)}
+ onValueChange={(v) => setThemePreference(v as ThemePreference)}
>
diff --git a/frontend/src/components/review/AudioSourcePicker.tsx b/frontend/src/components/review/AudioSourcePicker.tsx
new file mode 100644
index 0000000..9fca780
--- /dev/null
+++ b/frontend/src/components/review/AudioSourcePicker.tsx
@@ -0,0 +1,92 @@
+import { Music } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Radio, RadioGroup } from '@/components/ui/radio-group';
+
+export interface AudioSourceOption {
+ key: string;
+ src: string;
+ label: string;
+}
+
+interface AudioSourcePickerProps {
+ /** 可选音频源 */
+ sources: AudioSourceOption[];
+ /** 当前选中的索引 */
+ value: number;
+ /** 用户确认选择后回调 */
+ onConfirm: (index: number) => void;
+}
+
+/** 音频源选择弹窗。*/
+export function AudioSourcePicker({ sources, value, onConfirm }: AudioSourcePickerProps) {
+ const [open, setOpen] = useState(false);
+ const [picked, setPicked] = useState(value);
+ // 只在本次会话首次出现多来源时自动弹出一次;之后由顶栏下拉切换,不再打扰。
+ const autoPromptedRef = useRef(false);
+
+ useEffect(() => {
+ if (autoPromptedRef.current) return;
+ if (sources.length > 1) {
+ autoPromptedRef.current = true;
+ setPicked(value);
+ setOpen(true);
+ }
+ }, [sources, value]);
+
+ if (sources.length <= 1) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/review/EditorTopMenu.tsx b/frontend/src/components/review/EditorTopMenu.tsx
index 6496104..b511797 100644
--- a/frontend/src/components/review/EditorTopMenu.tsx
+++ b/frontend/src/components/review/EditorTopMenu.tsx
@@ -1,8 +1,16 @@
-import { DropdownMenu } from '@radix-ui/themes';
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
+import { type ComponentProps, useCallback } from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import { segmentLyricLines } from '$/modules/segmentation/utils/segmentation';
import { useSegmentationConfig } from '$/modules/segmentation/utils/useSegmentationConfig';
import {
@@ -33,8 +41,23 @@ const canRedoAtom = atom((get) => get(undoableLyricLinesAtom).canRedo);
const triggerClass =
'rounded px-2 py-1 text-xs font-medium text-ink-2 transition-colors hover:bg-surface-2 hover:text-ink';
+/** 菜单项:在右侧展示快捷键 */
+function MenuItem({
+ shortcut,
+ children,
+ ...props
+}: ComponentProps & { shortcut?: string }) {
+ return (
+
+ {children}
+ {shortcut ? (
+ {shortcut}
+ ) : null}
+
+ );
+}
+
function EditMenu() {
- const { t } = useTranslation();
const store = useStore();
const setUndo = useSetAtom(undoLyricLinesAtom);
const setRedo = useSetAtom(redoLyricLinesAtom);
@@ -67,52 +90,51 @@ function EditMenu() {
}, [store]);
return (
-
-
+
+
-
-
-
+
+
-
+
-
-
- {t('topBar.menu.selectAllLines', '选中所有歌词行')}
-
-
- {t('topBar.menu.unselectAllLines', '取消选中所有歌词行')}
-
-
- {t('topBar.menu.invertSelectAllLines', '反选所有歌词行')}
-
-
-
+ 重做
+
+
+
+
+
+
+
);
}
/* 工具 */
function ToolMenu() {
- const { t } = useTranslation();
- const store = useStore();
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
const { config: segmentationConfig } = useSegmentationConfig();
const [, setConfirmDialog] = useAtom(confirmDialogAtom);
const setTimeShiftDialog = useSetAtom(timeShiftDialogAtom);
const setAdvancedSegmentation = useSetAtom(advancedSegmentationDialogAtom);
+ const setReduceStutter = useSetAtom(reduceStutterDialogAtom);
const onAutoSegment = useCallback(() => {
editLyricLines((draft) => {
draft.lyricLines = segmentLyricLines(draft.lyricLines, segmentationConfig);
@@ -151,58 +173,46 @@ function ToolMenu() {
setConfirmDialog({
open: true,
- title: t('confirmDialog.syncLineTimestamps.title', '确认同步行时间戳'),
- description: t(
- 'confirmDialog.syncLineTimestamps.description',
- '此操作将根据每行单词的时间戳自动同步所有行的起始和结束时间为第一个和最后一个音节的开始和结束时间。确定要继续吗?'
- ),
+ title: '确认同步行时间戳',
+ description:
+ '此操作将根据每行单词的时间戳自动同步所有行的起始和结束时间为第一个和最后一个音节的开始和结束时间。确定要继续吗?',
onConfirm: action,
});
- }, [editLyricLines, setConfirmDialog, t]);
+ }, [editLyricLines, setConfirmDialog]);
const onReduceStutter = useCallback(() => {
- store.set(reduceStutterDialogAtom, { open: true });
- }, [store]);
+ setReduceStutter({ open: true });
+ }, [setReduceStutter]);
return (
-
-
+
+
-
-
-
-
- {t('topBar.menu.segmentationTools', '分词')}
-
-
-
- {t('topBar.menu.autoSegment', '自动分词')}
-
- setAdvancedSegmentation(true)}>
- {t('topBar.menu.advancedSegment', '高级分词...')}
-
-
-
-
-
- {t('topBar.menu.timestampTools.index', '时间戳')}
-
-
-
- {t('topBar.menu.timestampTools.syncLineTimestamps', '同步行时间戳')}
-
-
- {t('topBar.menu.timestampTools.reduceStutter', '消减卡顿')}
-
- setTimeShiftDialog(true)}>
- {t('topBar.menu.timeShift', '平移时间...')}
-
-
-
-
-
+
+
+
+ 分词
+
+ 自动分词
+ setAdvancedSegmentation(true)}>
+ 高级分词...
+
+
+
+
+ 时间戳
+
+ 同步行时间戳
+ 消减卡顿
+ setTimeShiftDialog(true)}>
+ 平移时间...
+
+
+
+
+
);
}
diff --git a/frontend/src/components/review/EditorWorkbench.tsx b/frontend/src/components/review/EditorWorkbench.tsx
index d96f619..17d8892 100644
--- a/frontend/src/components/review/EditorWorkbench.tsx
+++ b/frontend/src/components/review/EditorWorkbench.tsx
@@ -1,4 +1,3 @@
-import { Theme } from '@radix-ui/themes';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtomValue, useSetAtom } from 'jotai';
import { lazy, Suspense, useEffect, type ReactNode } from 'react';
@@ -12,11 +11,12 @@ import { AutosaveManager } from '@/editor/modules/project/autosave/AutosaveManag
import { showTouchSyncPanelAtom } from '@/editor/modules/settings/states/sync.ts';
import { ToolMode, toolModeAtom } from '@/editor/states/main.ts';
import { MetadataEditor } from '@/editor/modules/project/modals/MetadataEditor.tsx';
+import { AdvancedSegmentationDialog } from '@/editor/modules/segmentation/components/AdvancedSegmentation.tsx';
+import { TimeShiftDialog } from '@/editor/modules/lyric-editor/tools/TimeShift.tsx';
+import { ConfirmDialog } from '@/editor/components/ConfirmDialog.tsx';
+import { ReduceStutterDialog } from '@/editor/components/ReduceStutterDialog.tsx';
import { useMediaSession } from '@/editor/modules/audio/hooks/useMediaSession.ts';
import { useAudioFeedback } from '@/editor/modules/audio/hooks/useAudioFeedback.ts';
-import { resolvedThemeAtom } from '@/atoms/theme';
-import '@/editor/i18n';
-import '@radix-ui/themes/styles.css';
import './editor-theme.css';
const LyricLinesView = lazy(() => import('@/editor/modules/lyric-editor/components'));
@@ -52,10 +52,11 @@ export function EditorWorkbench() {
>
-
+
+ {/* 侧边栏紧贴 RibbonBar:顶部的 Tab 选中指示条需要顶到内容区最上沿 */}
-
+
+ {/* 顶栏「工具」菜单几个菜单项只是置位 atom,缺了这些渲染者就会点了没反应 */}
+
+
+
+
);
}
-/**
- * 编辑器的 Radix Theme 根。
- *
- * 导出让 `LyricEditorPage` 能把它提到页面顶层,从而把顶栏也包进来 ——
- * 否则顶栏右侧的 `EditorMenuBar`(Radix `DropdownMenu`)在 Theme 外,
- * 样式会和工具栏里的其他 Radix 组件不一致。
- */
export function EditorTheme({ children }: { children: ReactNode }) {
- const resolvedTheme = useAtomValue(resolvedThemeAtom);
- const isDark = resolvedTheme === 'dark';
-
- return (
-
- {children}
-
- );
+ return <>{children}>;
}
diff --git a/frontend/src/components/review/LyricEditorHeader.tsx b/frontend/src/components/review/LyricEditorHeader.tsx
index b3d3a9f..88f1ea3 100644
--- a/frontend/src/components/review/LyricEditorHeader.tsx
+++ b/frontend/src/components/review/LyricEditorHeader.tsx
@@ -1,9 +1,16 @@
import { useAtom } from 'jotai';
-import { ArrowLeft, Loader2, Save, Tags } from 'lucide-react';
+import { ArrowLeft, Check, ChevronDown, Loader2, Music, Save, Tags } from 'lucide-react';
import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import { ToolMode, toolModeAtom } from '@/editor/states/main.ts';
import { metadataEditorDialogAtom } from '@/editor/states/dialogs.ts';
import { EditorMenuBar } from '@/components/review/EditorTopMenu';
+import { UserMenu } from '@/components/UserMenu';
import { cn } from '@/lib/utils';
export interface EditorHeaderProps {
@@ -17,6 +24,59 @@ export interface EditorHeaderProps {
saving: boolean;
}
+/** 参考音频选择器 */
+function AudioSourceSelect({
+ sources,
+ audioIndex,
+ onAudioIndexChange,
+}: Pick) {
+ if (sources.length <= 1) return null;
+
+ const current = sources[audioIndex] ?? sources[0];
+
+ return (
+
+
+
+
+
+ {sources.map((source, index) => (
+ onAudioIndexChange(index)}
+ className="gap-2"
+ >
+
+
+ {source.label}
+
+
+ ))}
+
+
+ );
+}
+
function ToolModeSwitch() {
const [toolMode, setToolMode] = useAtom(toolModeAtom);
@@ -83,6 +143,11 @@ export function LyricEditorHeader({
+
{/* 分隔:菜单类操作与右侧的「元数据 / 提交」动作按钮区分开 */}
@@ -96,24 +161,11 @@ export function LyricEditorHeader({
元数据
- {sources.length > 1 && (
-
- )}
+
);
diff --git a/frontend/src/components/review/editor-theme.css b/frontend/src/components/review/editor-theme.css
index 69257bc..e911654 100644
--- a/frontend/src/components/review/editor-theme.css
+++ b/frontend/src/components/review/editor-theme.css
@@ -1,57 +1,4 @@
-.radix-themes.editor-radix-theme {
- height: 100%;
- min-height: 0;
-}
-
-body .rt-DialogOverlay {
- position: fixed !important;
- inset: 0 !important;
- min-height: 0 !important;
-}
-
-body .rt-DialogOverlay {
- --color-overlay: rgb(0 0 0 / 0.45);
-}
-
-body.editor-body:has(.dark),
-body.editor-body.dark {
- --color-overlay: rgb(0 0 0 / 0.6);
-}
-
-body .rt-DialogOverlay[data-has-background='true'] {
- background-color: transparent !important;
-}
-
-/* 兜底:不给data-has-background 也保持透明 */
-body .rt-DialogOverlay {
- background-color: transparent !important;
-}
-
-body .rt-DialogOverlay::before,
-body .rt-DialogOverlay:where([data-state='open'])::before {
- background-color: rgb(0 0 0 / 0.45) !important;
-}
-
-body.editor-body:has(.dark) .rt-DialogOverlay::before,
-body.editor-body.dark .rt-DialogOverlay::before,
-body.editor-body:has(.dark) .rt-DialogOverlay:where([data-state='open'])::before,
-body.editor-body.dark .rt-DialogOverlay:where([data-state='open'])::before {
- background-color: rgb(0 0 0 / 0.6) !important;
-}
-
-body .rt-DialogOverlay,
-body .rt-DialogScroll,
-body .rt-DialogContent {
- z-index: 400 !important;
-}
-
-body .rt-DialogContent .rt-ScrollAreaRoot {
- min-height: 0 !important;
- flex: 1 1 auto !important;
-}
-
-.radix-themes.editor-radix-theme,
-body.editor-body [data-accent-color] {
+body.editor-body {
--accent-1: #fff5f6;
--accent-2: #feeaec;
--accent-3: #fbdde0;
@@ -85,8 +32,8 @@ body.editor-body [data-accent-color] {
--accent-track: #fbdde0;
}
-.radix-themes.editor-radix-theme:where(.dark, .dark-theme),
-body.editor-body [data-accent-color]:where(.dark, .dark-theme) {
+body.editor-body:has(.dark),
+body.editor-body.dark {
--accent-1: #2a1418;
--accent-2: #3a1a20;
--accent-3: #4a2127;
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx
index 991fbf2..02c9d6b 100644
--- a/frontend/src/components/ui/checkbox.tsx
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -1,4 +1,4 @@
-import type { ComponentProps } from 'react';
+import { type ComponentProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
interface CheckboxProps extends Omit
, 'type' | 'checked' | 'onChange'> {
@@ -7,16 +7,27 @@ interface CheckboxProps extends Omit, 'type' | 'checked'
}
function Checkbox({ className, checked = false, onCheckedChange, ...props }: CheckboxProps) {
- const isChecked = checked === true || checked === 'indeterminate';
+ const ref = useRef(null);
+ const isIndeterminate = checked === 'indeterminate';
+ const isChecked = checked === true || isIndeterminate;
+
+ // 原生 input 的 indeterminate 只能通过 DOM 属性设置,不能由 HTML 属性表达
+ useEffect(() => {
+ if (ref.current) ref.current.indeterminate = isIndeterminate;
+ }, [isIndeterminate]);
+
return (
onCheckedChange?.(e.target.checked)}
className={cn(
'size-4 shrink-0 cursor-pointer appearance-none rounded border border-input bg-card transition-colors',
'checked:border-primary checked:bg-primary',
+ 'indeterminate:border-primary indeterminate:bg-primary',
'checked:bg-[url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2016%2016%27%20fill%3D%27white%27%3E%3Cpath%20d%3D%27M13.485%201.07L6.5%208.055%202.515%204.07%201%205.585l1.515%201.515L6.5%2011.385l5.985-5.985z%27%2F%3E%3C%2Fsvg%3E")] checked:bg-center checked:bg-no-repeat',
+ 'indeterminate:bg-[url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2016%2016%27%20fill%3D%27white%27%3E%3Crect%20x%3D%274%27%20y%3D%277%27%20width%3D%278%27%20height%3D%272%27%20rx%3D%271%27%2F%3E%3C%2Fsvg%3E")] indeterminate:bg-center indeterminate:bg-no-repeat',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
'disabled:cursor-not-allowed disabled:opacity-50',
className
diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx
new file mode 100644
index 0000000..2c485d0
--- /dev/null
+++ b/frontend/src/components/ui/context-menu.tsx
@@ -0,0 +1,103 @@
+import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
+import type { ComponentProps } from 'react';
+import { Check } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const ContextMenu = ContextMenuPrimitive.Root;
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
+const ContextMenuGroup = ContextMenuPrimitive.Group;
+
+function ContextMenuContent({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function ContextMenuItem({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+function ContextMenuLabel({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+function ContextMenuSeparator({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuLabel,
+ ContextMenuGroup,
+ ContextMenuSeparator,
+};
diff --git a/frontend/src/components/ui/hover-card.tsx b/frontend/src/components/ui/hover-card.tsx
new file mode 100644
index 0000000..4dea357
--- /dev/null
+++ b/frontend/src/components/ui/hover-card.tsx
@@ -0,0 +1,30 @@
+import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
+import type { ComponentProps } from 'react';
+import { cn } from '@/lib/utils';
+
+const HoverCard = HoverCardPrimitive.Root;
+const HoverCardTrigger = HoverCardPrimitive.Trigger;
+
+function HoverCardContent({
+ className,
+ align = 'center',
+ sideOffset = 6,
+ ...props
+}: ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent };
diff --git a/frontend/src/components/ui/kbd.tsx b/frontend/src/components/ui/kbd.tsx
new file mode 100644
index 0000000..fe93444
--- /dev/null
+++ b/frontend/src/components/ui/kbd.tsx
@@ -0,0 +1,18 @@
+import type { ComponentProps } from 'react';
+import { cn } from '@/lib/utils';
+
+/** 键位徽标:用于展示快捷键(如打轴键位速查) */
+function Kbd({ className, ...props }: ComponentProps<'kbd'>) {
+ return (
+
+ );
+}
+
+export { Kbd };
diff --git a/frontend/src/components/ui/radio-group.tsx b/frontend/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..ec8f2e1
--- /dev/null
+++ b/frontend/src/components/ui/radio-group.tsx
@@ -0,0 +1,25 @@
+import type { ComponentProps } from 'react';
+import { cn } from '@/lib/utils';
+
+function RadioGroup({ className, ...props }: ComponentProps<'div'>) {
+ return ;
+}
+
+/** 单选框:基于原生 input[type=radio] */
+function Radio({ className, ...props }: Omit, 'type'>) {
+ return (
+
+ );
+}
+
+export { Radio, RadioGroup };
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..d8bb5dc
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,19 @@
+import type { ComponentProps } from 'react';
+import { cn } from '@/lib/utils';
+
+/** 下拉选择 */
+function Select({ className, ...props }: ComponentProps<'select'>) {
+ return (
+
+ );
+}
+
+export { Select };
diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx
new file mode 100644
index 0000000..1588321
--- /dev/null
+++ b/frontend/src/components/ui/slider.tsx
@@ -0,0 +1,83 @@
+import type { ComponentProps, CSSProperties } from 'react';
+import { cn } from '@/lib/utils';
+
+interface SliderProps extends Omit, 'type' | 'value' | 'onChange'> {
+ value: number;
+ onValueChange?: (value: number) => void;
+ orientation?: 'horizontal' | 'vertical';
+ trackColor?: string;
+}
+
+/** 轨道粗细 */
+const TRACK_THICKNESS = 6;
+/** 滑块圆点直径 */
+const THUMB_SIZE = 12;
+
+function Slider({
+ className,
+ value,
+ onValueChange,
+ orientation = 'horizontal',
+ trackColor,
+ style,
+ ...props
+}: SliderProps) {
+ const isVertical = orientation === 'vertical';
+ const color = trackColor ?? 'var(--amll-muted)';
+
+ const inset = THUMB_SIZE / 2;
+ const trackStyle: CSSProperties = isVertical
+ ? {
+ top: `${inset}px`,
+ bottom: `${inset}px`,
+ left: '50%',
+ width: `${TRACK_THICKNESS}px`,
+ transform: 'translateX(-50%)',
+ background: color,
+ }
+ : {
+ left: `${inset}px`,
+ right: `${inset}px`,
+ top: '50%',
+ height: `${TRACK_THICKNESS}px`,
+ transform: 'translateY(-50%)',
+ background: color,
+ };
+
+ return (
+
+
+ onValueChange?.(e.target.valueAsNumber)}
+ className={cn(
+ // input 铺满外层容器
+ 'absolute inset-0 size-full bg-transparent',
+ isVertical && '[writing-mode:vertical-lr] [direction:rtl]',
+ 'cursor-pointer appearance-none accent-primary',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
+ 'disabled:cursor-not-allowed disabled:opacity-50',
+ // 滑块头
+ '[&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow',
+ '[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-primary'
+ )}
+ {...props}
+ />
+
+ );
+}
+
+export { Slider };
diff --git a/frontend/src/editor/components/ConfirmDialog.tsx b/frontend/src/editor/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..0fea8ab
--- /dev/null
+++ b/frontend/src/editor/components/ConfirmDialog.tsx
@@ -0,0 +1,64 @@
+import { useAtom } from 'jotai';
+import { useRef } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { confirmDialogAtom } from '$/states/dialogs.ts';
+
+export function ConfirmDialog() {
+ const [state, setState] = useAtom(confirmDialogAtom);
+ /** 标记本次关闭是「确认」触发的,避免同时回调 `onCancel` */
+ const confirmedRef = useRef(false);
+
+ const close = () => setState((prev) => ({ ...prev, open: false }));
+
+ const handleConfirm = () => {
+ const run = state.onConfirm;
+ confirmedRef.current = true;
+ // 先收起对话框再执行,与原生确认框一致,避免执行期间 UI 卡在弹窗上
+ close();
+ // 可能是异步任务(打开文件 / 恢复历史),错误由调用方自行处理
+ void run?.();
+ };
+
+ const handleCancel = () => {
+ state.onCancel?.();
+ close();
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) return;
+ if (confirmedRef.current) {
+ confirmedRef.current = false;
+ return;
+ }
+ handleCancel();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/editor/components/KeyBinding/index.tsx b/frontend/src/editor/components/KeyBinding/index.tsx
index d199c69..3787ace 100644
--- a/frontend/src/editor/components/KeyBinding/index.tsx
+++ b/frontend/src/editor/components/KeyBinding/index.tsx
@@ -1,6 +1,6 @@
-import { Kbd } from '@radix-ui/themes';
import { useAtomValue } from 'jotai';
import type { FC } from 'react';
+import { Kbd } from '@/components/ui/kbd';
import { formatKeyBindings, type useKeyBindingAtom } from '$/utils/keybindings.ts';
/**
diff --git a/frontend/src/editor/components/MasterDetail/index.tsx b/frontend/src/editor/components/MasterDetail/index.tsx
index a5479a0..7f0945b 100644
--- a/frontend/src/editor/components/MasterDetail/index.tsx
+++ b/frontend/src/editor/components/MasterDetail/index.tsx
@@ -1,6 +1,6 @@
-import { DocumentRegular } from '@fluentui/react-icons';
-import { Box, Flex, Heading, ScrollArea, Text } from '@radix-ui/themes';
+import { FileText } from 'lucide-react';
import type { ReactNode } from 'react';
+import { ScrollArea } from '@/components/ui/scroll-area';
import styles from './index.module.css';
/**
@@ -14,25 +14,23 @@ export const MasterDetailLayout = ({
master: ReactNode;
detail: ReactNode;
}) => (
-
+
{master}
-
- {detail}
-
-
+
{detail}
+
);
export const MasterColumn = ({ title, children }: { title: ReactNode; children: ReactNode }) => (
-
-
- {title}
-
-
-
- {children}
+
);
export const MasterListItem = ({
@@ -44,7 +42,7 @@ export const MasterListItem = ({
onSelect: () => void;
children: ReactNode;
}) => (
-
{children}
-
+
);
export const MasterListEmpty = ({ children }: { children: ReactNode }) => (
-
-
- {children}
-
-
+
);
export const DetailPlaceholder = ({ children }: { children: ReactNode }) => (
-
-
- {children}
-
+
);
diff --git a/frontend/src/editor/components/Menus/lyric-line-menu.tsx b/frontend/src/editor/components/Menus/lyric-line-menu.tsx
index 08398b4..fad547e 100644
--- a/frontend/src/editor/components/Menus/lyric-line-menu.tsx
+++ b/frontend/src/editor/components/Menus/lyric-line-menu.tsx
@@ -1,16 +1,17 @@
-import { ContextMenu } from '@radix-ui/themes';
import { atom, useAtomValue } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
import * as React from 'react';
-import { useTranslation } from 'react-i18next';
+import {
+ ContextMenuCheckboxItem,
+ ContextMenuItem,
+ ContextMenuSeparator,
+} from '@/components/ui/context-menu';
import { lyricLinesAtom, selectedLinesAtom } from '$/states/main';
import { type LyricLine, newLyricLine, newLyricWord } from '$/types/ttml';
const selectedLinesSizeAtom = atom((get) => get(selectedLinesAtom).size);
export const LyricLineMenu = ({ lineIndex }: { lineIndex: number }) => {
- const { t } = useTranslation();
-
const selectedLinesSize = useAtomValue(selectedLinesSizeAtom);
const selectedLines = useAtomValue(selectedLinesAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
@@ -56,41 +57,38 @@ export const LyricLineMenu = ({ lineIndex }: { lineIndex: number }) => {
return (
<>
-
- {t('contextMenu.bgLyric', '背景歌词')}
-
-
- {t('contextMenu.duetLyric', '对唱歌词')}
-
-
-
+ 背景歌词
+
+
+ 对唱歌词
+
+
+ {
editLyricLines((state) => {
state.lyricLines.splice(lineIndex, 0, newLyricLine());
});
}}
>
- {t('contextMenu.insertLineBefore', '在前插入空行')}
-
-
+ {
editLyricLines((state) => {
state.lyricLines.splice(lineIndex + 1, 0, newLyricLine());
});
}}
>
- {t('contextMenu.insertLineAfter', '在后插入空行')}
-
-
- {t('contextMenu.copyLine', {
- count: selectedLinesSize,
- defaultValue: '复制行',
- })}
-
-
- {t('contextMenu.combineLine', '合并行')}
-
-
+
+ 复制行
+
+
+ 合并行
+
+ {
editLyricLines((state) => {
if (selectedLinesSize === 0) {
@@ -101,11 +99,8 @@ export const LyricLineMenu = ({ lineIndex }: { lineIndex: number }) => {
});
}}
>
- {t('contextMenu.deleteLine', {
- count: selectedLinesSize,
- defaultValue: '删除行',
- })}
-
+ 删除行
+
>
);
diff --git a/frontend/src/editor/components/ReduceStutterDialog.tsx b/frontend/src/editor/components/ReduceStutterDialog.tsx
new file mode 100644
index 0000000..8dfe179
--- /dev/null
+++ b/frontend/src/editor/components/ReduceStutterDialog.tsx
@@ -0,0 +1,181 @@
+import { useAtom, useAtomValue } from 'jotai';
+import { useSetImmerAtom } from 'jotai-immer';
+import { Play } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { msToTimestamp } from '@/lib/format';
+import { audioEngine } from '$/modules/audio/audio-engine';
+import { reduceStutterDialogAtom } from '$/states/dialogs.ts';
+import { lyricLinesAtom } from '$/states/main.ts';
+import type { LyricWord } from '$/types/ttml';
+
+/** 音节间隔小于该值(ms)视为卡顿 */
+const STUTTER_GAP_THRESHOLD = 100;
+
+interface StutterPair {
+ lineIndex: number;
+ wordIndex: number;
+ nextWordIndex: number;
+ prevWord: LyricWord;
+ nextWord: LyricWord;
+ gap: number;
+}
+
+export function ReduceStutterDialog() {
+ const [open, setOpen] = useAtom(reduceStutterDialogAtom);
+ const lyricLines = useAtomValue(lyricLinesAtom);
+ const setLyricLines = useSetImmerAtom(lyricLinesAtom);
+ const [selectedPairs, setSelectedPairs] = useState
>(new Set());
+
+ const stutterPairs = useMemo(() => {
+ const pairs: StutterPair[] = [];
+ lyricLines.lyricLines.forEach((line, lineIndex) => {
+ // 过滤掉纯空白音节,只比较有实际内容的音节
+ const nonSpaceWords = line.words.filter((w) => w.word.trim().length > 0);
+ for (let i = 0; i < nonSpaceWords.length - 1; i++) {
+ const currentWord = nonSpaceWords[i];
+ const nextWord = nonSpaceWords[i + 1];
+ if (!currentWord || !nextWord) continue;
+
+ const gap = nextWord.startTime - currentWord.endTime;
+ if (gap > 0 && gap < STUTTER_GAP_THRESHOLD) {
+ const wordIndex = line.words.findIndex((w) => w.id === currentWord.id);
+ const nextWordIndex = line.words.findIndex((w) => w.id === nextWord.id);
+ pairs.push({
+ lineIndex,
+ wordIndex,
+ nextWordIndex,
+ prevWord: currentWord,
+ nextWord,
+ gap,
+ });
+ }
+ }
+ });
+ return pairs;
+ }, [lyricLines.lyricLines]);
+
+ // 打开时默认全选所有卡顿项
+ useEffect(() => {
+ if (open.open) {
+ setSelectedPairs(new Set(stutterPairs.map((_, index) => index.toString())));
+ }
+ }, [open.open, stutterPairs]);
+
+ const togglePair = (index: string) => {
+ setSelectedPairs((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) {
+ next.delete(index);
+ } else {
+ next.add(index);
+ }
+ return next;
+ });
+ };
+
+ const handleConfirm = () => {
+ const selectedIndices = Array.from(selectedPairs).map((id) => parseInt(id, 10));
+
+ setLyricLines((draft) => {
+ selectedIndices.forEach((pairIndex) => {
+ const pair = stutterPairs[pairIndex];
+ if (!pair) return;
+
+ const line = draft.lyricLines[pair.lineIndex];
+ if (!line) return;
+
+ const prevWord = line.words[pair.wordIndex];
+ const nextWord = line.words[pair.nextWordIndex];
+ if (!prevWord || !nextWord) return;
+
+ // 把两个音节的边界对齐到中间时间点
+ const middleTime = Math.round((prevWord.endTime + nextWord.startTime) / 2);
+ prevWord.endTime = middleTime;
+ nextWord.startTime = middleTime;
+ });
+ });
+
+ setOpen({ open: false });
+ };
+
+ const handleClose = () => setOpen({ open: false });
+
+ const handlePlay = (pair: StutterPair) => {
+ // 播放前一个音节开始前 100ms 到后一个音节结束后 100ms
+ const startTime = Math.max(0, pair.prevWord.startTime - 100) / 1000;
+ const endTime = (pair.nextWord.endTime + 100) / 1000;
+ audioEngine.auditionRange(startTime, endTime);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/editor/components/RibbonBar/common.tsx b/frontend/src/editor/components/RibbonBar/common.tsx
index d20b911..6c596cd 100644
--- a/frontend/src/editor/components/RibbonBar/common.tsx
+++ b/frontend/src/editor/components/RibbonBar/common.tsx
@@ -9,7 +9,6 @@
* https://github.com/amll-dev/amll-ttml-tool/blob/main/LICENSE
*/
-import { Flex, Separator, Text } from '@radix-ui/themes';
import { motion } from 'framer-motion';
import {
type FC,
@@ -22,26 +21,11 @@ import {
export const RibbonSection: FC> = ({ children, label }) => (
<>
-
-
- {children}
-
-
- {label}
-
-
-
+
+
>
);
@@ -59,28 +43,16 @@ export const RibbonFrame = forwardRef(({ chil
}, []);
return (
-
-
- {children}
-
-
+ {children}
+
);
});
diff --git a/frontend/src/editor/components/RibbonBar/edit-mode.tsx b/frontend/src/editor/components/RibbonBar/edit-mode.tsx
index 8b76830..84b4ab1 100644
--- a/frontend/src/editor/components/RibbonBar/edit-mode.tsx
+++ b/frontend/src/editor/components/RibbonBar/edit-mode.tsx
@@ -9,22 +9,16 @@
* https://github.com/amll-dev/amll-ttml-tool/blob/main/LICENSE
*/
-import { Add16Regular } from '@fluentui/react-icons';
-import {
- Button,
- Checkbox,
- Flex,
- Grid,
- IconButton,
- RadioGroup,
- Select,
- Text,
- TextField,
- Tooltip,
-} from '@radix-ui/themes';
import type { Draft } from 'immer';
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
+import { Plus } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Radio, RadioGroup } from '@/components/ui/radio-group';
+import { Select } from '@/components/ui/select';
+import { cn } from '@/lib/utils';
import {
type FC,
forwardRef,
@@ -36,7 +30,6 @@ import {
useRef,
useState,
} from 'react';
-import { useTranslation } from 'react-i18next';
import { I18nEditor } from '$/modules/lyric-editor/tools/i18nEditor.tsx';
import {
LayoutMode,
@@ -89,14 +82,14 @@ function EditField<
const [fieldInput, setFieldInput] = useState(undefined);
const [fieldPlaceholder, setFieldPlaceholder] = useState('');
const [durationInputInvalid, setDurationInputInvalid] = useState(false);
- const [showDurationInput, setShowDurationInput] = useAtom(showEndTimeAsDurationAtom);
+ /** 时间字段固定按「结束时间」展示,不再提供「持续时间」切换 */
+ const showDurationInput = useAtomValue(showEndTimeAsDurationAtom);
const itemAtom = useMemo(
() => (isWordField ? selectedWordsAtom : selectedLinesAtom),
[isWordField]
);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
- const { t } = useTranslation();
const setEditingTimeField = useSetAtom(editingTimeFieldAtom);
const [requestFocus, setRequestFocus] = useAtom(requestFocusAtom);
@@ -388,7 +381,7 @@ function EditField<
if (fieldName === 'endTime' && showDurationInput) {
if (durationValue === MULTIPLE_VALUES) {
setFieldInput('');
- setFieldPlaceholder(t('ribbonBar.editMode.multipleValues', '多个值...'));
+ setFieldPlaceholder('多个值...');
return;
}
if (typeof durationValue === 'number') {
@@ -402,34 +395,22 @@ function EditField<
}
if (currentValue === MULTIPLE_VALUES) {
setFieldInput('');
- setFieldPlaceholder(t('ribbonBar.editMode.multipleValues', '多个值...'));
+ setFieldPlaceholder('多个值...');
return;
}
setFieldInput(currentValue);
setFieldPlaceholder('');
- }, [currentValue, durationValue, fieldName, showDurationInput, t]);
+ }, [currentValue, durationValue, fieldName, showDurationInput]);
+
+ const invalid = durationInputInvalid || hasError;
return (
<>
- {fieldName === 'endTime' ? (
-
- ) : (
-
- {label}
-
- )}
- {label}
+ atom((get) => get(itemAtom).size === 0), [itemAtom]);
const isDisabledBase = useAtomValue(isDisabledAtom);
- // 对于 isDuet 字段,检查选中的行是否设置了 agent
- const hasAgentAtom = useMemo(
- () =>
- atom((get) => {
- if (fieldName !== 'isDuet' || isWordField) return false;
- const selectedItems = get(itemAtom);
- const lyricLines = get(lyricLinesAtom);
- if (selectedItems.size === 0) return false;
- const selectedLines = selectedItems as Set;
- for (const line of lyricLines.lyricLines) {
- if (selectedLines.has(line.id)) {
- // 如果任何一个选中的行设置了 agent,则禁用 checkbox
- if (line.agent) {
- return true;
- }
- }
- }
- return false;
- }),
- [fieldName, isWordField, itemAtom]
- );
- const hasAgent = useAtomValue(hasAgentAtom);
-
- const isDisabled = isDisabledBase || hasAgent;
+ const isDisabled = isDisabledBase;
const checkboxId = useId();
return (
<>
-
-
-
+
{
- if (value === 'indeterminate') return;
editLyricLines((state: Draft) => {
const selectedItems = store.get(itemAtom);
for (const line of state.lyricLines) {
@@ -587,21 +544,28 @@ function CheckboxField<
function EditModeField({ simpleModeLabel = '简单模式', advanceModeLabel = '高级模式' }) {
const [layoutMode, setLayoutMode] = useAtom(layoutModeAtom);
+ const name = useId();
return (
- setLayoutMode(v as LayoutMode)}
- size="1"
- >
-
-
- {simpleModeLabel}
-
-
- {advanceModeLabel}
-
-
-
+
+
+
+
);
}
@@ -623,7 +587,6 @@ const SONG_PART_OPTIONS = [
const NONE_VALUE = '__none__';
const SongPartField: FC = () => {
- const { t } = useTranslation();
const selectedLines = useAtomValue(selectedLinesAtom);
const lyricLines = useAtomValue(lyricLinesAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
@@ -673,70 +636,68 @@ const SongPartField: FC = () => {
return (
<>
-
- {t('ribbonBar.editMode.songPart', 'Song Part')}
-
-
-
+ Song Part
+
+
+
+ {isAddingCustom && (
+ <>
+
setCustomPart(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleAddCustomPart();
+ }
}}
+ />
+
+ >
+ )}
+
>
);
};
const AgentField: FC = () => {
- const { t } = useTranslation();
const selectedLines = useAtomValue(selectedLinesAtom);
const lyricLines = useAtomValue(lyricLinesAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
@@ -876,44 +837,41 @@ const AgentField: FC = () => {
return (
<>
-
- {t('ribbonBar.editMode.agent', 'Agent')}
-
-
+ Agent
+
+
+ {agentOptions.map((option) =>
+ option.type === 'separator' ? (
+
+ ) : (
+
+ )
+ )}
+
>
);
};
@@ -924,45 +882,38 @@ const AuxiliaryDisplayField: FC = () => {
const [showWordRomanizationInput, setShowWordRomanizationInput] = useAtom(
showWordRomanizationInputAtom
);
- const { t } = useTranslation();
const idTranslation = useId();
const idRomanization = useId();
const idPerWord = useId();
return (
-
-
-
-
+
+
setShowTranslation(Boolean(c))}
/>
-
-
-
+
setShowRomanization(Boolean(c))}
/>
-
-
-
+
setShowWordRomanizationInput(Boolean(c))}
/>
-
+
);
};
@@ -975,81 +926,80 @@ export const EditModeRibbonBar: FC = forwardRef((_props, ref) =>
const idBpm = useId();
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
- const { t } = useTranslation();
return (
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
-
+
+
{
@@ -1058,80 +1008,65 @@ export const EditModeRibbonBar: FC = forwardRef((_props, ref) =>
}}
formatter={String}
/>
-
+
-
-
+
+
v}
formatter={(v) => v}
/>
v}
formatter={(v) => v || ''}
/>
-
-
+
+
-
-
+
+
v}
formatter={(v) => v}
textFieldStyle={{ width: '20em' }}
/>
v}
formatter={(v) => v}
textFieldStyle={{ width: '20em' }}
/>
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
-
-
-
+
+
+
((_props, ref) =>
toggleTab({ tabId: 'outline', open: Boolean(checked) });
}}
/>
-
-
-
+
((_props, ref) =>
toggleTab({ tabId: 'bpm', open: Boolean(checked) });
}}
/>
-
+
);
diff --git a/frontend/src/editor/components/RibbonBar/index.tsx b/frontend/src/editor/components/RibbonBar/index.tsx
index d31110f..3a1fec4 100644
--- a/frontend/src/editor/components/RibbonBar/index.tsx
+++ b/frontend/src/editor/components/RibbonBar/index.tsx
@@ -9,7 +9,6 @@
* https://github.com/amll-dev/amll-ttml-tool/blob/main/LICENSE
*/
-import { Card, Inset } from '@radix-ui/themes';
import { AnimatePresence } from 'framer-motion';
import { useAtomValue } from 'jotai';
import { forwardRef, lazy, memo } from 'react';
@@ -25,42 +24,30 @@ export const RibbonBar = memo(
const toolMode = useAtomValue(toolModeAtom);
return (
-
-
-
-
- {toolMode === ToolMode.Edit && (
-
-
-
- )}
- {toolMode === ToolMode.Sync && (
-
-
-
- )}
- {toolMode === ToolMode.Preview && (
-
-
-
- )}
-
-
-
-
+
+
+ {toolMode === ToolMode.Edit && (
+
+
+
+ )}
+ {toolMode === ToolMode.Sync && (
+
+
+
+ )}
+ {toolMode === ToolMode.Preview && (
+
+
+
+ )}
+
+
+
);
})
);
diff --git a/frontend/src/editor/components/RibbonBar/preview-mode.tsx b/frontend/src/editor/components/RibbonBar/preview-mode.tsx
index a415a19..806a944 100644
--- a/frontend/src/editor/components/RibbonBar/preview-mode.tsx
+++ b/frontend/src/editor/components/RibbonBar/preview-mode.tsx
@@ -9,10 +9,10 @@
* https://github.com/amll-dev/amll-ttml-tool/blob/main/LICENSE
*/
-import { Checkbox, Grid, Text, TextField } from '@radix-ui/themes';
import { useAtom } from 'jotai';
import { forwardRef } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
import {
hideObsceneWordsAtom,
lyricWordFadeWidthAtom,
@@ -26,41 +26,27 @@ export const PreviewModeRibbonBar = forwardRef((_props, ref) =>
const [showRomanLine, setShowRomanLine] = useAtom(showRomanLinesAtom);
const [hideObsceneWords, setHideObsceneWords] = useAtom(hideObsceneWordsAtom);
const [lyricWordFadeWidth, setLyricWordFadeWidth] = useAtom(lyricWordFadeWidthAtom);
- const { t } = useTranslation();
return (
-
-
-
- {t('ribbonBar.previewMode.showTranslation', '显示翻译')}
-
+
+
+ 显示翻译
setShowTranslationLine(!!v)}
/>
-
- {t('ribbonBar.previewMode.showRoman', '显示音译')}
-
+ 显示音译
setShowRomanLine(!!v)} />
-
- {t('ribbonBar.previewMode.maskObsceneWords', '屏蔽不雅用语')}
-
+ 屏蔽不雅用语
setHideObsceneWords(!!v)} />
-
+
-
-
-
- {t('ribbonBar.previewMode.fadeWidth', '过渡宽度')}
-
-
+
+ 过渡宽度
+ {
const value = Number.parseFloat(e.target.value);
@@ -69,7 +55,7 @@ export const PreviewModeRibbonBar = forwardRef((_props, ref) =>
}
}}
/>
-
+
);
diff --git a/frontend/src/editor/components/RibbonBar/sync-mode.tsx b/frontend/src/editor/components/RibbonBar/sync-mode.tsx
index d11442d..5bbe5df 100644
--- a/frontend/src/editor/components/RibbonBar/sync-mode.tsx
+++ b/frontend/src/editor/components/RibbonBar/sync-mode.tsx
@@ -31,37 +31,36 @@ import {
keySyncStartAtom,
} from '$/states/keybindings.ts';
import { bgLyricIgnoreSyncAtom, lyricLinesAtom } from '$/states/main.ts';
-import { Checkbox, Flex, Grid, Kbd, Slider, Text, TextField } from '@radix-ui/themes';
import { useAtom, useAtomValue } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
import { type FC, forwardRef } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Kbd } from '@/components/ui/kbd';
+import { Slider } from '@/components/ui/slider';
+import { formatKeyBindings } from '$/utils/keybindings.ts';
import { KeyBinding } from '../KeyBinding/index.tsx';
import { RibbonFrame, RibbonSection } from './common';
-import { formatKeyBindings } from '$/utils/keybindings.ts';
const EmptyBeatField = () => {
const [currentEmptyBeat, setCurrentEmptyBeat] = useAtom(currentEmptyBeatAtom);
const currentWordEmptyBeat = useCurrentLocation()?.word.emptyBeat || 0;
- const { t } = useTranslation();
return (
<>
-
- {t('ribbonBar.syncMode.currentEmptyBeat', '当前空拍')}
-
+ 当前空拍
setCurrentEmptyBeat(v[0] ?? 0)}
+ value={currentEmptyBeat}
+ onValueChange={setCurrentEmptyBeat}
min={0}
max={currentWordEmptyBeat}
step={1}
disabled={currentWordEmptyBeat === 0}
/>
-
+
{currentEmptyBeat} / {currentWordEmptyBeat}
-
+
>
);
};
@@ -83,50 +82,38 @@ export const SyncModeRibbonBar: FC = forwardRef((_props, ref) =>
const [syncTimeOffset, setSyncTimeOffset] = useAtom(syncTimeOffsetAtom);
const syncNextDualMode = useAtomValue(syncNextDualModeAtom);
const syncNextAltKeys = useAtomValue(keySyncNextAltAtom);
- const { t } = useTranslation();
return (
-
-
-
- {t('ribbonBar.syncMode.timeOffset', '时间戳位移')}
-
- setSyncTimeOffset(e.target.valueAsNumber)}
- >
-
- ms
-
+
+
+
时间戳位移
+
+ setSyncTimeOffset(e.target.valueAsNumber)}
+ />
+ ms
+
-
+
-
-
-
- {t('ribbonBar.syncMode.showTimestampUpdate', '呈现时间戳更新')}
-
+
+
+ 呈现时间戳更新
setVisualizeTimestampUpdate(!!v)}
/>
-
- {t('ribbonBar.syncMode.touchSyncPanel', '触控打轴辅助面板')}
-
+ 触控打轴辅助面板
setShowTouchSyncPanel(!!v)}
/>
-
- {t('ribbonBar.syncMode.bgLyricIgnoreSync', '背景歌词忽略打轴')}
-
+ 背景歌词忽略打轴
{
@@ -142,58 +129,44 @@ export const SyncModeRibbonBar: FC = forwardRef((_props, ref) =>
});
}}
/>
-
+
-
-
-
- {t('ribbonBar.syncMode.showTimestamps', '显示时间戳')}
-
+
+
+ 显示时间戳
setShowTimestamps(!!v)} />
-
- {t('ribbonBar.syncMode.highlightActiveWord', '高亮当前音节')}
-
+ 高亮当前音节
setHighlightActiveWord(!!v)}
/>
-
- {t('ribbonBar.syncMode.highlightErrors', '高亮错误')}
-
+ 高亮错误
setHighlightErrors(!!v)} />
{showWordRomanizationInput && (
<>
-
- {t('ribbonBar.syncMode.showPerWordRomanization', '显示逐字音译')}
-
+ 显示逐字音译
setdisplayRomanizationInSync(!!v)}
/>
>
)}
-
+
-
-
-
-
- {t('ribbonBar.syncMode.startSync', '起始轴')}
-
+
+
+
+
起始轴
-
- {t('ribbonBar.syncMode.continuousSync', '连续轴')}
-
-
+ 连续轴
+
{syncNextDualMode ? {formatKeyBindings(syncNextAltKeys)} : null}
-
-
- {t('ribbonBar.syncMode.endSync', '结束轴')}
-
+
+ 结束轴
-
-
+
+
);
diff --git a/frontend/src/editor/components/Sidebar/BpmPanel.tsx b/frontend/src/editor/components/Sidebar/BpmPanel.tsx
index 3ff76e1..a0d85d1 100644
--- a/frontend/src/editor/components/Sidebar/BpmPanel.tsx
+++ b/frontend/src/editor/components/Sidebar/BpmPanel.tsx
@@ -1,25 +1,10 @@
-import {
- ArrowLeft16Regular,
- ArrowResetRegular,
- Dismiss16Regular,
- ErrorCircle16Regular,
- Info16Regular,
- Timer16Regular,
-} from '@fluentui/react-icons';
-import {
- Box,
- Button,
- Callout,
- Card,
- Checkbox,
- Flex,
- IconButton,
- Text,
- Tooltip,
-} from '@radix-ui/themes';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { AlertCircle, ArrowLeft, Info, RotateCcw, Timer, X } from 'lucide-react';
import { type FC, useCallback, useId, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Alert, AlertTitle } from '@/components/ui/alert';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { SimpleTooltip } from '@/components/ui/tooltip';
import { KeyBinding } from '$/components/KeyBinding';
import { useBpmControl, useBpmTapEngine } from '$/modules/audio/hooks';
import {
@@ -39,7 +24,6 @@ function formatCalculationTime(timeMs: number): string {
}
export const BpmPanel: FC = () => {
- const { t } = useTranslation();
const followRateCheckboxId = useId();
const showBeatLinesCheckboxId = useId();
const {
@@ -104,7 +88,7 @@ export const BpmPanel: FC = () => {
bpmValueText = `${currentBpm ?? Math.round(bpmState.result.bpm)}`;
durationText = formatCalculationTime(bpmState.calculationTime);
} else if (bpmState.status === 'analyzing') {
- bpmValueText = t('sidebar.bpm.analyzing', '正在分析');
+ bpmValueText = '正在分析';
}
const isAnalyzing = bpmState.status === 'analyzing';
@@ -113,314 +97,214 @@ export const BpmPanel: FC = () => {
isAdjusted || tapTimes.length > 0 || (isCompleted && bpmState.calculationTime === 0);
return (
-
-
-
-
- BPM
-
+
+
+
+
BPM
-
-
+
+
-
+
-
{bpmValueText}
-
+
-
+
-
-
+
+
{isCompleted && isModified && (
-
-
+
-
+
+
+
)}
-
+
-
-
-
- {durationText}
-
-
+
+
+ {durationText}
+
{bpmState.status === 'error' && (
-
-
-
-
- {bpmState.error}
-
+
+
+ {bpmState.error}
+
)}
-
+
setFollowPlaybackRate(Boolean(checked))}
/>
-
-
-
-
+
+
-
+
setShowBeatLines(Boolean(checked))}
/>
-
-
-
-
+
+
{!hasSeenTapWindowTip && (isKeyTapMode || isSpectrogramTapMode) && (
-
-
-
-
-
-
- {t('sidebar.bpm.slidingWindowTip', '只会使用最近 10 次校准数据计算 BPM')}
-
-
-
+
+ 只会使用最近 10 次校准数据计算 BPM
+
-
+
+
+
)}
{!isKeyTapMode && !isSpectrogramTapMode && (
-
+
-
+
)}
{isKeyTapMode && (
-
-
-
-
+
+
+
-
-
+ triggerTap()}
- style={{
- textAlign: 'center',
- color: isHighlighted ? 'var(--accent-11)' : undefined,
- cursor: 'pointer',
- userSelect: 'none',
- }}
>
- {t('sidebar.bpm.tapInstructionPrefix', '根据节拍按下 ')}
-
- {t('sidebar.bpm.tapInstructionSuffix', ' 或点击此处')}
-
-
-
+ 根据节拍按下
或点击此处
+
+
+
-
+
{Array.from({ length: 10 }).map((_, index) => (
-
))}
-
-
-
+
+
+
)}
{isSpectrogramTapMode && (
-
-
-
-
+
+
+
-
-
+
- {t('sidebar.bpm.clickOnSpectrogramToCalibrate', '在频谱图上点击以校准')}
-
-
-
+ 在频谱图上点击以校准
+
+
+
-
+
{Array.from({ length: 10 }).map((_, index) => (
-
))}
-
-
-
+
+
+
)}
-
-
+
+
);
};
diff --git a/frontend/src/editor/components/Sidebar/OutlinePanel.tsx b/frontend/src/editor/components/Sidebar/OutlinePanel.tsx
index 4e543bb..0c7e247 100644
--- a/frontend/src/editor/components/Sidebar/OutlinePanel.tsx
+++ b/frontend/src/editor/components/Sidebar/OutlinePanel.tsx
@@ -1,7 +1,6 @@
-import { ContextMenu } from '@radix-ui/themes';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { memo, useCallback, useMemo, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
+import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
import { LyricLineMenu } from '$/components/Menus/lyric-line-menu';
import { useLyricListDrag } from '$/modules/lyric-drag/useLyricListDrag';
import {
@@ -16,7 +15,6 @@ import { outlineJumpActionAtom } from '$/states/sidebar.ts';
import styles from './OutlinePanel.module.css';
export const OutlinePanel = memo(() => {
- const { t } = useTranslation();
const store = useStore();
const { lyricLines } = useAtomValue(lyricLinesAtom);
@@ -66,6 +64,9 @@ export const OutlinePanel = memo(() => {
[store, isDragging]
);
+ // 仅在编辑模式下挂右键菜单
+ const menuEnabled = toolMode === ToolMode.Edit;
+
return (
{
const textContent = line.words.map((w) => w.word).join('');
const hasText = textContent.trim().length > 0;
+ const item = (
+
{
+ onPointerDown(e, line.id, index);
+ }}
+ onDoubleClick={() => handleItemDoubleClick(line.id)}
+ >
+
{displayNumbers[index]}
+
+
+
+ {hasText ? textContent : (空行)}
+
+
+
+ );
+
return (
{
data-line-id={line.id}
data-absolute-index={index}
>
-
handleContextMenuOpen(opened, line.id)}>
-
- {
- onPointerDown(e, line.id, index);
- }}
- onDoubleClick={() => handleItemDoubleClick(line.id)}
- >
-
{displayNumbers[index]}
-
-
-
- {hasText ? (
- textContent
- ) : (
-
- {t('sidebar.outline.emptyLine', '(空行)')}
-
- )}
-
-
-
-
+ {menuEnabled ? (
+ handleContextMenuOpen(opened, line.id)}>
+ {item}
-
-
-
-
+
+
+
+
+ ) : (
+ item
+ )}
);
})}
diff --git a/frontend/src/editor/components/Sidebar/ResizableSidebar.module.css b/frontend/src/editor/components/Sidebar/ResizableSidebar.module.css
index 2649d50..aa41799 100644
--- a/frontend/src/editor/components/Sidebar/ResizableSidebar.module.css
+++ b/frontend/src/editor/components/Sidebar/ResizableSidebar.module.css
@@ -47,6 +47,8 @@
.title {
flex: 1 1 auto;
min-width: 0;
+ align-self: stretch;
+ height: 100%;
overflow: hidden;
}
diff --git a/frontend/src/editor/components/Sidebar/ResizableSidebar.tsx b/frontend/src/editor/components/Sidebar/ResizableSidebar.tsx
index bbe8832..9a5b4a8 100644
--- a/frontend/src/editor/components/Sidebar/ResizableSidebar.tsx
+++ b/frontend/src/editor/components/Sidebar/ResizableSidebar.tsx
@@ -1,9 +1,8 @@
-import { Dismiss16Regular } from '@fluentui/react-icons';
-import { Box, IconButton } from '@radix-ui/themes';
import { motion } from 'framer-motion';
import { useAtom, type WritableAtom } from 'jotai';
+import { X } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
import styles from './ResizableSidebar.module.css';
const SNAP_CLOSE_THRESHOLD = 20;
@@ -43,7 +42,6 @@ export const ResizableSidebar =
({
headerSingle,
children,
}: ResizableSidebarProps) => {
- const { t } = useTranslation();
const [activePanel, setActivePanel] = useAtom(panelAtom);
const [savedWidth, setSavedWidth] = useAtom(widthAtom);
@@ -134,17 +132,17 @@ export const ResizableSidebar = ({
{isOpen ? (titles[activePanel] ?? null) : null}
-
-
-
+
+
-
{children}
+
{children}
{side === 'left' && resizer}
diff --git a/frontend/src/editor/components/Sidebar/RightSidebar.tsx b/frontend/src/editor/components/Sidebar/RightSidebar.tsx
index ff95524..e8f69cb 100644
--- a/frontend/src/editor/components/Sidebar/RightSidebar.tsx
+++ b/frontend/src/editor/components/Sidebar/RightSidebar.tsx
@@ -1,11 +1,9 @@
import { useAtom } from 'jotai';
-import { useTranslation } from 'react-i18next';
import { rightSidebarPanelAtom, rightSidebarWidthAtom } from '$/states/sidebar.ts';
import { ResizableSidebar } from './ResizableSidebar';
const MIN_WIDTH = 220;
export const RightSidebar = () => {
- const { t } = useTranslation();
const [activePanel] = useAtom(rightSidebarPanelAtom);
return (
@@ -16,7 +14,7 @@ export const RightSidebar = () => {
closedPanel="none"
minWidth={MIN_WIDTH}
maxWidthRatio={0.4}
- titles={{ annotations: t('sidebar.annotations.title', '批注') }}
+ titles={{ annotations: '批注' }}
>
{activePanel === 'annotations' && null}
diff --git a/frontend/src/editor/components/Sidebar/SidebarTabBar.tsx b/frontend/src/editor/components/Sidebar/SidebarTabBar.tsx
index 95557b4..8b12001 100644
--- a/frontend/src/editor/components/Sidebar/SidebarTabBar.tsx
+++ b/frontend/src/editor/components/Sidebar/SidebarTabBar.tsx
@@ -1,7 +1,6 @@
-import { DismissRegular } from '@fluentui/react-icons';
import { AnimatePresence, motion } from 'framer-motion';
+import { X } from 'lucide-react';
import { type FC, useEffect, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
import type { SidebarPanelType } from '$/states/sidebar.ts';
import type { SidebarTab } from './index';
import styles from './SidebarTabBar.module.css';
@@ -19,7 +18,6 @@ export const SidebarTabBar: FC = ({
onSelectTab,
onCloseTab,
}) => {
- const { t } = useTranslation();
const prevTabIdsRef = useRef>(new Set());
const prevTabIds = prevTabIdsRef.current;
@@ -32,7 +30,7 @@ export const SidebarTabBar: FC = ({
if (singleTab) {
return (
- {singleTab.getTitle(t)}
+ {singleTab.title}
);
}
@@ -67,17 +65,17 @@ export const SidebarTabBar: FC = ({
}
}}
>
- {tab.getTitle(t)}
+ {tab.title}
);
diff --git a/frontend/src/editor/components/Sidebar/index.tsx b/frontend/src/editor/components/Sidebar/index.tsx
index 5c62959..d8298ac 100644
--- a/frontend/src/editor/components/Sidebar/index.tsx
+++ b/frontend/src/editor/components/Sidebar/index.tsx
@@ -1,4 +1,3 @@
-import type { TFunction } from 'i18next';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { type FC, useMemo } from 'react';
import {
@@ -18,19 +17,19 @@ const MAX_WIDTH = 650;
export interface SidebarTab {
id: Exclude;
- getTitle: (t: TFunction) => string;
+ title: string;
component: FC;
}
export const SIDEBAR_TABS: SidebarTab[] = [
{
id: 'outline',
- getTitle: (t) => t('sidebar.outline.title', '大纲'),
+ title: '大纲',
component: OutlinePanel,
},
{
id: 'bpm',
- getTitle: (t) => t('sidebar.bpm.title', 'BPM'),
+ title: 'BPM',
component: BpmPanel,
},
];
diff --git a/frontend/src/editor/components/SuspensePlaceHolder/index.tsx b/frontend/src/editor/components/SuspensePlaceHolder/index.tsx
index 4cb1d43..dcb97f7 100644
--- a/frontend/src/editor/components/SuspensePlaceHolder/index.tsx
+++ b/frontend/src/editor/components/SuspensePlaceHolder/index.tsx
@@ -1,30 +1,20 @@
-import { Flex, Spinner, Text } from '@radix-ui/themes';
import { motion } from 'framer-motion';
+import { Loader2 } from 'lucide-react';
import { type PropsWithChildren, Suspense } from 'react';
-import { useTranslation } from 'react-i18next';
+
export const SuspensePlaceHolder = (props: PropsWithChildren) => {
- const { t } = useTranslation();
return (
-
-
-
- {t('ribbonBar.loading', '面板载入中...')}
-
-
-
+
+ 面板载入中...
+
}
>
{props.children}
diff --git a/frontend/src/editor/hooks/useFileOpener.ts b/frontend/src/editor/hooks/useFileOpener.ts
index 3a5957f..4bab589 100644
--- a/frontend/src/editor/hooks/useFileOpener.ts
+++ b/frontend/src/editor/hooks/useFileOpener.ts
@@ -12,7 +12,6 @@ import {
import { openDB } from 'idb';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
import { uid } from 'uid';
import { audioEngine } from '$/modules/audio/audio-engine';
import { getProjectList } from '$/modules/project/autosave/autosave';
@@ -30,6 +29,7 @@ import {
fileUpdateSessionAtom,
isDirtyAtom,
lyricLinesAtom,
+ markLyricLinesSavedAtom,
newLyricLinesAtom,
projectIdAtom,
saveFileNameAtom,
@@ -167,13 +167,13 @@ export const useFileOpener = () => {
const setProjectId = useSetAtom(projectIdAtom);
const setSaveFileName = useSetAtom(saveFileNameAtom);
const setSourceFileContent = useSetAtom(sourceFileContentAtom);
+ const setMarkSaved = useSetAtom(markLyricLinesSavedAtom);
const setConfirmDialog = useSetAtom(confirmDialogAtom);
const isDirty = useAtomValue(isDirtyAtom);
const fileUpdateSession = useAtomValue(fileUpdateSessionAtom);
const defaultTtmlAuthorGithub = useAtomValue(defaultTtmlAuthorGithubAtom);
const defaultTtmlAuthorGithubLogin = useAtomValue(defaultTtmlAuthorGithubLoginAtom);
const handleTtmlError = useTtmlErrorHandler();
- const { t } = useTranslation();
const setPushNotification = useSetAtom(pushNotificationAtom);
const normalizeLyricLines = useCallback((lyricLines: LyricLine[]): TTMLLyric => {
@@ -269,9 +269,7 @@ export const useFileOpener = () => {
lyricData = normalizeLyricLines(rawLines);
} else {
setPushNotification({
- title: t('error.unsupportedFileFormat', '不支持的文件格式: {ext}', {
- ext,
- }),
+ title: `不支持的文件格式: ${ext}`,
level: 'error',
source: 'useFileOpener',
});
@@ -313,7 +311,7 @@ export const useFileOpener = () => {
} catch (e) {
fileOpenerLogger.error(`Failed to open file: ${file.name}`, e);
setPushNotification({
- title: t('error.openFileFailed', '打开文件失败'),
+ title: '打开文件失败',
level: 'error',
source: 'useFileOpener',
});
@@ -328,7 +326,6 @@ export const useFileOpener = () => {
normalizeLyricLines,
defaultTtmlAuthorGithub,
defaultTtmlAuthorGithubLogin,
- t,
setPushNotification,
handleTtmlError,
]
@@ -364,18 +361,15 @@ export const useFileOpener = () => {
if (isDirty) {
setConfirmDialog({
open: true,
- title: t('confirmDialog.openFile.title', '确认打开文件'),
- description: t(
- 'confirmDialog.openFile.description',
- '当前文件有未保存的更改。如果继续,这些更改将会丢失。确定要打开新文件吗?'
- ),
+ title: '确认打开文件',
+ description: '当前文件有未保存的更改。如果继续,这些更改将会丢失。确定要打开新文件吗?',
onConfirm: run,
});
} else {
run();
}
},
- [fileUpdateSession, isDirty, performOpenFile, setConfirmDialog, t]
+ [fileUpdateSession, isDirty, performOpenFile, setConfirmDialog]
);
const openCachedAudio = useCallback(async () => {
@@ -383,7 +377,7 @@ export const useFileOpener = () => {
const record = await readAudioCache();
if (!record?.file) {
setPushNotification({
- title: t('audioPanel.cachedAudioMissing', '未找到缓存音频'),
+ title: '未找到缓存音频',
level: 'warning',
source: 'useFileOpener',
});
@@ -394,19 +388,19 @@ export const useFileOpener = () => {
const file = new File([record.file], name, { type });
await loadAudioFile(file, { extractMetadata: false });
setPushNotification({
- title: t('audioPanel.cachedAudioLoaded', '已从缓存加载音频'),
+ title: '已从缓存加载音频',
level: 'success',
source: 'useFileOpener',
});
} catch (error) {
fileOpenerLogger.error('Failed to load cached audio', error);
setPushNotification({
- title: t('audioPanel.cachedAudioFailed', '读取缓存音频失败'),
+ title: '读取缓存音频失败',
level: 'error',
source: 'useFileOpener',
});
}
- }, [loadAudioFile, setPushNotification, t]);
+ }, [loadAudioFile, setPushNotification]);
const loadTtmlContent = useCallback(
async (ttml: string, options?: { projectId?: string; fileName?: string }) => {
@@ -435,6 +429,7 @@ export const useFileOpener = () => {
});
return { ...prev, metadata: nextMetadata };
});
+ setMarkSaved();
return true;
},
[
@@ -442,6 +437,7 @@ export const useFileOpener = () => {
defaultTtmlAuthorGithubLogin,
handleTtmlError,
setLyricLines,
+ setMarkSaved,
setNewLyricLines,
setProjectId,
setSaveFileName,
diff --git a/frontend/src/editor/hooks/useRelativeTime.ts b/frontend/src/editor/hooks/useRelativeTime.ts
index 8c19a81..7a4bd22 100644
--- a/frontend/src/editor/hooks/useRelativeTime.ts
+++ b/frontend/src/editor/hooks/useRelativeTime.ts
@@ -1,22 +1,16 @@
import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
/** 相对时间文案(刚刚 / N 分钟前 / N 小时前 / N 天前)。 */
export const useRelativeTime = () => {
- const { t } = useTranslation();
+ return useCallback((timestamp: number) => {
+ const diff = Date.now() - timestamp;
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
- return useCallback(
- (timestamp: number) => {
- const diff = Date.now() - timestamp;
- const minutes = Math.floor(diff / 60000);
- const hours = Math.floor(minutes / 60);
- const days = Math.floor(hours / 24);
-
- if (days > 0) return t('time.daysAgo', '{count}天前', { count: days });
- if (hours > 0) return t('time.hoursAgo', '{count}小时前', { count: hours });
- if (minutes > 0) return t('time.minutesAgo', '{count}分钟前', { count: minutes });
- return t('time.justNow', '刚刚');
- },
- [t]
- );
+ if (days > 0) return `${days}天前`;
+ if (hours > 0) return `${hours}小时前`;
+ if (minutes > 0) return `${minutes}分钟前`;
+ return '刚刚';
+ }, []);
};
diff --git a/frontend/src/editor/i18n/index.ts b/frontend/src/editor/i18n/index.ts
deleted file mode 100644
index ddacb4b..0000000
--- a/frontend/src/editor/i18n/index.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import i18n from 'i18next';
-import { initReactI18next } from 'react-i18next';
-
-if (!i18n.isInitialized) {
- i18n.use(initReactI18next).init({
- resources: { zh: { translation: {} } },
- lng: 'zh',
- fallbackLng: 'zh',
- interpolation: {
- escapeValue: false,
- },
- returnNull: false,
- });
-}
-
-export default i18n;
diff --git a/frontend/src/editor/index.css b/frontend/src/editor/index.css
index 558bf6a..1f3d558 100644
--- a/frontend/src/editor/index.css
+++ b/frontend/src/editor/index.css
@@ -44,7 +44,7 @@ body,
overscroll-behavior: none;
}
-.radix-themes {
+:root {
--default-font-family: 'MiSans', Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
--default-font-family-mono:
@@ -78,18 +78,6 @@ body,
}
}
-.rt-BaseButton:where(.rt-variant-soft) {
- background-color: var(--accent-a3);
-
- &:hover {
- background-color: var(--accent-a4);
- }
-}
-
-.rt-BaseButton:where(.rt-variant-soft):where(:active:not([data-state='open'])) {
- background-color: var(--accent-a5);
-}
-
.amll-lyric-player {
margin: 0 !important;
padding: 0 !important;
diff --git a/frontend/src/editor/modules/audio/audio-engine.ts b/frontend/src/editor/modules/audio/audio-engine.ts
index 1ef47b2..86c9158 100644
--- a/frontend/src/editor/modules/audio/audio-engine.ts
+++ b/frontend/src/editor/modules/audio/audio-engine.ts
@@ -62,6 +62,11 @@ class AudioEngineWrapper extends EventTarget {
fileName: '',
};
+ private _loadToken = 0;
+ private _inflightLoads = new Map>();
+ private _inflightUrl: string | null = null;
+ private _abortCurrent: AbortController | null = null;
+
//#region Audio context basics
private _ctx: AudioContext | null = null;
get ctx() {
@@ -325,58 +330,165 @@ class AudioEngineWrapper extends EventTarget {
return this.mapAudioMetadataToTTML();
}
- /**
- * 从 URL 加载音频。
- *
- * 审核页的参考音频是后端给的直链:上传音频的
- * `/api/v1/uploads/file/xxx`,或者**网易云解析出来的播放地址**。
- *
- * ## 做法:完整下载 → 交给引擎
- *
- * 整个文件先 `fetch` 成 `File`,再走 `loadMusic()`(内部
- * `decodeAudioData` 一次性解码)。**不流式播放**,理由:
- *
- * - 时长立刻可知,歌词编辑器的时间轴/进度条不用等
- * - 解码只有一次失败点(`decodeAudioData`),错误信息是浏览器给的,
- * 比 wasm 的 `last_error` 可读得多
- * - 审核时经常来回拖进度、反复试听同一段,全量在手更稳
- *
- * 关于 CORS:`fetch` 确实要求对方带 `Access-Control-Allow-Origin`,
- * 但本站两条音源都满足 —— 上传音频走同源 `/api/v1/uploads/file/*`,
- * 网易云直链本身也带 CORS 头(详情页 `PlayerBoot` 早就在
- * `fetch` + `decodeAudioData` 这么用了)。
- *
- * 关于文件名:`parseAudioTrackMetadata` 靠扩展名判断音频格式,
- * 而网易云直链的 URL 里通常没有扩展名,所以从 pathname 猜一个;
- * 猜不到就用 `audio`(此时只是读不出内嵌歌名,解码不受影响)。
- */
async loadMusicFromUrl(url: string): Promise {
- let blob: Blob;
+
+ const inflight = this._inflightLoads.get(url);
+ if (inflight) return inflight;
+
+ const token = ++this._loadToken;
+ if (this._inflightUrl !== null && this._inflightUrl !== url) {
+ this._abortCurrent?.abort();
+ }
+ const controller = new AbortController();
+ this._abortCurrent = controller;
+ this._inflightUrl = url;
+
+ const task = (async (): Promise => {
+ let blob: Blob;
+ try {
+ blob = await this.downloadAudioBlob(url, controller.signal);
+ } catch (e) {
+ if (e instanceof DOMException && e.name === 'AbortError') {
+ throw new Error('音频加载已被更新的请求取代', { cause: e });
+ }
+ throw new Error(
+ `无法下载参考音频(${url.slice(0, 120)})。原始错误:${
+ e instanceof Error ? e.message : String(e)
+ }`,
+ { cause: e }
+ );
+ }
+
+ if (token !== this._loadToken) {
+ throw new Error('音频加载已被更新的请求取代');
+ }
+
+ if (blob.size === 0) {
+ throw new Error(`参考音频为空(${url.slice(0, 120)})`);
+ }
+
+ const name = audioFileNameFromUrl(url) || 'audio';
+ const file = new File([blob], name, {
+ type: blob.type || 'audio/*',
+ });
+ const metadata = await this.loadMusic(file);
+
+ if (token !== this._loadToken) {
+ throw new Error('音频加载已被更新的请求取代');
+ }
+ return metadata;
+ })();
+
+ this._inflightLoads.set(url, task);
try {
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
+ return await task;
+ } finally {
+ // 无论成功失败都摘掉在途登记,允许之后重试同一 URL。
+ if (this._inflightLoads.get(url) === task) {
+ this._inflightLoads.delete(url);
+ }
+ // 该 URL 已不再是「在途」时,同步清掉标记,避免下次误判为「切换源」。
+ if (this._inflightLoads.size === 0) {
+ this._inflightUrl = null;
+ this._abortCurrent = null;
}
- blob = await response.blob();
- } catch (e) {
- console.error('fetch/读取 blob 失败', e);
- throw new Error(
- `无法下载参考音频(${url.slice(0, 120)})。原始错误:${
- e instanceof Error ? e.message : String(e)
- }`,
- { cause: e }
- );
}
+ }
- if (blob.size === 0) {
- throw new Error(`参考音频为空(${url.slice(0, 120)})`);
- }
+ private async downloadAudioBlob(url: string, signal: AbortSignal): Promise {
+ /** 最多尝试轮次(首轮 + 续传)。按实测每次约 4~8MB 被掐断,足够拉完 20MB。 */
+ const MAX_ROUNDS = 8;
+ /** 每轮中断后的退避 */
+ const RETRY_BACKOFF_MS = 700;
+
+ const chunks: Uint8Array[] = [];
+ let received = 0;
+ let total: number | null = null;
+ let contentType = 'audio/*';
+ let lastError: unknown;
+
+ for (let round = 0; round < MAX_ROUNDS; round++) {
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
+ const startedAt = Date.now();
+ try {
+ const headers: Record = {};
+ // 已收部分字节时,从断点续传
+ if (received > 0) headers.Range = `bytes=${received}-`;
+
+ const response = await fetch(url, {
+ signal,
+ cache: 'no-store',
+ credentials: 'same-origin',
+ headers,
+ });
- const name = audioFileNameFromUrl(url) || 'audio';
- const file = new File([blob], name, {
- type: blob.type || 'audio/*',
- });
- return this.loadMusic(file);
+ if (round === 0) {
+ if (response.status === 206) {
+ throw new Error('服务器返回了分段内容(206),音频未完整下载');
+ }
+ if (!response.ok) {
+ // 4xx 是确定性失败(地址不对 / 无权限),重试无意义
+ const err = new Error(`HTTP ${response.status} ${response.statusText}`);
+ if (response.status >= 400 && response.status < 500) {
+ (err as { retryable?: boolean }).retryable = false;
+ }
+ throw err;
+ }
+ contentType = response.headers.get('content-type') || contentType;
+ const len = Number(response.headers.get('content-length'));
+ total = Number.isFinite(len) && len > 0 ? len : null;
+ } else if (response.status !== 206) {
+ throw new Error(`续传请求未返回 206(HTTP ${response.status})`);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ // 没有可读流(极老环境):退回一次性 blob()
+ return await response.blob();
+ }
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value && value.length > 0) {
+ chunks.push(value);
+ received += value.length;
+ }
+ }
+
+ // 服务端声明了长度却没传完:当成本轮中断,继续续传
+ if (total !== null && received < total) {
+ throw new Error(`响应提前结束:已收 ${received}/${total} 字节`);
+ }
+
+ const blob = new Blob(chunks as BlobPart[], { type: contentType });
+ if (round > 0) {
+ console.info('[audio-engine] 音频断点续传完成', {
+ url,
+ bytes: received,
+ rounds: round + 1,
+ });
+ }
+ return blob;
+ } catch (e) {
+ // 主动 abort(被更新的请求取代)不重试
+ if (e instanceof DOMException && e.name === 'AbortError') throw e;
+ if (e instanceof Error && (e as { retryable?: boolean }).retryable === false) throw e;
+ lastError = e;
+ console.warn('音频下载中断,将断点续传', {
+ url,
+ round: round + 1,
+ received,
+ total,
+ ms: Date.now() - startedAt,
+ error: e instanceof Error ? `${e.name}: ${e.message}` : String(e),
+ });
+ if (round === MAX_ROUNDS - 1) break;
+ await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS));
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
+ }
+ }
+ console.error('音频下载重试全部失败', { url, received, total, error: lastError });
+ throw lastError;
}
private mapAudioMetadataToTTML(): TTMLMetadata[] {
diff --git a/frontend/src/editor/modules/audio/components/AudioSlider.tsx b/frontend/src/editor/modules/audio/components/AudioSlider.tsx
index ac9cd55..516821e 100644
--- a/frontend/src/editor/modules/audio/components/AudioSlider.tsx
+++ b/frontend/src/editor/modules/audio/components/AudioSlider.tsx
@@ -1,4 +1,3 @@
-import { Card } from '@radix-ui/themes';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { audioEngine } from '$/modules/audio/audio-engine';
@@ -127,13 +126,9 @@ export const AudioSlider = () => {
);
return (
-
{
isDraggingRef={isDraggingRef}
/>
-
+
);
};
diff --git a/frontend/src/editor/modules/audio/components/index.tsx b/frontend/src/editor/modules/audio/components/index.tsx
index a5e6c0c..c672b9a 100644
--- a/frontend/src/editor/modules/audio/components/index.tsx
+++ b/frontend/src/editor/modules/audio/components/index.tsx
@@ -9,30 +9,13 @@
* https://github.com/amll-dev/amll-ttml-tool/blob/main/LICENSE
*/
-import {
- ChevronDownFilled,
- ChevronUpFilled,
- MyLocationRegular,
- PauseFilled,
- PlayFilled,
- Speaker2Filled,
-} from '@fluentui/react-icons';
-import {
- Button,
- Card,
- Flex,
- Grid,
- HoverCard,
- IconButton,
- Inset,
- Slider,
- Spinner,
- Text,
- Tooltip,
-} from '@radix-ui/themes';
import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
+import { ChevronDown, ChevronUp, Loader2, LocateFixed, Pause, Play, Volume2 } from 'lucide-react';
import { type FC, memo, useCallback, useEffect, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
+import { Slider } from '@/components/ui/slider';
+import { SimpleTooltip } from '@/components/ui/tooltip';
import { audioEngine } from '$/modules/audio/audio-engine';
import { AudioSlider } from '$/modules/audio/components/AudioSlider';
import { useBpmControl } from '$/modules/audio/hooks';
@@ -120,17 +103,9 @@ const CurrentTimeDisplay = memo(() => {
}, []);
return (
-
+
00:00.000
-
+
);
});
@@ -143,7 +118,6 @@ export const AudioControls: FC = memo(() => {
const [volume, setVolume] = useAtom(volumeAtom);
const [playbackRate, setPlaybackRate] = useAtom(playbackRateAtom);
const { lyricLines } = useAtomValue(lyricLinesAtom);
- const { t } = useTranslation();
const openTab = useSetAtom(openTabAtom);
const setLocateAction = useSetAtom(locateActionAtom);
@@ -183,120 +157,127 @@ export const AudioControls: FC = memo(() => {
}, [playbackRate]);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t('audioPanel.volume', '音量')}
- setVolume(v[0] ?? volume)}
- />
-
- {(volume * 100).toFixed()}%
-
- {t('audioPanel.playbackRate', '播放速度')}
- setPlaybackRate(v[0] ?? playbackRate)}
- />
-
- {playbackRate.toFixed(2)}x
-
- {t('audioPanel.bpm', 'BPM')}
-
- {bpmState.status === 'analyzing' && (
-
- )}
- {bpmState.status === 'completed' && (
-
- )}
- {bpmState.status === 'error' && (
-
- )}
- {bpmState.status === 'idle' && (
-
- )}
-
-
-
-
-
-
-
-
- {audioPlaying ? : }
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
音量
+
setVolume(v)}
+ />
+
+ {(volume * 100).toFixed()}%
+
+ 播放速度
+ setPlaybackRate(v)}
+ />
+
+ {playbackRate.toFixed(2)}x
+
+ BPM
+
+ {bpmState.status === 'analyzing' && (
+
+ )}
+ {bpmState.status === 'completed' && (
+
+ )}
+ {bpmState.status === 'error' && (
+
+ )}
+ {bpmState.status === 'idle' && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {msToTimestamp(currentDuration)}
+
+
+
+
+
+
+
+
+
+
);
});
diff --git a/frontend/src/editor/modules/audio/hooks/useAudioFeedback.ts b/frontend/src/editor/modules/audio/hooks/useAudioFeedback.ts
index b6488f1..3db1e58 100644
--- a/frontend/src/editor/modules/audio/hooks/useAudioFeedback.ts
+++ b/frontend/src/editor/modules/audio/hooks/useAudioFeedback.ts
@@ -1,6 +1,5 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useEffect, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
import { uid } from 'uid';
import { audioEngineStateAtom, audioErrorAtom } from '$/modules/audio/states/index.ts';
import {
@@ -13,7 +12,6 @@ export const useAudioFeedback = () => {
const engineState = useAtomValue(audioEngineStateAtom);
const [errorMsg, setErrorMsg] = useAtom(audioErrorAtom);
const notificationId = useRef(null);
- const { t } = useTranslation();
const setPushNotification = useSetAtom(pushNotificationAtom);
const upsertNotification = useSetAtom(upsertNotificationAtom);
const removeNotification = useSetAtom(removeNotificationAtom);
@@ -25,7 +23,7 @@ export const useAudioFeedback = () => {
}
upsertNotification({
id: notificationId.current,
- title: t('audio.status.loading', '正在加载音频...'),
+ title: '正在加载音频...',
level: 'info',
source: 'Audio',
});
@@ -36,12 +34,12 @@ export const useAudioFeedback = () => {
removeNotification(notificationId.current);
notificationId.current = null;
}
- }, [engineState, t, upsertNotification, removeNotification]);
+ }, [engineState, upsertNotification, removeNotification]);
useEffect(() => {
if (errorMsg) {
setPushNotification({
- title: `${t('audio.error.workerError', '处理音频时出错')}: ${errorMsg}`,
+ title: `处理音频时出错: ${errorMsg}`,
level: 'error',
source: 'Audio',
});
@@ -52,5 +50,5 @@ export const useAudioFeedback = () => {
setErrorMsg(null);
}
- }, [errorMsg, setErrorMsg, t, setPushNotification, removeNotification]);
+ }, [errorMsg, setErrorMsg, setPushNotification, removeNotification]);
};
diff --git a/frontend/src/editor/modules/lyric-drag/DragGhostRenderer.tsx b/frontend/src/editor/modules/lyric-drag/DragGhostRenderer.tsx
index 647baec..715704c 100644
--- a/frontend/src/editor/modules/lyric-drag/DragGhostRenderer.tsx
+++ b/frontend/src/editor/modules/lyric-drag/DragGhostRenderer.tsx
@@ -1,47 +1,32 @@
-import { ArrowExportRegular, CopyRegular } from '@fluentui/react-icons';
import { useAtomValue } from 'jotai';
-import { useEffect, useRef, useState } from 'react';
+import { ArrowUpFromLine, Copy } from 'lucide-react';
+import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
-import { useTranslation } from 'react-i18next';
import { draggedCountAtom, isCopyModeAtom } from '$/states/main';
import styles from './DragGhost.module.css';
export const DragGhostRenderer = () => {
- const { t } = useTranslation();
-
const draggedCount = useAtomValue(draggedCountAtom);
const isCopyMode = useAtomValue(isCopyModeAtom);
const [portalContainer, setPortalContainer] = useState(null);
- const anchorRef = useRef(null);
useEffect(() => {
- if (anchorRef.current) {
- const themeContainer = anchorRef.current.closest('.radix-themes');
- setPortalContainer(themeContainer || document.body);
- }
+ setPortalContainer(document.body);
}, []);
- const actionText = isCopyMode ? t('common.copyTo', '复制到') : t('common.moveTo', '移动到');
-
- if (!portalContainer) {
- return ;
- }
+ const actionText = isCopyMode ? '复制到' : '移动到';
- return (
- <>
-
+ if (!portalContainer) return null;
- {createPortal(
-
- {isCopyMode ?
:
}
+ return createPortal(
+
+ {isCopyMode ?
:
}
-
{actionText}
+
{actionText}
- {draggedCount > 1 &&
{draggedCount}}
-
,
- portalContainer
- )}
- >
+ {draggedCount > 1 &&
{draggedCount}}
+
,
+ portalContainer
);
};
diff --git a/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.module.css b/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.module.css
deleted file mode 100644
index 88ba5e6..0000000
--- a/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.module.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.syncButtons > button {
- padding-left: 0;
- padding-right: 0;
- text-wrap: nowrap;
- white-space: nowrap;
-}
diff --git a/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.tsx b/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.tsx
index e93a84d..74a1939 100644
--- a/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.tsx
+++ b/frontend/src/editor/modules/lyric-editor/components/TouchSyncPanel/index.tsx
@@ -1,7 +1,6 @@
-import { Button, Card, Grid } from '@radix-ui/themes';
import { useStore } from 'jotai';
import type { FC } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
import {
keyMoveNextWordAtom,
keyMovePrevWordAtom,
@@ -10,18 +9,15 @@ import {
keySyncStartAtom,
} from '$/states/keybindings.ts';
import { forceInvokeKeyBindingAtom } from '$/utils/keybindings.ts';
-import styles from './index.module.css';
export const TouchSyncPanel: FC = () => {
const store = useStore();
- const { t } = useTranslation();
return (
-
-
+
+
-
-
+
+
);
};
diff --git a/frontend/src/editor/modules/lyric-editor/components/index.module.css b/frontend/src/editor/modules/lyric-editor/components/index.module.css
index 6381214..9021dbe 100644
--- a/frontend/src/editor/modules/lyric-editor/components/index.module.css
+++ b/frontend/src/editor/modules/lyric-editor/components/index.module.css
@@ -113,7 +113,7 @@
display: flex;
gap: 0.25em;
- & :global(.rt-TextFieldRoot) {
+ & input {
min-width: 10em;
}
}
@@ -129,7 +129,7 @@
color: var(--white-a6);
}
-:global(.radix-themes.light) {
+:global(html:not(.dark)) {
.startTime,
.endTime {
color: var(--white-a10);
@@ -226,9 +226,9 @@
opacity: 0;
transition: opacity 0.2s ease-in-out;
- &:has(> input:active),
- &:has(> input:focus),
- &:has(> input:hover),
+ &:active,
+ &:focus,
+ &:hover,
&.empty {
opacity: 1;
}
@@ -445,7 +445,7 @@
background-color: var(--orange-a5);
}
-:global(.radix-themes.light) .lyricWord.selected {
+:global(html:not(.dark)) .lyricWord.selected {
.startTime,
.endTime {
color: var(--white-a12);
@@ -537,7 +537,7 @@
filter: saturate(0.35);
}
-.annotationRemoved :global(.rt-TextFieldRoot),
+.annotationRemoved input,
.annotationRemoved .displayWord {
text-decoration: line-through;
text-decoration-color: var(--red-a8);
diff --git a/frontend/src/editor/modules/lyric-editor/components/index.tsx b/frontend/src/editor/modules/lyric-editor/components/index.tsx
index 187f447..c653356 100644
--- a/frontend/src/editor/modules/lyric-editor/components/index.tsx
+++ b/frontend/src/editor/modules/lyric-editor/components/index.tsx
@@ -10,7 +10,6 @@
*/
import { LayoutGroup } from 'framer-motion';
-import { Box, ContextMenu, Flex, Text } from '@radix-ui/themes';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { splitAtom } from 'jotai/utils';
import { useSetImmerAtom } from 'jotai-immer';
@@ -24,8 +23,13 @@ import {
useMemo,
useRef,
} from 'react';
-import { useTranslation } from 'react-i18next';
import { ViewportList, type ViewportListRef } from 'react-viewport-list';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu';
import { useFileOpener } from '$/hooks/useFileOpener.ts';
import { audioEngine } from '$/modules/audio/audio-engine.ts';
import { useLyricListDrag } from '$/modules/lyric-drag/useLyricListDrag';
@@ -84,7 +88,6 @@ export const LyricLinesView: FC = forwardRef((_props, ref) => {
const playbackLocatedLineIndex = useAtomValue(playbackLocatedLineIndexAtom);
const playbackHighlightedLineId = useAtomValue(playbackHighlightedLineIdAtom);
const setPlaybackCurrentTime = useSetAtom(playbackCurrentTimeAtom);
- const { t } = useTranslation();
const { openFile } = useFileOpener();
const handlePasteTTML = useCallback(async () => {
@@ -96,9 +99,9 @@ export const LyricLinesView: FC = forwardRef((_props, ref) => {
});
openFile(file, 'ttml');
} catch (e) {
- lyricLinesViewLogger.error(t('error.pasteClipboardFailed', '读取剪贴板失败'), e);
+ lyricLinesViewLogger.error('读取剪贴板失败', e);
}
- }, [openFile, t]);
+ }, [openFile]);
const handleNewLine = useCallback(() => {
editLyricLines((state) => {
@@ -207,30 +210,17 @@ export const LyricLinesView: FC = forwardRef((_props, ref) => {
const innerView =
editLyric.length === 0 ? (
-
- {t('app.empty.title', '没有歌词行')}
-
- {t('app.empty.description', '在顶部面板中添加新歌词行或从菜单栏打开 / 导入已有歌词')}
-
-
+ 没有歌词行
+ 在顶部面板中添加新歌词行或从菜单栏打开 / 导入已有歌词
+
) : (
-
-
+ ((_props, ref) => {
)}
-
-
+
+
);
return (
-
-
+
{innerView}
-
-
-
- {t('contextMenu.pasteTTML', '粘贴 TTML')}
-
-
- {t('contextMenu.newLine', '新建行')}
-
-
-
+
+
+ 粘贴 TTML
+ 新建行
+
+
);
});
diff --git a/frontend/src/editor/modules/lyric-editor/components/line-marker-icons.tsx b/frontend/src/editor/modules/lyric-editor/components/line-marker-icons.tsx
new file mode 100644
index 0000000..47cd907
--- /dev/null
+++ b/frontend/src/editor/modules/lyric-editor/components/line-marker-icons.tsx
@@ -0,0 +1,37 @@
+/*
+ * 背景歌词 / 对唱歌词行标记图标
+ */
+
+const SIZE = 20;
+
+/** 背景歌词标记,`VideoBackgroundEffectFilled`,蓝色 */
+export function BackgroundLyricIcon({ size = SIZE, color = '#4466FF' }) {
+ return (
+
+ );
+}
+
+/** 对唱歌词标记,`TextAlignRightFilled`,绿色 */
+export function DuetLyricIcon({ size = SIZE, color = '#44AA33' }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/editor/modules/lyric-editor/components/lyric-line-view.tsx b/frontend/src/editor/modules/lyric-editor/components/lyric-line-view.tsx
index 7de6b74..acd7154 100644
--- a/frontend/src/editor/modules/lyric-editor/components/lyric-line-view.tsx
+++ b/frontend/src/editor/modules/lyric-editor/components/lyric-line-view.tsx
@@ -9,15 +9,11 @@
* https://github.com/amll-dev/amll-ttml-tool/blob/main/LICENSE
*/
-import {
- AddFilled,
- LinkMultiple20Regular,
- People24Regular,
- TextAlignRightFilled,
- VideoBackgroundEffectFilled,
-} from '@fluentui/react-icons';
-import { Button, ContextMenu, Flex, IconButton, Text, TextField } from '@radix-ui/themes';
-import classNames from 'classnames';
+import { Link2, Plus, Users } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import type { Draft } from 'immer';
import { type Atom, atom, useAtomValue, useStore } from 'jotai';
@@ -35,7 +31,6 @@ import {
useRef,
useState,
} from 'react';
-import { useTranslation } from 'react-i18next';
import { LyricLineMenu } from '$/components/Menus/lyric-line-menu.tsx';
import { predictLineRomanization } from '$/modules/segmentation/utils/Transliteration/distributor';
import {
@@ -60,6 +55,7 @@ import { type LyricLine, newLyricLine, newLyricWord, type TTMLLyric } from '$/ty
import { containsRadicalChar } from '$/utils/detect-radical.ts';
import { msToTimestamp } from '@/lib/format';
import styles from './index.module.css';
+import { BackgroundLyricIcon, DuetLyricIcon } from './line-marker-icons.tsx';
import LyricWordView from './lyric-word-view.tsx';
import { RomanWordView } from './roman-word-view.tsx';
@@ -178,7 +174,6 @@ const SubLineEdit = memo(
const line = useAtomValue(lineAtom);
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState('');
- const { t } = useTranslation();
const onEnter = useCallback(
(evt: SyntheticEvent) => {
@@ -228,17 +223,11 @@ const SubLineEdit = memo(
}
}, [editing, line, type]);
- const label = useMemo(
- () =>
- type === 'translatedLyric'
- ? t('lyricLineView.translatedLabel', '翻译:')
- : t('lyricLineView.romanLabel', '音译:'),
- [type, t]
- );
+ const label = useMemo(() => (type === 'translatedLyric' ? '翻译:' : '音译:'), [type]);
return (
-
- {label}
+
+
{label}
{editing ? (
- setInputValue(evt.currentTarget.value)}
onBlur={onEnter}
@@ -273,24 +261,18 @@ const SubLineEdit = memo(
) : (
)}
-
+
);
}
);
@@ -306,7 +288,6 @@ export const LyricLineView: FC<{
allowDrag?: boolean
) => void;
}> = memo(({ lineAtom, lineIndex, playbackHighlightedLineId, onPointerDown }) => {
- const { t } = useTranslation();
const lyricState = useAtomValue(lyricLinesAtom);
/*
* `?? []` 每次渲染都会产生**新的数组引用**,会让下面两个 useMemo
@@ -569,13 +550,9 @@ export const LyricLineView: FC<{
/>
{enableInsert && (
)}
- {
if (opened) {
if (!store.get(selectedLinesAtom).has(line.id)) {
@@ -596,12 +573,10 @@ export const LyricLineView: FC<{
}
}}
>
-
-
+ {
const target = evt.target as HTMLElement | null;
- if (
- target?.closest(
- "button, input, textarea, select, a, .rt-TextFieldRoot, [contenteditable='true']"
- )
- ) {
+ if (target?.closest("button, input, textarea, select, a, [contenteditable='true']")) {
return;
}
if (toolMode === ToolMode.Edit || toolMode === ToolMode.Sync) {
@@ -642,30 +611,29 @@ export const LyricLineView: FC<{
}}
/>
)}
-
-
+
{displayNumber}
-
- {line.isBG && }
- {line.isDuet && }
-
+
+ {line.isBG && }
+ {line.isDuet && }
+
(
))}
{enableInsert && (
- {
evt.preventDefault();
evt.stopPropagation();
@@ -703,15 +672,13 @@ export const LyricLineView: FC<{
});
}}
>
-
-
+
+
)}
-
)}
-
+
);
})}
@@ -742,7 +709,7 @@ export const LyricLineView: FC<{
.map((ghost) => (
))}
{enableInsert && (
- {
evt.preventDefault();
evt.stopPropagation();
@@ -764,13 +732,14 @@ export const LyricLineView: FC<{
});
}}
>
-
-
+
+
)}
{toolMode === ToolMode.Edit && (
-
)}
{vocalTagIds.length > 0 && (
-
- {t('lyricLineView.vocalTagsLabel', '演唱者:')}
-
+
+
演唱者:
+
{(() => {
const selectedIds = parseLineVocalIds(line.vocal);
const selectedSet = new Set(selectedIds);
@@ -834,10 +803,8 @@ export const LyricLineView: FC<{
return (
+
)}
>
)}
{toolMode === ToolMode.Edit && (
-
-
+ {
evt.preventDefault();
evt.stopPropagation();
setEnableInsert((v) => !v);
}}
>
-
-
-
+
+
+
)}
{toolMode === ToolMode.Sync && showTimestamps && (
-
+
{msToTimestamp(line.startTime)}
-
+
{endTimeLinked ? (
-
+
) : showEndTimeAsDuration ? (
`+${line.endTime - line.startTime}ms`
) : (
@@ -928,23 +889,19 @@ export const LyricLineView: FC<{
)}
-
+
)}
-
-
-
+
+
+
-
-
+
+
{enableInsert && (
{
editLyricLines((state: Draft) => {
state.lyricLines.splice(lineIndex + 1, 0, newLyricLine());
@@ -953,7 +910,7 @@ export const LyricLineView: FC<{
setEnableInsert(false);
}}
>
- {t('lyricLineView.insertLine', '在此插入新行')}
+ 在此插入新行
)}
>
diff --git a/frontend/src/editor/modules/lyric-editor/components/lyric-word-menu.tsx b/frontend/src/editor/modules/lyric-editor/components/lyric-word-menu.tsx
index 6b6fc89..3aadf48 100644
--- a/frontend/src/editor/modules/lyric-editor/components/lyric-word-menu.tsx
+++ b/frontend/src/editor/modules/lyric-editor/components/lyric-word-menu.tsx
@@ -1,7 +1,6 @@
-import { ContextMenu } from '@radix-ui/themes';
import { type Atom, atom, useAtomValue, useSetAtom, useStore } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
-import { useTranslation } from 'react-i18next';
+import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { replaceWordDialogAtom, splitWordDialogAtom } from '$/states/dialogs';
import {
editingWordStateAtom,
@@ -24,8 +23,6 @@ export const LyricWordMenu = ({
wordAtom: Atom;
lineIndex: number;
}) => {
- const { t } = useTranslation();
-
const store = useStore();
const selectedWordsSize = useAtomValue(selectedWordsSizeAtom);
const selectedLinesSize = useAtomValue(selectedLinesSizeAtom);
@@ -37,7 +34,7 @@ export const LyricWordMenu = ({
return (
<>
- {
setEditingWordState({
@@ -48,9 +45,9 @@ export const LyricWordMenu = ({
setOpenSplitWordDialog(true);
}}
>
- {t('contextMenu.splitWord', '拆分单词…')}
-
-
+ {
setEditingWordState({
@@ -61,9 +58,9 @@ export const LyricWordMenu = ({
setOpenReplaceWordDialog(true);
}}
>
- {t('contextMenu.replaceWord', '替换单词…')}
-
-
+ 1 && selectedLinesSize === 1)}
onSelect={() => {
editLyricLines((state) => {
@@ -93,10 +90,10 @@ export const LyricWordMenu = ({
});
}}
>
- {t('contextMenu.combineWords', '合并单词')}
-
+ 合并单词
+
- {
editLyricLines((state) => {
@@ -110,26 +107,20 @@ export const LyricWordMenu = ({
});
}}
>
- {t('contextMenu.deleteWords', {
- count: selectedWordsSize,
- defaultValue: '删除选定单词',
- })}
-
+ 删除选定单词
+
-
+
- afterToNewLine()}>
- {t('contextMenu.moveFollowingWordToNewLine', '此后单词拆至新行')}
-
+ afterToNewLine()}>
+ 此后单词拆至新行
+
- selectedToNewLine()}>
- {t('contextMenu.moveWordToNewLine', {
- count: selectedWordsSize,
- defaultValue: '所选单词拆至新行',
- })}
-
+ selectedToNewLine()}>
+ 所选单词拆至新行
+
-
+
>
);
diff --git a/frontend/src/editor/modules/lyric-editor/components/lyric-word-view.tsx b/frontend/src/editor/modules/lyric-editor/components/lyric-word-view.tsx
index ecd5f58..8ae7a3c 100644
--- a/frontend/src/editor/modules/lyric-editor/components/lyric-word-view.tsx
+++ b/frontend/src/editor/modules/lyric-editor/components/lyric-word-view.tsx
@@ -10,18 +10,21 @@
*/
import {
- CutRegular,
- DeleteRegular,
- PaddingLeftRegular,
- PaddingRightRegular,
- SplitVerticalRegular,
- TaskListLtrRegular,
-} from '@fluentui/react-icons';
-import { ContextMenu, IconButton, TextField } from '@radix-ui/themes';
-import classNames from 'classnames';
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Columns2,
+ ListChecks,
+ Scissors,
+ Trash2,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
import { type Atom, atom, useAtomValue, useSetAtom, useStore } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
import {
+ type ComponentPropsWithoutRef,
type FC,
type MouseEvent,
memo,
@@ -34,7 +37,6 @@ import {
useRef,
useState,
} from 'react';
-import { useTranslation } from 'react-i18next';
import { LyricLineMenu } from '$/components/Menus/lyric-line-menu.tsx';
import { audioEngine } from '$/modules/audio/audio-engine.ts';
import {
@@ -84,18 +86,14 @@ const parseRubyShortcut = (value: string) => {
};
const getDisplayWordText = (
- t: (key: string, defaultValue: string, options?: { count?: number }) => string,
word: string,
isWordBlank: boolean,
romanWord?: string,
displayRomanizationInSync?: boolean
) => {
if (displayRomanizationInSync && romanWord && romanWord.trim() !== '') return romanWord;
- if (word === '') return t('lyricWordView.empty', '空白');
- if (isWordBlank)
- return t('lyricWordView.spaceCount', '空格 x{count}', {
- count: word.length,
- });
+ if (word === '') return '空白';
+ if (isWordBlank) return `空格 x${word.length}`;
return word;
};
@@ -182,7 +180,7 @@ const LyricWordViewEditSpan = ({
}
return (
- {
if (!open) return;
if (isWordSelected) return;
@@ -196,7 +194,7 @@ const LyricWordViewEditSpan = ({
});
}}
>
-
+
{
@@ -303,12 +301,12 @@ const LyricWordViewEditSpan = ({
>
{children}
-
-
+
+
-
-
+
+
);
};
@@ -318,17 +316,16 @@ function WordEditField({
formatter,
parser,
// textFieldStyle,
- children,
+ leadingIcon,
...other
-}: PropsWithChildren<
- {
- wordAtom: Atom;
- fieldName: F;
- formatter: (v: V) => string;
- parser: (v: string) => V;
- textFieldStyle?: React.CSSProperties;
- } & TextField.RootProps
->) {
+}: {
+ wordAtom: Atom;
+ fieldName: F;
+ formatter: (v: V) => string;
+ parser: (v: string) => V;
+ textFieldStyle?: React.CSSProperties;
+ leadingIcon?: React.ReactNode;
+} & ComponentPropsWithoutRef<'input'>) {
const [fieldInput, setFieldInput] = useState(undefined);
const [fieldPlaceholder, setFieldPlaceholder] = useState('');
@@ -382,24 +379,29 @@ function WordEditField({
}, [currentValue]);
return (
- setFieldInput(evt.currentTarget.value)}
- onKeyDown={(evt) => {
- if (evt.key !== 'Enter') return;
- onInputFinished(evt.currentTarget.value);
- }}
- onBlur={(evt) => {
- if (evt.currentTarget.value === currentValue) return;
- onInputFinished(evt.currentTarget.value);
- }}
- {...other}
- >
- {children}
-
+
+ {leadingIcon && (
+
+ {leadingIcon}
+
+ )}
+ setFieldInput(evt.currentTarget.value)}
+ onKeyDown={(evt) => {
+ if (evt.key !== 'Enter') return;
+ onInputFinished(evt.currentTarget.value);
+ }}
+ onBlur={(evt) => {
+ if (evt.currentTarget.value === currentValue) return;
+ onInputFinished(evt.currentTarget.value);
+ }}
+ className={cn('h-7 text-xs', leadingIcon && 'pl-6')}
+ {...other}
+ />
+
);
}
@@ -433,7 +435,7 @@ const LyricWordViewEditAdvance = ({
const className = useMemo(
() =>
- classNames(
+ cn(
styles.lyricWord,
styles.edit,
styles.advance,
@@ -447,8 +449,8 @@ const LyricWordViewEditAdvance = ({
);
return (
-
-
+
+
-
-
-
-
+ leadingIcon={}
+ />
- {
setSplitState({
wordIndex,
@@ -484,10 +482,9 @@ const LyricWordViewEditAdvance = ({
setOpenSplitWordDialog(true);
}}
>
-
-
+
+
- {
editLyricLines((state) => {
const selectedWords = store.get(selectedWordsAtom);
@@ -508,8 +506,8 @@ const LyricWordViewEditAdvance = ({
});
}}
>
-
-
+
+
-
-
-
-
+ leadingIcon={}
+ />
-
-
-
-
- }
+ />
+ {
editLyricLines((state) => {
for (const line of state.lyricLines)
@@ -562,21 +552,20 @@ const LyricWordViewEditAdvance = ({
});
}}
>
-
-
+
+
-
-
+
+
-
-
+
+
);
};
const LyricWorldViewEdit = ({ wordAtom, wordIndex, line, lineIndex }: LyricWordViewEditProps) => {
- const { t } = useTranslation();
const word = useAtomValue(wordAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
const setSelectedLines = useSetImmerAtom(selectedLinesAtom);
@@ -589,7 +578,7 @@ const LyricWorldViewEdit = ({ wordAtom, wordIndex, line, lineIndex }: LyricWordV
const [editing, setEditing] = useState(false);
const toolMode = useAtomValue(toolModeAtom);
const isWordBlank = useWordBlank(word.word);
- const displayWord = getDisplayWordText(t, word.word, isWordBlank);
+ const displayWord = getDisplayWordText(word.word, isWordBlank);
const showRubyEditor = useMemo(() => word.ruby !== undefined, [word.ruby]);
const hasError = useMemo(() => word.startTime > word.endTime, [word.startTime, word.endTime]);
@@ -598,7 +587,7 @@ const LyricWorldViewEdit = ({ wordAtom, wordIndex, line, lineIndex }: LyricWordV
const className = useMemo(
() =>
- classNames(
+ cn(
styles.lyricWord,
styles.edit,
isWordSelected && styles.selected,
@@ -631,7 +620,7 @@ const LyricWorldViewEdit = ({ wordAtom, wordIndex, line, lineIndex }: LyricWordV
return editing ? (
-
) : (
- {
if (!open) return;
if (isWordSelected) return;
@@ -657,7 +646,7 @@ const LyricWorldViewEdit = ({ wordAtom, wordIndex, line, lineIndex }: LyricWordV
});
}}
>
-
+
}
-
-
+
+
-
-
+
+
);
};
@@ -788,7 +777,7 @@ const LyricSyncWordView: FC<{
const className = useMemo(
() =>
- classNames(
+ cn(
styles.lyricWord,
styles.sync,
isWordSelected && styles.selected,
@@ -820,13 +809,13 @@ const LyricSyncWordView: FC<{
}}
>
{showTimestamps && (
-
+
{msToTimestamp(startTime)}
)}
{displayWord}
{showTimestamps && (
-
+
{showEndTimeAsDuration
? `+${formatDurationMs(endTime - startTime, { suffix: true })}`
: msToTimestamp(endTime)}
@@ -842,14 +831,13 @@ const LyricWorldViewSync: FC<{
line: LyricLine;
lineIndex: number;
}> = ({ wordAtom, line }) => {
- const { t } = useTranslation();
const word = useAtomValue(wordAtom);
const displayRomanizationInSync = useAtomValue(displayRomanizationInSyncAtom);
const isWordBlank = useWordBlank(word.word);
const getDisplayWord = useCallback(
(displayText: string, isBlank: boolean, romanWord?: string, showRomanization?: boolean) =>
- getDisplayWordText(t, displayText, isBlank, romanWord, showRomanization),
- [t]
+ getDisplayWordText(displayText, isBlank, romanWord, showRomanization),
+ []
);
if (word.ruby && word.ruby.length > 0) {
diff --git a/frontend/src/editor/modules/lyric-editor/components/roman-word-view.module.css b/frontend/src/editor/modules/lyric-editor/components/roman-word-view.module.css
deleted file mode 100644
index 7f81646..0000000
--- a/frontend/src/editor/modules/lyric-editor/components/roman-word-view.module.css
+++ /dev/null
@@ -1,27 +0,0 @@
-.romanWordView {
- display: inline-block;
- padding: 0.25em 0.5em;
- border-radius: var(--radius-4);
- text-align: left;
-
- background-color: var(--color-panel);
- box-shadow: var(--base-card-surface-box-shadow);
-}
-
-.romanWordView.placeholder {
- color: var(--gray-a6);
- font-style: italic;
-}
-
-.romanWordView:has(input) {
- background-color: var(--color-component-background);
-}
-
-.romanWordView :global(input) {
- text-align: left;
- font-size: var(--font-size-1);
-}
-
-.warning {
- background-color: var(--orange-a3);
-}
diff --git a/frontend/src/editor/modules/lyric-editor/components/roman-word-view.tsx b/frontend/src/editor/modules/lyric-editor/components/roman-word-view.tsx
index 93fc60b..ed2f45c 100644
--- a/frontend/src/editor/modules/lyric-editor/components/roman-word-view.tsx
+++ b/frontend/src/editor/modules/lyric-editor/components/roman-word-view.tsx
@@ -1,12 +1,12 @@
-import { Button, TextField } from '@radix-ui/themes';
-import classNames from 'classnames';
import { type Atom, useAtom, useAtomValue, type WritableAtom } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
import { lyricLinesAtom } from '$/states/main';
import type { LyricWord } from '$/types/ttml';
import { applyGeneratedRuby } from '$/modules/lyric-editor/utils/ruby-generator';
-import styles from './roman-word-view.module.css';
interface RomanWordViewProps {
wordAtom: Atom
;
@@ -91,10 +91,12 @@ export const RomanWordView = ({
if (isEditing) {
return (
- setInputValue(e.currentTarget.value)}
onBlur={(e) => saveAndStopEditing(e.currentTarget.value)}
@@ -105,13 +107,11 @@ export const RomanWordView = ({
return (
{
e.stopPropagation();
diff --git a/frontend/src/editor/modules/lyric-editor/tools/ReplaceWordDialog.tsx b/frontend/src/editor/modules/lyric-editor/tools/ReplaceWordDialog.tsx
index 93a0a95..e06598e 100644
--- a/frontend/src/editor/modules/lyric-editor/tools/ReplaceWordDialog.tsx
+++ b/frontend/src/editor/modules/lyric-editor/tools/ReplaceWordDialog.tsx
@@ -1,13 +1,22 @@
-import { Button, Checkbox, Dialog, Flex, Text, TextField } from '@radix-ui/themes';
import { useAtom, useAtomValue } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
import { memo, useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
import { replaceWordDialogAtom } from '$/states/dialogs';
import { editingWordStateAtom, lyricLinesAtom } from '$/states/main';
export const ReplaceWordDialog = memo(() => {
- const { t } = useTranslation();
const [isOpen, setIsOpen] = useAtom(replaceWordDialogAtom);
const editingState = useAtomValue(editingWordStateAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
@@ -61,64 +70,52 @@ export const ReplaceWordDialog = memo(() => {
};
return (
-
-
- {t('replaceWordDialog.title', '替换单词')}
-
- {t('replaceWordDialog.description', '新的单词内容:')}
-
+
-
-
-
- {t('common.cancel', '取消')}
-
-
- {t('common.confirm', '确认')}
-
-
-
+
+
+ 取消
+
+ 确认
+
+
+
);
});
diff --git a/frontend/src/editor/modules/lyric-editor/tools/RubyEditor.tsx b/frontend/src/editor/modules/lyric-editor/tools/RubyEditor.tsx
index 057868d..ba5cdba 100644
--- a/frontend/src/editor/modules/lyric-editor/tools/RubyEditor.tsx
+++ b/frontend/src/editor/modules/lyric-editor/tools/RubyEditor.tsx
@@ -1,10 +1,11 @@
-import { Add20Regular, TranslateRegular } from '@fluentui/react-icons';
-import { IconButton, TextField } from '@radix-ui/themes';
-import classNames from 'classnames';
import type { Draft } from 'immer';
import { type Atom, useAtomValue, useStore } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
+import { Languages, Plus } from 'lucide-react';
import { type ComponentPropsWithoutRef, useCallback, useRef } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
import { recalculateWordTime } from '$/modules/segmentation/utils/segmentation.ts';
import { useSegmentationConfig } from '$/modules/segmentation/utils/useSegmentationConfig.ts';
import { lyricLinesAtom } from '$/states/main.ts';
@@ -18,7 +19,7 @@ const AutoSizeTextField = ({
placeholder,
inputRef,
...rest
-}: ComponentPropsWithoutRef
& {
+}: ComponentPropsWithoutRef<'input'> & {
inputRef?: React.Ref;
}) => {
const valueString = value === undefined || value === null ? '' : String(value);
@@ -29,9 +30,9 @@ const AutoSizeTextField = ({
? placeholder
: ' ';
return (
-
+
{mirrorText}
-
+
{showIcon && (
-
-
-
+
+
+
)}
{rubyWords.map((rubyWord, index) => (
{
inputRefs.current[index] = el;
}}
@@ -209,9 +215,15 @@ export const RubyEditor = ({
}}
/>
))}
-
-
-
+
+
+
);
};
diff --git a/frontend/src/editor/modules/lyric-editor/tools/TimeShift.tsx b/frontend/src/editor/modules/lyric-editor/tools/TimeShift.tsx
index 6d2e0d2..a8153f2 100644
--- a/frontend/src/editor/modules/lyric-editor/tools/TimeShift.tsx
+++ b/frontend/src/editor/modules/lyric-editor/tools/TimeShift.tsx
@@ -1,11 +1,19 @@
/**
* @description “平移时间” 模态框
*/
-import { ArrowLeftRegular, ArrowRightRegular } from '@fluentui/react-icons';
-import { Button, Dialog, Flex, IconButton, RadioGroup, Text, TextField } from '@radix-ui/themes';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { ArrowLeft, ArrowRight } from 'lucide-react';
+import { useEffect, useId, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Radio, RadioGroup } from '@/components/ui/radio-group';
import { timeShiftDialogAtom } from '$/states/dialogs.ts';
import {
lyricLinesAtom,
@@ -20,7 +28,6 @@ type ShiftDirection = 'delay' | 'advance';
type ShiftScope = 'all' | 'selected' | 'selected-following' | 'custom';
export const TimeShiftDialog = () => {
- const { t } = useTranslation();
const [open, setOpen] = useAtom(timeShiftDialogAtom);
const lyricLines = useAtomValue(lyricLinesAtom);
useAtomValue(undoableLyricLinesAtom);
@@ -28,6 +35,9 @@ export const TimeShiftDialog = () => {
const setLyricLines = useSetAtom(undoableLyricLinesAtom);
const pushReviewOperation = useSetAtom(pushReviewOperationAtom);
+ const directionName = useId();
+ const scopeName = useId();
+
const [offsetStr, setOffsetStr] = useState('100');
const [direction, setDirection] = useState('delay');
const [scope, setScope] = useState('all');
@@ -140,106 +150,142 @@ export const TimeShiftDialog = () => {
};
return (
-
-
- {t('timeShiftDialog.title', '平移时间')}
-
-
-
- {t('timeShiftDialog.amount', '偏移量 (ms)')}
-
-
- adjustOffset(-50)} title="- 50ms">
-
-
-
+
+ 平移时间
+
+
+
偏移量 (ms)
+
+
adjustOffset(-50)}
+ title="- 50ms"
+ >
+
+
+
setOffsetStr(e.target.value)}
placeholder="100"
- style={{ flexGrow: 1 }}
+ className="flex-1"
/>
-
adjustOffset(50)} title="+ 50ms">
-
-
-
-
-
-
-
- {t('timeShiftDialog.direction', '方向')}
-
- setDirection(v as ShiftDirection)}
- style={{ flexDirection: 'row', gap: '16px' }}
- >
-
- {t('timeShiftDialog.advance', '提前 (-)')}
-
-
- {t('timeShiftDialog.delay', '延后 (+)')}
-
-
-
-
-
-
- {t('timeShiftDialog.scopeLabel', '应用于')}
-
- setScope(v as ShiftScope)}>
-
- {t('timeShiftDialog.scope.all', '所有行')}
-
-
-
- {t('timeShiftDialog.scope.selected', '所选行')}
+ adjustOffset(50)}
+ title="+ 50ms"
+ >
+
+
+
+
+
+
+ 方向
+
+
+
+
+
+
+
+ 应用于
+
+
+
+
-
- {t('timeShiftDialog.scope.selectedFollowing', '所选行及其后续')}
-
+
-
- {t('timeShiftDialog.scope.custom', '自定义范围')}
-
-
-
+
+
+
{scope === 'custom' && (
-
- {t('timeShiftDialog.fromLine', '从')}
-
+ 从
+ setCustomStart(e.target.value)}
/>
- {t('timeShiftDialog.toLine', '行 到')}
- 行 到
+ setCustomEnd(e.target.value)}
/>
- {t('timeShiftDialog.line', '行')}
-
+
行
+
)}
-
-
-
-
-
- {t('common.cancel', '取消')}
-
-
- {t('common.apply', '应用')}
-
-
-
+
+
+
+
+ 取消
+
+ 应用
+
+
+
);
};
diff --git a/frontend/src/editor/modules/lyric-editor/tools/i18nEditor.tsx b/frontend/src/editor/modules/lyric-editor/tools/i18nEditor.tsx
index 05851e2..01c77e2 100644
--- a/frontend/src/editor/modules/lyric-editor/tools/i18nEditor.tsx
+++ b/frontend/src/editor/modules/lyric-editor/tools/i18nEditor.tsx
@@ -1,18 +1,17 @@
-import { Add16Regular } from '@fluentui/react-icons';
-import { Grid, IconButton, Select, Text } from '@radix-ui/themes';
import { useAtomValue, useSetAtom } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
+import { Plus } from 'lucide-react';
import { type FC, useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Select } from '@/components/ui/select';
import { addLanguageDialogAtom } from '$/states/dialogs';
import { lyricLinesAtom } from '$/states/main.ts';
export const I18nEditor: FC = () => {
- const { t } = useTranslation();
const lyricLines = useAtomValue(lyricLinesAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
const setAddLanguageDialog = useSetAtom(addLanguageDialogAtom);
- const placeholder = t('ribbonBar.editMode.multilingualPlaceholder', '请选择语言');
+ const placeholder = '请选择语言';
/** 只有一个 und 条目时,说明这行没有指定语言,退回 und 处理。 */
const isOnlyUndEntry = (entries: [string, string][]) =>
@@ -325,85 +324,82 @@ export const I18nEditor: FC = () => {
}, [applyWordRomanizationLang, editLyricLines, setAddLanguageDialog]);
return (
-
-
- {t('ribbonBar.editMode.translation', '翻译')}
-
-
+ 翻译
+
-
+ {placeholder}
+
+ {translationLanguages.map((lang) => (
+
+ ))}
+
+
-
-
-
- {t('ribbonBar.editMode.romanization', '音译')}
-
-
+
+ 音译
+
+
-
-
-
- {t('ribbonBar.editMode.wordRomanization', '逐字音译')}
-
-
+
+ 逐字音译
+
+
-
-
-
+
+
+
);
};
diff --git a/frontend/src/editor/modules/project/modals/GlobalDragOverlay.tsx b/frontend/src/editor/modules/project/modals/GlobalDragOverlay.tsx
index 00a1c93..1d0c41e 100644
--- a/frontend/src/editor/modules/project/modals/GlobalDragOverlay.tsx
+++ b/frontend/src/editor/modules/project/modals/GlobalDragOverlay.tsx
@@ -2,16 +2,13 @@
* @description 拖拽文件到应用上方时显示的遮罩层
*/
-import { ArrowUploadRegular } from '@fluentui/react-icons';
-import { Flex, Text } from '@radix-ui/themes';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtomValue } from 'jotai';
-import { useTranslation } from 'react-i18next';
+import { Upload } from 'lucide-react';
import { isGlobalFileDraggingAtom } from '$/states/main';
export const GlobalDragOverlay = () => {
const isDragging = useAtomValue(isGlobalFileDraggingAtom);
- const { t } = useTranslation();
return (
@@ -29,19 +26,13 @@ export const GlobalDragOverlay = () => {
pointerEvents: 'none',
}}
>
-
-
-
- {t('globalDragOverlay.title', '拖放到此处以加载')}
-
-
+
+ 拖放到此处以加载
+
)}
diff --git a/frontend/src/editor/modules/project/modals/MetadataEditor.module.css b/frontend/src/editor/modules/project/modals/MetadataEditor.module.css
index d99d931..45fa57b 100644
--- a/frontend/src/editor/modules/project/modals/MetadataEditor.module.css
+++ b/frontend/src/editor/modules/project/modals/MetadataEditor.module.css
@@ -101,14 +101,19 @@
}
.navBadge {
+ display: inline-flex;
min-width: 18px;
height: 18px;
+ align-items: center;
+ justify-content: center;
padding: 0 6px;
color: var(--accent-11);
font-size: 12px;
+ font-variant-numeric: tabular-nums;
line-height: 18px;
text-align: center;
- background: var(--accent-a4);
+ background: var(--accent-a2);
+ border: 1px solid var(--accent-a5);
border-radius: var(--radius-full);
}
diff --git a/frontend/src/editor/modules/project/modals/MetadataEditor.tsx b/frontend/src/editor/modules/project/modals/MetadataEditor.tsx
index 0fe001c..ea49b8f 100644
--- a/frontend/src/editor/modules/project/modals/MetadataEditor.tsx
+++ b/frontend/src/editor/modules/project/modals/MetadataEditor.tsx
@@ -1,30 +1,29 @@
import {
- Add16Regular,
- AlbumRegular,
- Delete16Regular,
- GlobeSearch20Regular,
- Info16Regular,
- MusicNote1Regular,
- NumberSymbol16Regular,
- Open16Regular,
- Person16Regular,
- Sparkle20Regular,
-} from '@fluentui/react-icons';
-import {
- Button,
- Dialog,
- Flex,
- Heading,
- IconButton,
- Spinner,
- Text,
- TextField,
-} from '@radix-ui/themes';
+ Disc3,
+ ExternalLink,
+ Globe,
+ Hash,
+ Info,
+ Loader2,
+ Music,
+ Plus,
+ Sparkles,
+ Trash2,
+ User,
+} from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtom, useAtomValue } from 'jotai';
import { useImmerAtom } from 'jotai-immer';
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
import {
getMeatdataSuggestion,
type MetaSuggestionResult,
@@ -113,7 +112,6 @@ const MetadataValueEditorRow = memo(
applySuggestionValues,
requestNeteaseMeta,
}: MetadataValueEditorRowProps) => {
- const { t } = useTranslation();
const [suggestions, setSuggestions] = useState([]);
const [isFocused, setIsFocused] = useState(false);
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
@@ -170,12 +168,22 @@ const MetadataValueEditorRow = memo(
const canFetchNeteaseMeta = option.value === 'ncmMusicId' && !isFocused && value.trim() !== '';
return (
-
-
+ setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={(e) => updateValue(valueIndex, e.currentTarget.value)}
@@ -205,37 +213,36 @@ const MetadataValueEditorRow = memo(
const text = e.dataTransfer.getData('text');
if (text) updateValue(valueIndex, text);
}}
- variant={hasAnyError ? 'soft' : 'surface'}
- color={
- itemHasError ? (validation?.severe ? 'red' : 'orange') : isDuplicate ? 'red' : undefined
- }
/>
{hasSuggestion && suggestions.length === 1 && suggestions[0]?.matchedValue !== value && (
- {
applySuggestionValues(suggestions[0]?.values ?? []);
setSuggestions([]);
}}
- title={t('metadataDialog.applySuggestion', '应用建议')}
+ title="应用建议"
>
-
-
+
+
)}
{hasSuggestion && suggestions.length > 1 && (
-
-
-
-
-
-
-
- {t('metadataDialog.pickSuggestion', '选择匹配项')}
-
+
)}
{canFetchNeteaseMeta && (
- {
if (isFetchingMeta) return;
@@ -264,38 +273,52 @@ const MetadataValueEditorRow = memo(
setIsFetchingMeta(false);
}
}}
- title={t('metadataDialog.fetchNeteaseMeta', '从网易云获取元数据')}
+ title="从网易云获取元数据"
>
- {isFetchingMeta ? : }
-
+ {isFetchingMeta ? : }
+
)}
{option.isLinkable && (
- {
+ /* 禁用态无操作 */
+ }
+ }
>
{isButtonEnabled ? (
-
+
) : (
-
+
)}
-
+
)}
- removeValue(valueIndex)}>
-
-
-
+ removeValue(valueIndex)}
+ title="删除"
+ >
+
+
+
);
}
);
const MetadataItemEditor = memo(
({ entry, option, setLyricLines, requestNeteaseMeta }: MetadataItemEditorProps) => {
- const { t } = useTranslation();
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const [focusIndex, setFocusIndex] = useState(null);
const [isDraggingCategory, setIsDraggingCategory] = useState(false);
@@ -468,7 +491,7 @@ const MetadataItemEditor = memo(
{values.length === 0 && (
- {t('metadataDialog.emptyItem', '此项尚未添加任何条目')}
+ 此项尚未添加任何条目
)}
{values.map((value, index) => (
@@ -497,18 +520,14 @@ const MetadataItemEditor = memo(
{validation && rowHasError && (
-
+
{validation.message}
-
+
)}
- {rowHasDuplicate && (
-
- {t('metadataDialog.duplicateMsg', '存在重复的元数据值')}
-
- )}
- addValue()}>
-
- {t('metadataDialog.addValue', '添加')}
+ {rowHasDuplicate && 存在重复的元数据值}
+ addValue()}>
+
+ 添加
);
@@ -532,7 +551,6 @@ export const MetadataEditor = () => {
[resolvedTheme]
);
- const { t } = useTranslation();
/**
* 从网易云 ID 拉取歌曲资料并填进元数据。
*
@@ -575,140 +593,116 @@ export const MetadataEditor = () => {
return [
// 歌词所匹配的歌曲名
{
- label: t('metadataDialog.builtinOptions.musicName', '歌曲名称'),
+ label: '歌曲名称',
value: 'musicName',
- icon: ,
+ icon: ,
},
// 歌词所匹配的歌手名
{
- label: t('metadataDialog.builtinOptions.artists', '歌曲的艺术家'),
+ label: '歌曲的艺术家',
value: 'artists',
- icon: ,
+ icon: ,
suggestion: true,
validation: {
verifier: (value: string) => !/^.+[,;&,;、].+$/.test(value),
- message: t(
- 'metadataDialog.builtinOptions.artistsInvalidMsg',
- '如果有多个艺术家,请多次添加该键值,避免使用分隔符'
- ),
+ message: '如果有多个艺术家,请多次添加该键值,避免使用分隔符',
},
},
// 歌词所匹配的词曲作者
{
- label: t('metadataDialog.builtinOptions.songwriter', '词曲作者'),
+ label: '词曲作者',
value: 'songwriter',
- icon: ,
+ icon: ,
validation: {
verifier: (value: string) => !/^.+[,;&,;、].+$/.test(value),
- message: t(
- 'metadataDialog.builtinOptions.songwriterInvalidMsg',
- '如果有多个词曲作者,请多次添加该键值,避免使用分隔符'
- ),
+ message: '如果有多个词曲作者,请多次添加该键值,避免使用分隔符',
},
},
// 歌词所匹配的专辑名
{
- label: t('metadataDialog.builtinOptions.album', '歌曲的专辑名'),
+ label: '歌曲的专辑名',
value: 'album',
- icon: ,
+ icon: ,
},
// 歌词所匹配的网易云音乐 ID
{
- label: t('metadataDialog.builtinOptions.ncmMusicId', '网易云音乐 ID'),
+ label: '网易云音乐 ID',
value: 'ncmMusicId',
icon: ,
isLinkable: true,
urlFormatter: (val) => getPlatformUrl('ncmMusicId', val),
validation: {
verifier: numeric,
- message: t(
- 'metadataDialog.builtinOptions.ncmMusicIdInvalidMsg',
- '网易云音乐 ID 应为纯数字'
- ),
+ message: '网易云音乐 ID 应为纯数字',
severe: true,
},
},
// 歌词所匹配的 QQ 音乐 ID
{
- label: t('metadataDialog.builtinOptions.qqMusicId', 'QQ 音乐 ID'),
+ label: 'QQ 音乐 ID',
value: 'qqMusicId',
icon: ,
isLinkable: true,
urlFormatter: (val) => getPlatformUrl('qqMusicId', val),
validation: {
verifier: alphanumeric,
- message: t(
- 'metadataDialog.builtinOptions.qqMusicIdInvalidMsg',
- 'QQ 音乐 ID 应为字母或数字'
- ),
+ message: 'QQ 音乐 ID 应为字母或数字',
severe: true,
},
},
// 歌词所匹配的 Spotify 音乐 ID
{
- label: t('metadataDialog.builtinOptions.spotifyId', 'Spotify 音乐 ID'),
+ label: 'Spotify 音乐 ID',
value: 'spotifyId',
icon: ,
isLinkable: true,
urlFormatter: (val) => getPlatformUrl('spotifyId', val),
validation: {
verifier: alphanumeric,
- message: t(
- 'metadataDialog.builtinOptions.spotifyIdInvalidMsg',
- 'Spotify ID 应为字母或数字'
- ),
+ message: 'Spotify ID 应为字母或数字',
severe: true,
},
},
// 歌词所匹配的 Apple Music 音乐 ID
{
- label: t('metadataDialog.builtinOptions.appleMusicId', 'Apple Music 音乐 ID'),
+ label: 'Apple Music 音乐 ID',
value: 'appleMusicId',
icon: ,
isLinkable: true,
urlFormatter: (val) => getPlatformUrl('appleMusicId', val),
validation: {
verifier: numeric,
- message: t(
- 'metadataDialog.builtinOptions.appleMusicIdInvalidMsg',
- 'Apple Music ID 应为纯数字'
- ),
+ message: 'Apple Music ID 应为纯数字',
severe: true,
},
},
// 歌词所匹配的 ISRC 编码
{
- label: t('metadataDialog.builtinOptions.isrc', '歌曲的 ISRC 号码'),
+ label: '歌曲的 ISRC 号码',
value: 'isrc',
- icon: ,
+ icon: ,
isLinkable: true,
urlFormatter: (val) => getPlatformUrl('isrc', val),
validation: {
verifier: (value: string) => /^[A-Z]{2}-?[A-Z0-9]{3}-?\d{2}-?\d{5}$/.test(value),
- message: t(
- 'metadataDialog.builtinOptions.isrcInvalidMsg',
- 'ISRC 编码格式应为 CC-XXX-YY-NNNNN'
- ),
+ message: 'ISRC 编码格式应为 CC-XXX-YY-NNNNN',
severe: true,
},
},
// 逐词歌词作者 GitHub ID,例如 39523898
{
- label: t('metadataDialog.builtinOptions.ttmlAuthorGithub', '歌词作者 GitHub ID'),
+ label: '歌词作者 GitHub ID',
value: 'ttmlAuthorGithub',
icon: ,
validation: {
verifier: numeric,
- message: t(
- 'metadataDialog.builtinOptions.ttmlAuthorGithubInvalidMsg',
- 'GitHub ID 应为纯数字'
- ),
+ message: 'GitHub ID 应为纯数字',
severe: true,
},
},
// 逐词歌词作者 GitHub 用户名,例如 Steve-xmh
{
- label: t('metadataDialog.builtinOptions.ttmlAuthorGithubLogin', '歌词作者 GitHub 用户名'),
+ label: '歌词作者 GitHub 用户名',
value: 'ttmlAuthorGithubLogin',
icon: ,
isLinkable: true,
@@ -716,15 +710,12 @@ export const MetadataEditor = () => {
validation: {
verifier: (value: string) =>
/^(?!.*--)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(value),
- message: t(
- 'metadataDialog.builtinOptions.ttmlAuthorGithubLoginInvalidMsg',
- 'GitHub username should be alphanumeric or hyphens, up to 39 characters'
- ),
+ message: 'GitHub username should be alphanumeric or hyphens, up to 39 characters',
severe: true,
},
},
];
- }, [t]);
+ }, []);
const customOptions: SelectOption[] = useMemo(
() =>
@@ -733,7 +724,7 @@ export const MetadataEditor = () => {
.map((metadata) => ({
label: metadata.key,
value: metadata.key,
- icon: ,
+ icon: ,
})),
[builtinOptions, lyricLines.metadata]
);
@@ -772,7 +763,7 @@ export const MetadataEditor = () => {
}, [builtinOptions, setLyricLines]);
return (
- {
setMetadataEditorDialog(open);
@@ -790,38 +781,24 @@ export const MetadataEditor = () => {
}
}}
>
- `,它在自己所在的元素上渲染出
- * `data-accent-color="indigo"`(Theme 的默认值),
- * 而 Radix 的色阶正是用这个属性选择器声明的:
- *
- * [data-accent-color='indigo'] { --accent-9: … } ← 0,1,0
- *
- * 浮层在 body 子树里,跟工作台那套 `.editor-radix-theme`
- * 覆写不在一条继承链上,CSS 类很难稳定压过它 ——
- * 这就是「弹窗里按钮/复选框颜色和外面不一样」的原因。
- *
- * 内联 style 优先级高于任何非 `!important` 声明,
- * 直接写在 Content 元素上必然生效,也不用去猜层叠顺序。
- *
- * 色值与 `components/review/editor-theme.css` 保持一致
- * (站点品牌红 #e0303f),深色模式的提亮档在下面按需覆盖。
- */
- style={accentStyle}
- >
-
- {t('metadataDialog.title', '元数据编辑器')}
-
+ {/*
+ * 主题色用**内联 style**下发,不用 CSS 类。
+ *
+ * 原因:浮层 Portal 到 body,跟工作台的 CSS 覆写不在一条继承链上,
+ * CSS 类很难稳定压过浮层自身的色阶声明 —— 这就是
+ * 「弹窗里按钮/复选框颜色和外面不一样」的原因。
+ *
+ * 内联 style 优先级高于任何非 `!important` 声明,直接写在
+ * Content 元素上必然生效,也不用去猜层叠顺序。
+ *
+ * 色值与 `components/review/editor-theme.css` 保持一致
+ * (站点品牌红 #e0303f),深色模式的提亮档在下面按需覆盖。
+ */}
+
+ 元数据编辑器
-
- {activeOption?.label}
-
+
{activeOption?.label}
{activeOption && (
-
- {activeOption.value}
-
+
{activeOption.value}
)}
@@ -897,36 +870,29 @@ export const MetadataEditor = () => {
-
+
-
-
+
+
);
};
diff --git a/frontend/src/editor/modules/project/modals/VocalTagsEditor.tsx b/frontend/src/editor/modules/project/modals/VocalTagsEditor.tsx
index 9d0b188..21f6fb6 100644
--- a/frontend/src/editor/modules/project/modals/VocalTagsEditor.tsx
+++ b/frontend/src/editor/modules/project/modals/VocalTagsEditor.tsx
@@ -1,9 +1,10 @@
-import { Add16Regular, Delete16Regular } from '@fluentui/react-icons';
-import { Button, Dialog, Flex, Text, TextField } from '@radix-ui/themes';
import { useAtom } from 'jotai';
import { useImmerAtom } from 'jotai-immer';
+import { Plus, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
import { vocalTagsEditorDialogAtom } from '$/states/dialogs.ts';
import { lyricLinesAtom } from '$/states/main.ts';
import type { TTMLLyric } from '$/types/ttml';
@@ -54,7 +55,6 @@ const reassignVocalIds = (draft: TTMLLyric) => {
export const VocalTagsEditor = () => {
const [open, setOpen] = useAtom(vocalTagsEditorDialogAtom);
const [lyricLines, setLyricLines] = useImmerAtom(lyricLinesAtom);
- const { t } = useTranslation();
const [editingIndex, setEditingIndex] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [isAdding, setIsAdding] = useState(false);
@@ -68,23 +68,19 @@ export const VocalTagsEditor = () => {
}, [lyricLines.metadata]);
return (
-
-
+
-
+
+
);
};
diff --git a/frontend/src/editor/modules/project/modals/metadata-dialog-theme.ts b/frontend/src/editor/modules/project/modals/metadata-dialog-theme.ts
index 6462939..05b0b6e 100644
--- a/frontend/src/editor/modules/project/modals/metadata-dialog-theme.ts
+++ b/frontend/src/editor/modules/project/modals/metadata-dialog-theme.ts
@@ -1,32 +1,6 @@
-/**
- * 弹窗 Radix accent 色阶下发
- *
- * ## 为什么需要它
- *
- * `MetadataEditor` 的 Dialog Portal 到 `body`,**不在 `editor-body` 子树里**,
- * 所以 `editor-theme.css` 里挂在 `body.editor-body [data-accent-color]`
- * 的品牌红色阶对它不生效 —— 弹窗里按钮/复选框/开关会回落到 Radix
- * 内置的indigo。
- *
- * 而 Radix 的 `Dialog.Content` 内部又套了一层 ``,
- * 在自己元素上渲染 `data-accent-color="indigo"`,Radix 用
- * `[data-accent-color='…']`(优先级 0,1,0)声明色阶。CSS 类很难稳定
- * 压过它,所以这里用**内联 style**(优先级最高,一定生效)。
- *
- * ## 色值来源:CSS 变量,不是硬编码
- *
- * 色阶的唯一权威定义在 `components/review/editor-theme.css`
- * (浅色档 + 深色档)。这里**读取**那些变量再回填成内联 style,
- * 而不是抄一份色值 —— 之前这里硬编码了 34 个色值,和 CSS 那份
- * 逐字相同,改配色时必然两边漂移。
- *
- * 读取时机:Portal 内容挂载后从 `document.body` 上取
- * (`editor-theme.css` 挂在 body 上,深色档由 `body.editor-body.dark`
- * 决定)。取不到时回落到当前文件里的兜底值,保证不会渲染成 indigo。
- */
import type React from 'react';
-/** Radix accent 梯度:1~13 纯色,a1~a13 半透明,另有 4 个语义别名 */
+/** accent 梯度:1~13 纯色,a1~a13 半透明,另有 4 个语义别名 */
const ACCENT_VARS = [
'--accent-1',
'--accent-2',
@@ -59,12 +33,6 @@ const ACCENT_VARS = [
'--accent-track',
] as const;
-/**
- * 兜底色阶(品牌红)。
- *
- * 仅在读不到 CSS 变量时使用 —— 例如 `editor-theme.css` 未加载
- * (如单独跑 Storybook)。正常路径下读到的是 CSS 里那份。
- */
const FALLBACK = {
'--accent-1': '#fff5f6',
'--accent-2': '#feeaec',
@@ -97,12 +65,6 @@ const FALLBACK = {
'--accent-track': '#fbdde0',
} as const satisfies Record<(typeof ACCENT_VARS)[number], string>;
-/**
- * 从 body 上读取当前主题下的品牌红色阶。
- *
- * 深色档由 `body.editor-body.dark` 提供,所以必须从 **body** 读而不是
- * 从弹窗元素读(弹窗不在 editor-body 内)。读到的值已经带上主题。
- */
export function readBrandAccentStyle(): React.CSSProperties {
if (typeof document === 'undefined') return FALLBACK as React.CSSProperties;
diff --git a/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx b/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx
index 3ac1dff..f871a0a 100644
--- a/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx
+++ b/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx
@@ -1,14 +1,8 @@
-import { Box, Button, Flex, Switch, Text, TextArea } from '@radix-ui/themes';
+import { RotateCcw, Bold, Italic, Heading1, Heading2, Heading3, AlignJustify } from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
-import {
- ArrowReset20Regular,
- TextBold20Regular,
- TextHeader120Regular,
- TextHeader220Regular,
- TextHeader320Regular,
- TextItalic20Regular,
- TextLineSpacing20Regular,
-} from '@fluentui/react-icons';
+import { Button } from '@/components/ui/button';
+import { Switch } from '@/components/ui/switch';
+import { Textarea } from '@/components/ui/textarea';
import {
DEFAULT_REVIEW_REPORT_FORMAT,
resetReviewReportBlockFormat,
@@ -126,55 +120,24 @@ export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEdit
const insertVariable = (name: string) => insertTemplateText(`{{${name}}}`);
return (
-
+
{/* 区块列表 + 恢复默认 */}
-
-
- 选择要编辑的区块,模板里用 {'{{变量}}'} 引用内容
-
+
+
选择要编辑的区块,模板里用 {'{{变量}}'} 引用内容
onChange(DEFAULT_REVIEW_REPORT_FORMAT)}
>
-
+
恢复默认
-
+
-
+
{/* 左:区块列表(独立滚动) */}
-
+
{reviewReportFormatBlockDefinitions.map((definition) => {
const selected = selectedDefinition.kind === definition.kind;
const changed = changedKinds.has(definition.kind);
@@ -183,43 +146,16 @@ export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEdit
key={definition.kind}
type="button"
onClick={() => setSelectedKind(definition.kind)}
- style={{
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'flex-start',
- gap: 4,
- width: '100%',
- minWidth: 0,
- padding: '8px 10px',
- textAlign: 'left',
- cursor: 'pointer',
- borderRadius: 6,
- border: `1px solid ${selected ? 'var(--amll-primary)' : 'var(--amll-border)'}`,
- background: selected
- ? 'var(--amll-primary-soft)'
+ className={`flex w-full min-w-0 cursor-pointer flex-col items-start gap-1 rounded-md border px-2.5 py-2 text-left text-inherit transition-colors ${
+ selected
+ ? 'border-primary bg-primary-soft'
: changed
- ? 'var(--amll-muted)'
- : 'var(--amll-card)',
- color: 'inherit',
- }}
+ ? 'border-line bg-muted'
+ : 'border-line bg-card hover:bg-surface-2'
+ }`}
>
-
-
+
+
{definition.label}
{/*
@@ -227,181 +163,125 @@ export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEdit
* 星号太弱,用户会以为是标点符号而不是状态。
*/}
{changed && (
-
+
已改
)}
-
+
{definition.description}
);
})}
-
+
{/* 右:模板编辑区(独立滚动,宽度不撑破卡片) */}
-
-
-
- {selectedDefinition.label}
-
+
+
+
{selectedDefinition.label}
onChange(resetReviewReportBlockFormat(value, selectedDefinition.kind))}
>
-
+
重置此项
-
+
-
{/* 实时预览 */}
-
-
- 当前报告预览
-
-
-
-
-
-
+
+ 当前报告预览
+
+
+
+
+
);
};
diff --git a/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx b/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx
index 90b99b7..7b5e132 100644
--- a/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx
+++ b/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx
@@ -1,15 +1,4 @@
-import {
- Box,
- Button,
- Checkbox,
- Dialog,
- Flex,
- ScrollArea,
- Switch,
- Tabs,
- Text,
- TextArea,
-} from '@radix-ui/themes';
+import { Check, Music, Pencil, Trash2 } from 'lucide-react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import {
Component,
@@ -20,12 +9,13 @@ import {
type CSSProperties,
type ReactNode,
} from 'react';
-import {
- Delete16Regular,
- Checkmark16Regular,
- Edit16Regular,
- MusicNote216Regular,
-} from '@fluentui/react-icons';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Switch } from '@/components/ui/switch';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Textarea } from '@/components/ui/textarea';
import {
reviewReportFormatAtom,
type ReviewReportFormat,
@@ -65,17 +55,13 @@ class DialogContentBoundary extends Component<{ children: ReactNode }, { error:
override render() {
if (this.state.error) {
return (
-
-
- 审阅结果面板加载失败
-
-
- {this.state.error.message}
-
-
+
+
审阅结果面板加载失败
+
{this.state.error.message}
+
详细堆栈见浏览器控制台。可以先关闭本窗口继续编辑,改用「编辑」 面板里的保存按钮提交。
-
-
+
+
);
}
return this.props.children;
@@ -83,11 +69,7 @@ class DialogContentBoundary extends Component<{ children: ReactNode }, { error:
}
function DialogFlexBody({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
+ return {children}
;
}
export interface ReviewReportDialogProps {
@@ -130,13 +112,15 @@ function blockSummary(block: ReviewReportBlock): string {
return `第 ${block.lineNumber} 行 · 「${block.word}」打轴调整`;
case 'lineTiming':
return `第 ${block.lineNumber} 行时轴调整`;
+ case 'metadata':
+ return `元数据(${block.label})${block.action === 'add' ? '增加' : '删除'} ${block.items.join('、')}`;
default:
return '未知条目';
}
}
function getReportBlockScopeLabel(block: ReviewReportBlock): string | null {
- if (block.kind === 'manual') return null;
+ if (block.kind === 'manual' || block.kind === 'metadata') return null;
if (block.kind === 'timeShift') {
return block.targetCount === block.totalLineCount
? '全部歌词行'
@@ -153,7 +137,7 @@ function getReportBlockScopeLabel(block: ReviewReportBlock): string | null {
}
function getReportBlockScopeKey(block: ReviewReportBlock): string | null {
- if (block.kind === 'manual') return null;
+ if (block.kind === 'manual' || block.kind === 'metadata') return null;
if (block.kind === 'timeShift' || block.kind === 'wordTextShared') {
return `${block.kind}:${block.id}`;
}
@@ -218,40 +202,29 @@ function ManualReportBlockView({
const textAreaRef = useRef(null);
return (
-
-
-
- onToggle(block.id, v === true)}
- />
-
- 手写内容
-
-
-
-
+
+
+ onToggle(block.id, v)} />
+ 手写内容
+
+
+
onLineBreakChange(block.id, v)}
aria-label="换行"
/>
-
- 换行
-
-
+ 换行
+
onMove(block.id, -1)}
title="将手写条目上移"
@@ -259,8 +232,9 @@ function ManualReportBlockView({
上移
onMove(block.id, 1)}
title="将手写条目下移"
@@ -268,25 +242,26 @@ function ManualReportBlockView({
下移
onDelete(block.id)}
title="删除条目"
>
-
+
-
-
+
+
{focused ? (
-
+
);
}
@@ -333,20 +297,12 @@ function StructuredReportBlockView({
const title = getReviewReportBlockText(block, reportFormat);
return (
-
-
+
{block.kind === 'wordTextGroup' ? (
block.changes.map((change, index) => {
const enabled = isWordTextGroupChangeEnabled(block, index);
@@ -357,16 +313,11 @@ function StructuredReportBlockView({
aria-pressed={enabled}
title={enabled ? '点击禁用该结果' : '点击启用该结果'}
onClick={() => onToggleGroupChange(block.id, index, !enabled)}
- style={{
- border: '1px solid var(--amll-border)',
- borderRadius: 3,
- padding: '1px 5px',
- fontSize: 12,
- cursor: 'pointer',
- background: enabled ? 'var(--amll-primary-soft)' : 'transparent',
- color: enabled ? 'var(--amll-primary)' : 'var(--amll-muted-foreground)',
- textDecoration: enabled ? 'none' : 'line-through',
- }}
+ className={`cursor-pointer rounded-[3px] border border-line px-1.5 py-px text-xs ${
+ enabled
+ ? 'bg-primary-soft text-primary no-underline'
+ : 'bg-transparent text-muted-foreground line-through'
+ }`}
>
{change.oldWord} → {change.newWord}
@@ -378,44 +329,50 @@ function StructuredReportBlockView({
aria-pressed={block.enabled}
title={block.enabled ? '点击禁用该结果' : '点击启用该结果'}
onClick={() => onToggle(block.id, !block.enabled)}
- style={{
- border: '1px solid var(--amll-border)',
- borderRadius: 3,
- padding: '1px 5px',
- fontSize: 12,
- cursor: 'pointer',
- background: block.enabled ? 'var(--amll-primary-soft)' : 'transparent',
- color: block.enabled ? 'var(--amll-primary)' : 'var(--amll-muted-foreground)',
- textDecoration: block.enabled ? 'none' : 'line-through',
- maxWidth: '100%',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- }}
+ className={`max-w-full cursor-pointer overflow-hidden rounded-[3px] border border-line px-1.5 py-px text-xs text-ellipsis whitespace-nowrap ${
+ block.enabled
+ ? 'bg-primary-soft text-primary no-underline'
+ : 'bg-transparent text-muted-foreground line-through'
+ }`}
>
{blockSummary(block)}
)}
-
+
onDelete(block.id)}
title="删除条目"
- style={{ flexShrink: 0 }}
>
-
+
- {title ? {title} : null}
-
+ {title ? {title} : null}
+
);
}
const ACTION_BUTTONS = [
- { action: 'approve', label: '接受', color: 'green', Icon: Checkmark16Regular },
- { action: 'revision', label: '需要修改', color: 'red', Icon: Edit16Regular },
- { action: 'missing_audio', label: '缺少音源', color: 'orange', Icon: MusicNote216Regular },
+ {
+ action: 'approve',
+ label: '接受',
+ className: 'bg-success text-white hover:bg-success/90',
+ Icon: Check,
+ },
+ {
+ action: 'revision',
+ label: '需要修改',
+ className: 'bg-error text-white hover:bg-error/90',
+ Icon: Pencil,
+ },
+ {
+ action: 'missing_audio',
+ label: '缺少音源',
+ className: 'bg-warning text-white hover:bg-warning/90',
+ Icon: Music,
+ },
] as const;
const BRAND_ACCENT = {
@@ -551,85 +508,58 @@ export const ReviewReportDialog = ({ onSubmit, submitting }: ReviewReportDialogP
if (!dialog.open) return null;
return (
- {
if (!open) close();
}}
>
- e.preventDefault()}
>
-
+
对稿件【{dialog.title}】做出的审阅结果如下:
-
-
+
+
-
+
-
-
- 条目
- 格式
- 文本
- 预览
-
+
+ 条目
+ 格式
+ 文本
+ 预览
+
{/* 条目 */}
-
-
+
+
结构化条目按系统顺序排列;手写条目可通过上移/下移插入序列。
-
-
+
+
{blockGroups.length === 0 ? (
-
-
- 暂无报告条目
-
-
+
+ 暂无报告条目
+
) : (
-
+
{blockGroups.map((group) => (
-
+
{group.scopeLabel && (
-
+
{group.scopeLabel}
-
+
)}
-
+
{group.blocks.map((block) =>
block.kind === 'manual' ? (
)
)}
-
-
+
+
))}
-
+
)}
-
-
+
+
新增手写条目
-
-
+
+
{/* 格式 */}
-
+
-
+
{/* 文本 */}
-
-
-
-
+
{/* 预览 */}
-
-
-
+
+
+
{renderedReport ? (
) : (
-
- (报告为空)
-
+
(报告为空)
)}
-
+
-
-
+
+
{/* 底部操作栏 */}
-
-
+
+
放弃
-
-
- setUploadTtml(Boolean(v))} />
-
- 上传修订版
-
-
- {ACTION_BUTTONS.map(({ action: key, label, color, Icon }) => (
+
+
+ setUploadTtml(v)} />
+ 上传修订版
+
+ {ACTION_BUTTONS.map(({ action: key, label, className, Icon }) => (
{
setAction(key);
@@ -770,11 +673,11 @@ export const ReviewReportDialog = ({ onSubmit, submitting }: ReviewReportDialogP
{label}
))}
-
+
-
-
+
+
);
};
diff --git a/frontend/src/editor/modules/review/services/report-service/format-service.ts b/frontend/src/editor/modules/review/services/report-service/format-service.ts
index 6d8aeb3..618b30d 100644
--- a/frontend/src/editor/modules/review/services/report-service/format-service.ts
+++ b/frontend/src/editor/modules/review/services/report-service/format-service.ts
@@ -221,6 +221,15 @@ export const createReviewReportBlockVariables = (
switch (block.kind) {
case 'manual':
return { content: block.content };
+ case 'metadata':
+ return {
+ label: block.label,
+ key: block.key,
+ action: block.action,
+ actionLabel: block.action === 'add' ? '增加' : '删除',
+ items: block.items.join('、'),
+ itemsCode: block.items.map(wrap).join('、'),
+ };
case 'wordTextShared':
return {
lineLabels: formatLineLabelList(block.lineRefs),
@@ -381,6 +390,7 @@ const getLineMergeCategory = (block: ReviewReportBlock) => {
case 'lineTiming':
return 'lineTiming';
case 'manual':
+ case 'metadata':
case 'wordTextShared':
case 'timeShift':
return null;
diff --git a/frontend/src/editor/modules/review/services/report-service/format-template.ts b/frontend/src/editor/modules/review/services/report-service/format-template.ts
index e6d3336..7fad55e 100644
--- a/frontend/src/editor/modules/review/services/report-service/format-template.ts
+++ b/frontend/src/editor/modules/review/services/report-service/format-template.ts
@@ -313,6 +313,39 @@ export const reviewReportFormatBlockDefinitions: ReviewReportFormatBlockDefiniti
description: '用户手动新增的报告条目',
variables: [{ name: 'content', label: '手写内容', description: '手写条目原文' }],
},
+ {
+ kind: 'metadata',
+ label: '元数据改动',
+ description: '歌曲元数据(曲名 / 歌手 / 专辑 / 平台 ID 等)新增或删除值时使用',
+ variables: [
+ {
+ name: 'label',
+ label: '类型',
+ description: '元数据类型的中文标签,例如:歌曲名称、歌曲的艺术家',
+ },
+ { name: 'key', label: '键名', description: '元数据的原始键名,例如:musicName' },
+ {
+ name: 'action',
+ label: '动作',
+ description: 'add 或 remove',
+ },
+ {
+ name: 'actionLabel',
+ label: '动作描述',
+ description: '中文动作词,例如:增加、删除',
+ },
+ {
+ name: 'items',
+ label: '内容列表',
+ description: '本次动作涉及的值,以顿号连接',
+ },
+ {
+ name: 'itemsCode',
+ label: '内容代码列表',
+ description: '每个值带 Markdown 行内代码标记,以顿号连接',
+ },
+ ],
+ },
];
export const DEFAULT_REVIEW_REPORT_FORMAT: ReviewReportFormat = {
@@ -323,6 +356,10 @@ export const DEFAULT_REVIEW_REPORT_FORMAT: ReviewReportFormat = {
template: '{{content}}',
listItem: false,
},
+ metadata: {
+ template: '元数据({{label}})需要{{actionLabel}}内容为 {{itemsCode}} 的数据',
+ listItem: true,
+ },
wordTextShared: {
template: '{{lineLabels}}:`{{oldWord}}` 存在错误,应为 `{{newWord}}`',
listItem: true,
diff --git a/frontend/src/editor/modules/review/services/report-service/metadata-report-builder.ts b/frontend/src/editor/modules/review/services/report-service/metadata-report-builder.ts
index c045a82..5e76094 100644
--- a/frontend/src/editor/modules/review/services/report-service/metadata-report-builder.ts
+++ b/frontend/src/editor/modules/review/services/report-service/metadata-report-builder.ts
@@ -2,11 +2,22 @@ import type { TTMLLyric, TTMLMetadata } from '$/types/ttml';
import { createReviewReport, createReviewReportBlockId } from './normalize-service';
import type { ReviewReportBlock } from './types';
-/** 元数据 key → 中文标签。只覆盖投稿里常见的,其余回退到 key 本身。 */
const METADATA_LABELS: Record = {
+ // —— 编辑器面板内置 key ——
+ musicName: '歌曲名称',
+ artists: '歌曲的艺术家',
+ songwriter: '词曲作者',
+ album: '歌曲的专辑名',
+ ncmMusicId: '网易云音乐 ID',
+ qqMusicId: 'QQ 音乐 ID',
+ spotifyId: 'Spotify 音乐 ID',
+ appleMusicId: 'Apple Music 音乐 ID',
+ isrc: '歌曲的 ISRC 号码',
+ ttmlAuthorGithub: '歌词作者 GitHub ID',
+ ttmlAuthorGithubLogin: '歌词作者 GitHub 用户名',
+ // —— 其它常见元数据 key ——
title: '标题',
artist: '歌手',
- album: '专辑',
albumArtist: '专辑歌手',
byArtist: '演唱者',
composer: '作曲',
@@ -54,12 +65,16 @@ function sameValues(a: string[], b: string[]): boolean {
}
/** 逐 key 做集合差:返回「旧里有新没有」与「新里有旧没有」 */
-function diffValues(oldValues: string[], newValues: string[]): string[] {
+function diffValues(
+ oldValues: string[],
+ newValues: string[]
+): { removed: string[]; added: string[] } {
const oldSet = new Set(oldValues);
const newSet = new Set(newValues);
- const removed = oldValues.filter((v) => !newSet.has(v));
- const added = newValues.filter((v) => !oldSet.has(v));
- return [...removed.map((v) => `- ${v}`), ...added.map((v) => `+ ${v}`)];
+ return {
+ removed: oldValues.filter((v) => !newSet.has(v)),
+ added: newValues.filter((v) => !oldSet.has(v)),
+ };
}
/** 把 TTMLLyric 的 metadata 压成 Map(同一个 key 出现多次时合并 value) */
@@ -87,26 +102,33 @@ export const buildMetadataChanges = (freeze: TTMLLyric, staged: TTMLLyric): Revi
if (sameValues(oldValues, newValues)) continue;
+ const { removed, added } = diffValues(oldValues, newValues);
const label = getLabel(key);
- let content: string;
- if (oldValues.length === 0) {
- content = `**${label}**:新增 ${newValues.map((v) => `\`${v}\``).join('、')}`;
- } else if (newValues.length === 0) {
- content = `**${label}**:删除 ${oldValues.map((v) => `\`${v}\``).join('、')}`;
- } else {
- const detail = diffValues(oldValues, newValues).join(',');
- content = `**${label}**:${detail}`;
+ // 一个 key 同时有增有删时拆成两条 —— 语义单一,模板统一为
+ // 「元数据(类型)需要增加/删除内容为 xxx 的数据」。
+ if (added.length > 0) {
+ blocks.push({
+ id: createReviewReportBlockId('metadata'),
+ kind: 'metadata',
+ enabled: true,
+ action: 'add',
+ key,
+ label,
+ items: added,
+ });
+ }
+ if (removed.length > 0) {
+ blocks.push({
+ id: createReviewReportBlockId('metadata'),
+ kind: 'metadata',
+ enabled: true,
+ action: 'remove',
+ key,
+ label,
+ items: removed,
+ });
}
-
- blocks.push({
- id: createReviewReportBlockId('metadata'),
- kind: 'manual',
- enabled: true,
- // 独立成段,不和歌词 diff 混在一起
- lineBreakBefore: true,
- content,
- });
}
return blocks;
diff --git a/frontend/src/editor/modules/review/services/report-service/render-service.ts b/frontend/src/editor/modules/review/services/report-service/render-service.ts
index df9b2c8..bf8ac1f 100644
--- a/frontend/src/editor/modules/review/services/report-service/render-service.ts
+++ b/frontend/src/editor/modules/review/services/report-service/render-service.ts
@@ -15,6 +15,8 @@ export const getReviewReportBlockLabel = (block: ReviewReportBlock) => {
switch (block.kind) {
case 'manual':
return '手写内容';
+ case 'metadata':
+ return '元数据';
case 'wordTextShared':
case 'wordTextGroup':
case 'wordText':
diff --git a/frontend/src/editor/modules/review/services/report-service/selection-service.ts b/frontend/src/editor/modules/review/services/report-service/selection-service.ts
index 2f506ce..35ca6de 100644
--- a/frontend/src/editor/modules/review/services/report-service/selection-service.ts
+++ b/frontend/src/editor/modules/review/services/report-service/selection-service.ts
@@ -59,6 +59,9 @@ export const getReviewReportSelectionKey = (block: ReviewReportBlock) => {
return block.operationId
? `${block.kind}:${block.operationId}:${block.lineId}`
: `${block.kind}:${block.lineId}`;
+ case 'metadata':
+ // 元数据按「键 + 动作 + 内容」去重:同一键的增/删是两条独立条目。
+ return `${block.kind}:${block.key}:${block.action}:${block.items.join('|')}`;
}
};
diff --git a/frontend/src/editor/modules/review/services/report-service/structured-report-builder.ts b/frontend/src/editor/modules/review/services/report-service/structured-report-builder.ts
index e855b48..f023499 100644
--- a/frontend/src/editor/modules/review/services/report-service/structured-report-builder.ts
+++ b/frontend/src/editor/modules/review/services/report-service/structured-report-builder.ts
@@ -288,6 +288,7 @@ const createReportChangeSelector = (context: StructuredReviewSelectionContext) =
const selectBlockChanges = (block: ReviewReportBlock) => {
switch (block.kind) {
case 'manual':
+ case 'metadata':
return [];
case 'wordTextShared':
return block.lineRefs.flatMap((ref) =>
diff --git a/frontend/src/editor/modules/review/services/report-service/types.ts b/frontend/src/editor/modules/review/services/report-service/types.ts
index 4f2154d..abcc8f9 100644
--- a/frontend/src/editor/modules/review/services/report-service/types.ts
+++ b/frontend/src/editor/modules/review/services/report-service/types.ts
@@ -196,6 +196,18 @@ export type ReviewReportBlock =
newStart: number;
oldEnd: number;
newEnd: number;
+ })
+ | (ReviewReportBlockBase & {
+ /** 元数据改动 */
+ kind: 'metadata';
+ /** 动作:新增 / 删除 */
+ action: 'add' | 'remove';
+ /** 元数据原始 key,如 musicName */
+ key: string;
+ /** 类型的中文标签,如「歌曲名称」 */
+ label: string;
+ /** 本次动作涉及的值列表 */
+ items: string[];
});
export type ReviewReport = {
diff --git a/frontend/src/editor/modules/review/services/review-template-service.tsx b/frontend/src/editor/modules/review/services/review-template-service.tsx
index dc58e05..8365457 100644
--- a/frontend/src/editor/modules/review/services/review-template-service.tsx
+++ b/frontend/src/editor/modules/review/services/review-template-service.tsx
@@ -1,9 +1,11 @@
-import { Delete20Regular, Edit20Regular, LightbulbCheckmark20Regular } from '@fluentui/react-icons';
-import { Box, Button, Flex, Text, TextArea, TextField } from '@radix-ui/themes';
+import { Lightbulb, Pencil, Trash2 } from 'lucide-react';
import { openDB } from 'idb';
import { useSetAtom } from 'jotai';
import { useEffect, useState } from 'react';
import { uid } from 'uid';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
import { pushNotificationAtom } from '$/states/notifications';
const TEMPLATE_DB_NAME = 'review-template-db';
@@ -224,99 +226,100 @@ export const ReviewTemplateSection = ({ open, onInsertTemplate }: ReviewTemplate
};
return (
-
-
- 模板
-
-
+
+
模板
+
{[...presetTemplates, ...customTemplates].map((template) => {
const isCustom = !template.id.startsWith('preset-');
return (
onInsertTemplate(template.content)}
>
-
-
- {template.title}
- {isCustom && (
- <>
- {
- event.stopPropagation();
- handleStartEdit(template);
- }}
- style={{ cursor: 'pointer', marginLeft: '4px' }}
- title="编辑模板"
- >
-
-
- {
- event.stopPropagation();
- handleDeleteTemplate(template.id);
- }}
- style={{ cursor: 'pointer', marginLeft: '4px' }}
- title="删除模板"
- >
-
-
- >
- )}
-
+
+ {template.title}
+ {isCustom && (
+ <>
+ {
+ event.stopPropagation();
+ handleStartEdit(template);
+ }}
+ title="编辑模板"
+ className="ml-1 inline-flex cursor-pointer items-center text-ink-3 transition-colors hover:text-primary"
+ >
+
+
+ {
+ event.stopPropagation();
+ handleDeleteTemplate(template.id);
+ }}
+ title="删除模板"
+ className="ml-1 inline-flex cursor-pointer items-center text-ink-3 transition-colors hover:text-error"
+ >
+
+
+ >
+ )}
);
})}
- {templateLoading && (
-
- 正在加载模板...
-
- )}
-
+ {templateLoading &&
正在加载模板...}
+
{showTemplateEditor ? (
-
-
+ setTemplateTitle(event.currentTarget.value)}
placeholder="模板标题"
/>
- setTemplateContent(event.currentTarget.value)}
placeholder="模板内容"
- style={{ minHeight: '120px' }}
+ className="min-h-[120px]"
/>
-
+
取消
{editingTemplateId ? '更新模板' : '保存模板'}
-
-
+
+
) : (
-
- setShowTemplateEditor(true)}>
+
+ setShowTemplateEditor(true)}
+ >
新增自定义模板
-
+
)}
-
+
);
};
diff --git a/frontend/src/editor/modules/segmentation/components/AdvancedSegmentation.tsx b/frontend/src/editor/modules/segmentation/components/AdvancedSegmentation.tsx
index 7654fc4..f662741 100644
--- a/frontend/src/editor/modules/segmentation/components/AdvancedSegmentation.tsx
+++ b/frontend/src/editor/modules/segmentation/components/AdvancedSegmentation.tsx
@@ -2,25 +2,17 @@
* @description 高级分词组件
*/
-import { DeleteRegular, EditRegular, Info16Regular } from '@fluentui/react-icons';
-import {
- Box,
- Button,
- Card,
- Checkbox,
- Dialog,
- Flex,
- IconButton,
- RadioGroup,
- Select,
- Text,
- TextArea,
- TextField,
-} from '@radix-ui/themes';
+import { Info, Pencil, Trash2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Radio } from '@/components/ui/radio-group';
+import { Select } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
import { useAtom, useAtomValue } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
import { memo, useCallback, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
import {
segmentationCustomRulesAtom,
segmentationIgnoreListTextAtom,
@@ -69,8 +61,6 @@ export const AdvancedSegmentationDialog = memo(() => {
const [lang, setLang] = useAtom(segmentationLangAtom);
- const { t } = useTranslation();
-
const toggleSplitPoint = useCallback((index: number) => {
setManualSplitIndices((prev) => {
const next = new Set(prev);
@@ -145,31 +135,25 @@ export const AdvancedSegmentationDialog = memo(() => {
if (!resultWords || resultWords.length === 0) return;
return (
-
+
{resultWords.map((w, i) => (
{w.word.trim() === '' ? (
-
- {w.word.length > 0
- ? t('splitWordDialog.spaceCount', '空格x{count}', {
- count: w.word.length,
- })
- : t('splitWordDialog.empty', '空白')}
-
+
+ {w.word.length > 0 ? `空格x${w.word.length}` : '空白'}
+
) : (
w.word
)}
))}
-
+
);
} catch (error) {
segmentationLogger.error('分词预览出错:', error);
- return (
- {t('advancedSegmentDialog.test.outputError', '分词预览出错')}
- );
+ return 分词预览出错;
}
- }, [testInput, t, segmentationConfig]);
+ }, [testInput, segmentationConfig]);
const onApply = useCallback(() => {
const maxLines = currentLyric.lyricLines.length;
@@ -201,160 +185,141 @@ export const AdvancedSegmentationDialog = memo(() => {
]);
return (
-
-
- {t('advancedSegmentDialog.title', '高级分词')}
-
-
-
- {t('advancedSegmentDialog.scope.title', '应用范围')}
-
- setScope(value as 'all' | 'range')}
- >
-
-
-
- {t('advancedSegmentDialog.scope.all', '所有歌词行')}
-
-
-
-
-
- {t('advancedSegmentDialog.scope.range.from', '从第')}
- setRangeStart(e.target.value)}
- disabled={scope !== 'range'}
- style={{ maxWidth: 80 }}
- />
- {t('advancedSegmentDialog.scope.range.to', '行到第')}
- setRangeEnd(e.target.value)}
- disabled={scope !== 'range'}
- style={{ maxWidth: 80 }}
- />
- {t('advancedSegmentDialog.scope.range.end', '行')}
-
-
-
-
-
-
- {t('advancedSegmentDialog.rules.title', '自动分词规则')}
-
-
- setSplitCJK(c as boolean)} />
- {t('advancedSegmentDialog.rules.cjk', 'CJK 按字符分词')}
-
-
-
-
-
-
- setSplitEnglish(c as boolean)}
- />
- {t('advancedSegmentDialog.rules.syllable', '西文单词按音节分词')}
-
-
-
- {splitEnglish && (
-
-
- {t('advancedSegmentDialog.language.select', '选择一个分词语言模型:')}
-
-
-
-
- {SUPPORTED_LANGUAGES.map((l) => (
-
- {l.label}
-
- ))}
-
-
-
- )}
-
-
-
-
-
-
- {t(
- 'advancedSegmentDialog.tip',
- '分词后,原音节的时长会按照字符数和权重重新分配给新的音节'
- )}
-
-
-
-
-
-
- {t('advancedSegmentDialog.postProcess.title', '后处理')}
-
-
- {t('advancedSegmentDialog.postProcess.punct.caption', '标点符号处理:')}
-
- setPunctuationMode(value as 'merge' | 'standalone')
- }
- >
-
-
-
-
- {t('advancedSegmentDialog.postProcess.punct.merge', '合并到相邻音节')}
-
-
-
-
-
-
-
- {t('advancedSegmentDialog.postProcess.punct.standalone', '设为新音节')}
-
-
-
-
-
-
-
-
-
- {t('advancedSegmentDialog.postProcess.punct.weight', '标点时长权重:')}
-
+
+ 高级分词
+
+
+
+
+
+
自动分词规则
+
+
+
+
+
+ {splitEnglish && (
+
+ 选择一个分词语言模型:
+
+
+ )}
+
+
+
+
+
+ 分词后,原音节的时长会按照字符数和权重重新分配给新的音节
+
+
+
+
+
+
后处理
+
+
标点符号处理:
+
+
+
+
+
+
+
+ 标点时长权重:
+ setPunctuationWeight(e.target.value)}
+ disabled={punctuationMode !== 'standalone'}
+ className="max-w-[100px]"
+ />
+
+
+
+
+
+
+
自定义分词规则
+
{
setManualWordInput(e.target.value);
@@ -366,30 +331,21 @@ export const AdvancedSegmentationDialog = memo(() => {
splitIndices={manualSplitIndices}
onSplitIndexToggle={toggleSplitPoint}
/>
-
- {t('advancedSegmentDialog.custom.add', '添加到自定义规则')}
+
+ 添加到自定义规则
{customRules.size > 0 && (
-
- {t('advancedSegmentDialog.custom.list', '自定义规则列表:')}
-
+
+
自定义规则列表:
+
{Array.from(customRules.entries()).map(([word, parts]) => (
-
-
+
{word}
-
- →
-
+ →
{parts.map((part, i) => (
{
}`}
>
{part.trim() === '' ? (
-
- {part.length > 0
- ? t('splitWordDialog.spaceCount', '空格x{count}', {
- count: part.length,
- })
- : t('splitWordDialog.empty', '空白')}
-
+
+ {part.length > 0 ? `空格x${part.length}` : '空白'}
+
) : (
part
)}
))}
-
+
-
-
+ handleEditRule(word, parts)}
>
-
-
-
+
+ handleDeleteRule(word)}
>
-
-
-
-
+
+
+
+
))}
-
-
+
+
)}
-
-
-
-
- {t('advancedSegmentDialog.ignore.title', '忽略列表')}
- setIgnoreListText(e.target.value)}
- style={{ minHeight: 100, resize: 'vertical' }}
- />
-
-
-
-
-
- {t('advancedSegmentDialog.test.title', '分词测试')}
- setTestInput(e.target.value)}
- />
- {t('advancedSegmentDialog.test.output', '预览结果:')}
-
- {testPreview}
-
-
-
-
-
-
-
-
- {t('common.cancel', '取消')}
+
+
+
+ 忽略列表
+ setIgnoreListText(e.target.value)}
+ className="min-h-[100px] resize-y"
+ />
+
+
+
+
分词测试
+
setTestInput(e.target.value)}
+ />
+
预览结果:
+
{testPreview}
+
+
+
+
+
+
+ 取消
-
- {t('common.apply', '应用')}
-
-
-
+
+
+ 应用
+
+
+
+
);
});
diff --git a/frontend/src/editor/modules/segmentation/components/ManualWordSplitter.tsx b/frontend/src/editor/modules/segmentation/components/ManualWordSplitter.tsx
index 27e75a1..fc0cfe1 100644
--- a/frontend/src/editor/modules/segmentation/components/ManualWordSplitter.tsx
+++ b/frontend/src/editor/modules/segmentation/components/ManualWordSplitter.tsx
@@ -2,7 +2,6 @@
* @description 手动分词组件
*/
-import { Flex } from '@radix-ui/themes';
import { Fragment, memo, useMemo } from 'react';
import styles from './ManualWordSplitter.module.css';
@@ -17,19 +16,7 @@ export const ManualWordSplitter = memo(
const manualGraphemes = useMemo(() => Array.from(word), [word]);
return (
-
+
{manualGraphemes.map((grapheme, index) => (
{grapheme}
))}
-
+
);
}
);
diff --git a/frontend/src/editor/modules/segmentation/components/split-word.tsx b/frontend/src/editor/modules/segmentation/components/split-word.tsx
index 5c4682e..1a08fee 100644
--- a/frontend/src/editor/modules/segmentation/components/split-word.tsx
+++ b/frontend/src/editor/modules/segmentation/components/split-word.tsx
@@ -1,9 +1,17 @@
-import { Info16Regular } from '@fluentui/react-icons';
-import { Box, Button, Card, Checkbox, Dialog, Flex, Text } from '@radix-ui/themes';
import { useAtom, useAtomValue } from 'jotai';
import { useSetImmerAtom } from 'jotai-immer';
+import { Info } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
import {
segmentationCustomRulesAtom,
segmentationIgnoreListTextAtom,
@@ -29,7 +37,6 @@ export const SplitWordDialog = memo(() => {
const editingState = useAtomValue(editingWordStateAtom);
const lyricLines = useAtomValue(lyricLinesAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
- const { t } = useTranslation();
const [splitIndices, setSplitIndices] = useState(new Set());
const [targetWordText, setTargetWordText] = useState('');
@@ -203,60 +210,50 @@ export const SplitWordDialog = memo(() => {
]);
return (
-
-
- {t('splitWordDialog.title', '拆分单词')}
-
-
-
-
-
- {t(
- 'splitWordDialog.tip',
- '拆分后新单词将会按自身单词字符平均分配原单词的始末时间,如有空拍则会被清除'
- )}
-
-
-
+
-
+
+
+ 执行
+
+
+
+
);
});
diff --git a/frontend/src/editor/modules/spectrogram/components/AudioSpectrogram.tsx b/frontend/src/editor/modules/spectrogram/components/AudioSpectrogram.tsx
index 1d675da..523b4b2 100644
--- a/frontend/src/editor/modules/spectrogram/components/AudioSpectrogram.tsx
+++ b/frontend/src/editor/modules/spectrogram/components/AudioSpectrogram.tsx
@@ -1,8 +1,9 @@
-import { EyeFilled, EyeOffFilled, MusicNote2Filled, Timer16Regular } from '@fluentui/react-icons';
-import { Flex, IconButton, Slider, Text, Theme, Tooltip } from '@radix-ui/themes';
+import { Eye, EyeOff, Music, Timer } from 'lucide-react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { Slider } from '@/components/ui/slider';
+import { SimpleTooltip } from '@/components/ui/tooltip';
import { audioEngine } from '$/modules/audio/audio-engine.ts';
import { useBpmTapEngine } from '$/modules/audio/hooks';
import {
@@ -147,8 +148,6 @@ export const AudioSpectrogram: FC = () => {
const rulerRef = useRef(null);
- const { t } = useTranslation();
-
const { tileCache, requestTileIfNeeded, lastTileTimestamp } = useSpectrogramWorker(
pcmDataReady,
currentDurationMs,
@@ -300,19 +299,16 @@ export const AudioSpectrogram: FC = () => {
if (isSpectrogramTapMode) {
const nextBeatIndex = totalTapCount + 1;
- hoverTimeFormatted = `${t('spectrogram.clickToCalibrateBeatN', '点击校准第 {{n}} 拍', { n: nextBeatIndex })}: ${hoverTimeFormatted}`;
+ hoverTimeFormatted = `点击校准第 ${nextBeatIndex} 拍: ${hoverTimeFormatted}`;
tooltipBgColor = 'var(--green-9)';
hoverLineColor = 'var(--green-9)';
} else if (isInvalidEndTime) {
- hoverTimeFormatted = t('spectrogram.invalidEndTime', '不能选择此结束时间');
+ hoverTimeFormatted = '不能选择此结束时间';
tooltipBgColor = 'var(--red-9)';
hoverLineColor = 'var(--red-9)';
} else if (editingTimeField && !editingTimeField.isWord) {
- const fieldName =
- editingTimeField.field === 'startTime'
- ? t('ribbonBar.editMode.startTime', '起始时间')
- : t('ribbonBar.editMode.endTime', '结束时间');
- hoverTimeFormatted = `${t('common.clickToSet', '点击设置')}${fieldName}: ${hoverTimeFormatted}`;
+ const fieldName = editingTimeField.field === 'startTime' ? '起始时间' : '结束时间';
+ hoverTimeFormatted = `${'点击设置'}${fieldName}: ${hoverTimeFormatted}`;
tooltipBgColor = 'var(--accent-9)';
}
@@ -347,23 +343,20 @@ export const AudioSpectrogram: FC = () => {
-
-
+
+
setGain(v[0] ?? gain)}
- style={{ flex: 1, width: '18px', zIndex: 10 }}
+ value={gain}
+ onValueChange={setGain}
+ aria-label="频谱图增益"
+ className="flex-1"
+ style={{ flex: 1, zIndex: 10 }}
+ trackColor="var(--gray-a6)"
/>
{
{gain}x
-
-
+
+
{!pcmDataReady ? (
-
-
+
+
{/**
* 审核页不提供「加载本地音频文件」入口 ——
* 参考音频来自后端投稿,由 `LyricEditorPage` 注入。
*/}
{engineState === 'idle' ? (
-
- {t('spectrogram.noAudioLoaded', '参考音频未就绪,频谱图不可用')}
-
+ 参考音频未就绪,频谱图不可用
) : (
-
- {t('spectrogram.decoding', '正在解码音频,请稍候...')}
-
+ 正在解码音频,请稍候...
)}
-
+
) : (
<>
@@ -488,7 +477,8 @@ export const AudioSpectrogram: FC = () => {
)}
-
+ {/* 频谱图恒定深色底(canvas 配色依赖),用 .dark 作用域替代原 */}
+
{showBeatLines && }
{reviewTimingOverlayEnabled ? (
{
/>
)}
-
+
@@ -524,22 +514,26 @@ export const AudioSpectrogram: FC = () => {
-
-
+ setShowBeatLines((prev) => !prev)}
>
-
-
-
-
-
+
+
+
+ setShowUnselectedLines((prev) => !prev)}
>
- {showUnselectedLines ? : }
-
-
+ {showUnselectedLines ? : }
+
+
diff --git a/frontend/src/editor/states/dialogs.ts b/frontend/src/editor/states/dialogs.ts
index 6f5eddb..83940a4 100644
--- a/frontend/src/editor/states/dialogs.ts
+++ b/frontend/src/editor/states/dialogs.ts
@@ -86,6 +86,7 @@ export const reduceStutterDialogAtom = atom<{
}>({
open: false,
});
+
export const ttmlErrorDialogAtom = atom<{
error: JsError;
rawText?: string;
diff --git a/frontend/src/editor/states/main.ts b/frontend/src/editor/states/main.ts
index ab4c11f..aa02def 100644
--- a/frontend/src/editor/states/main.ts
+++ b/frontend/src/editor/states/main.ts
@@ -121,7 +121,18 @@ export const saveStatusAtom = atom(SaveStatus.Saved);
export const lastSavedTimeAtom = atom(null);
export const undoableLyricLinesAtom = withHistory(lyricLinesAtom, 256);
-export const isDirtyAtom = atom((get) => get(undoableLyricLinesAtom).canUndo);
+
+export const savedLyricBaselineAtom = atom(null);
+
+export const markLyricLinesSavedAtom = atom(null, (get, set) => {
+ set(savedLyricBaselineAtom, JSON.stringify(get(lyricLinesAtom)));
+});
+
+export const isDirtyAtom = atom((get) => {
+ const baseline = get(savedLyricBaselineAtom);
+ if (baseline === null) return false;
+ return JSON.stringify(get(lyricLinesAtom)) !== baseline;
+});
export const reviewOperationLogAtom = atom([]);
export const reviewOperationRedoStackAtom = atom([]);
diff --git a/frontend/src/editor/states/notifications.ts b/frontend/src/editor/states/notifications.ts
index 51be320..8157806 100644
--- a/frontend/src/editor/states/notifications.ts
+++ b/frontend/src/editor/states/notifications.ts
@@ -1,4 +1,5 @@
import { atom } from 'jotai';
+import { toast } from 'sonner';
import { uid } from 'uid';
export type NotificationLevel = 'info' | 'warning' | 'error' | 'success';
@@ -39,60 +40,61 @@ export type AppNotification = {
export const notificationsAtom = atom([]);
+/** sonner 的 toast 级别 */
+const toSonner = (level: NotificationLevel) => {
+ switch (level) {
+ case 'success':
+ return toast.success;
+ case 'error':
+ return toast.error;
+ case 'warning':
+ return toast.warning;
+ default:
+ return toast.info;
+ }
+};
+
+const emit = (notification: AppNotification) => {
+ const notify = toSonner(notification.level);
+ notify(notification.title, {
+ id: notification.id,
+ description: notification.description,
+ duration: notification.pinned ? Number.POSITIVE_INFINITY : undefined,
+ });
+};
+
export const pushNotificationAtom = atom(
null,
(
_get,
- set,
+ _set,
input: Omit & {
id?: string;
createdAt?: string;
}
) => {
- const nextNotification: AppNotification = {
+ emit({
...input,
id: input.id ?? uid(),
createdAt: input.createdAt ?? new Date().toISOString(),
- };
- set(notificationsAtom, (prev) => [nextNotification, ...prev]);
+ });
}
);
export const upsertNotificationAtom = atom(
null,
- (get, set, input: Omit & { createdAt?: string }) => {
- const notifications = get(notificationsAtom);
- const existingIndex = notifications.findIndex((notification) => notification.id === input.id);
- const existing = existingIndex >= 0 ? notifications[existingIndex] : null;
- const nextNotification: AppNotification = {
- ...existing,
+ (_get, _set, input: Omit & { createdAt?: string }) => {
+ emit({
...input,
- createdAt: input.createdAt ?? existing?.createdAt ?? new Date().toISOString(),
- };
- if (existingIndex >= 0) {
- set(notificationsAtom, (prev) => {
- const next = [...prev];
- next[existingIndex] = nextNotification;
- return next;
- });
- } else {
- set(notificationsAtom, (prev) => [nextNotification, ...prev]);
- }
+ createdAt: input.createdAt ?? new Date().toISOString(),
+ });
}
);
-export const removeNotificationAtom = atom(null, (get, set, id: string) => {
- const notifications = get(notificationsAtom);
- const next = notifications.filter(
- (notification) => notification.id !== id || notification.dismissible === false
- );
- if (next.length !== notifications.length) {
- set(notificationsAtom, next);
- }
+export const removeNotificationAtom = atom(null, (_get, _set, id: string) => {
+ toast.dismiss(id);
});
-export const clearNotificationsAtom = atom(null, (_get, set) => {
- set(notificationsAtom, (prev) =>
- prev.filter((notification) => notification.dismissible === false)
- );
+export const clearNotificationsAtom = atom(null, (_get, _set) => {
+ toast.dismiss();
});
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e1c3297..8af92cc 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -4,29 +4,6 @@
/* prose:法律文档正文排版(.legal-prose 仅做主题令牌桥接与少量定制) */
@plugin '@tailwindcss/typography';
-/* Radix Accordion 展开动画(shadcn accordion 依赖 --animate-accordion-*) */
-@theme {
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
-
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
-}
-
@property --amll-background {
syntax: '';
inherits: true;
@@ -42,11 +19,6 @@
inherits: true;
initial-value: #ffffff;
}
-@property --amll-card-foreground {
- syntax: '';
- inherits: true;
- initial-value: #1d1d1f;
-}
@property --amll-popover {
syntax: '';
inherits: true;
@@ -174,7 +146,6 @@
--amll-foreground: #1d1d1f;
--amll-card: #ffffff;
- --amll-card-foreground: #1d1d1f;
--amll-popover: #ffffff;
--amll-popover-foreground: #1d1d1f;
@@ -222,12 +193,12 @@
}
/* 深色主题 */
-html.dark {
+html.dark,
+.dark {
--amll-background: #101014;
--amll-foreground: #f5f5f7;
--amll-card: #1a1a1f;
- --amll-card-foreground: #f5f5f7;
--amll-popover: #1a1a1f;
--amll-popover-foreground: #f5f5f7;
@@ -263,6 +234,45 @@ html.dark {
--amll-state-warning: #e09a33;
--amll-state-error: #ef4444;
--amll-state-info: #4d9fe0;
+
+ /*
+ * Radix 色板兜底:深色档。
+ */
+ --gray-1: #111113;
+ --gray-2: #18191b;
+ --gray-3: #212225;
+ --gray-4: #272a2d;
+ --gray-5: #2e3135;
+ --gray-7: #43484e;
+ --gray-8: #5a6169;
+ --gray-9: #696e77;
+ --gray-10: #777b84;
+ --gray-11: #9ba1a8;
+ --gray-12: #eeeff1;
+ --gray-a1: rgb(255 255 255 / 0.02);
+ --gray-a2: rgb(255 255 255 / 0.06);
+ --gray-a3: rgb(255 255 255 / 0.13);
+ --gray-a4: rgb(255 255 255 / 0.19);
+ --gray-a5: rgb(255 255 255 / 0.26);
+ --gray-a6: rgb(255 255 255 / 0.34);
+ --gray-a7: rgb(255 255 255 / 0.44);
+
+ /* 白/黑透明梯:深色下白梯更不透明,保证可读 */
+ --white-a6: rgb(255 255 255 / 0.5);
+ --white-a10: rgb(255 255 255 / 0.78);
+ --white-a12: rgb(255 255 255 / 0.95);
+
+ /* 绿 / 蓝 / 橙 / 琥珀:深色下主色提亮一档 */
+ --green-8: #3cb179;
+ --green-9: #30a46c;
+ --green-11: #3dd68c;
+ --green-12: #b1f1cb;
+
+ --blue-9: #0090ff;
+
+ --amber-9: #ffc53d;
+ --amber-10: #ffd60a;
+ --amber-11: #ffca16;
}
@custom-variant dark (&:where(.dark, .dark *));
@@ -273,7 +283,6 @@ html.dark {
--color-foreground: var(--amll-foreground);
--color-card: var(--amll-card);
- --color-card-foreground: var(--amll-card-foreground);
--color-popover: var(--amll-popover);
--color-popover-foreground: var(--amll-popover-foreground);
@@ -324,22 +333,12 @@ html {
深色时由 atoms/theme 写内联 color-scheme + 下方 html.dark 兜底 */
color-scheme: light;
}
-
-/*
- * 歌词/编辑器共用的 Radix accent 覆写
- *
- * ⚠️ 与 `components/review/editor-theme.css` 是**同一组变量**,
- * 工作台 CSS 类覆写了这里的浅色档(那边的 a3/a4/a8 是半透明红)。
- * 这里的值是**兜底默认值**,改配色时两处要一起改。
- * 名字沿用 Radix 原生 `--accent-*` 梯度语义(1~13 纯色,a1~a13 带alpha)。
- */
:root {
/* 行块底色(低 alpha!叠三四行仍需透光) */
--accent-a3: rgb(224 48 63 / 0.16);
--accent-a8: rgb(178 20 36 / 0.72);
--accent-a11: rgb(255 255 255 / 0.45);
--accent-a12: rgb(255 255 255 / 0.6);
- --accent-a13: rgb(255 255 255 / 0.75);
/* 选中的词:整词加底色 */
--accent-a4: var(--color-primary);
@@ -363,16 +362,100 @@ html {
--gray-12: rgb(29 29 31);
--gray-a3: rgb(29 29 31 / 0.13);
--gray-a4: rgb(29 29 31 / 0.26);
+/* 间距刻度:1 单位 = 4px */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
/* 圆角 */
--radius-1: 4px;
--radius-2: 6px;
+ --radius-3: 8px;
+ --radius-4: 12px;
+ --radius-full: 9999px;
+
+ /* 等宽字体 */
+ --default-font-family-mono: var(--amll-font-mono);
+
+ /* 面板底色(侧栏 / 编辑区背景) */
+ --color-panel: var(--amll-surface);
+ --color-bg-1: var(--amll-background);
+
+ /* ── 中性灰阶(Radix gray,浅色) ── */
+ --gray-7: #8d8d99;
+ --gray-9: #6a6a74;
+ --gray-10: #5a5a63;
+ --gray-a1: rgb(29 29 31 / 0.02);
+ --gray-a2: rgb(29 29 31 / 0.06);
+ --gray-a5: rgb(29 29 31 / 0.36);
+ --gray-a6: rgb(29 29 31 / 0.5);
+ --gray-a7: rgb(29 29 31 / 0.62);
+
+ /* 字号刻度(Radix Theme 的 --font-size-*,仅保留遗留 CSS Module 实际引用的档位) */
+ --font-size-2: 14px;
+ --font-size-4: 18px;
+ --font-size-5: 24px;
+
+ /* ── 白/黑透明梯(用于深色底上的文字) ── */
+ --white-3: rgb(255 255 255 / 0.18);
+ --white-a6: rgb(255 255 255 / 0.5);
+ --white-a10: rgb(255 255 255 / 0.78);
+ --white-a12: rgb(255 255 255 / 0.95);
+
+ /* ── 红(Radix red,用于错误 / 结束时间) ── */
+ --red-9: #e5484d;
+ --red-11: #ce2c31;
+ --red-a2: rgb(229 72 77 / 0.08);
+ --red-a3: rgb(229 72 77 / 0.16);
+ --red-a4: rgb(229 72 77 / 0.26);
+ --red-a5: rgb(229 72 77 / 0.34);
+ --red-a8: rgb(229 72 77 / 0.7);
+ --red-a10: rgb(229 72 77 / 0.88);
+
+ /* ── 绿(Radix green,用于成功 / 起始时间) ── */
+ --green-8: #2f9e63;
+ --green-9: #30a46c;
+ --green-11: #218358;
+ --green-12: #193b2d;
+ --green-a3: rgb(48 164 108 / 0.16);
+ --green-a4: rgb(48 164 108 / 0.26);
+ --green-a5: rgb(48 164 108 / 0.34);
+ --green-a6: rgb(48 164 108 / 0.44);
+ --green-a8: rgb(48 164 108 / 0.7);
+ --green-a11: rgb(48 164 108 / 0.82);
+
+ /* ── 蓝(Radix blue,用于选中 / 信息) ── */
+ --blue-9: #0090ff;
+ --blue-a2: rgb(0 144 255 / 0.08);
+ --blue-a3: rgb(0 144 255 / 0.16);
+ --blue-a4: rgb(0 144 255 / 0.24);
+ --blue-a5: rgb(0 144 255 / 0.32);
+ --blue-a6: rgb(0 144 255 / 0.42);
+ --blue-a7: rgb(0 144 255 / 0.54);
+ --blue-a9: rgb(0 144 255 / 0.9);
+ --blue-a10: rgb(0 144 255 / 0.9);
+ --blue-a11: rgb(0 144 255 / 0.82);
+
+ /* ── 橙(Radix orange,用于警告 / agent 提示) ── */
+ --orange-a2: rgb(247 107 21 / 0.08);
+ --orange-a3: rgb(247 107 21 / 0.16);
+ --orange-a4: rgb(247 107 21 / 0.24);
+ --orange-a5: rgb(247 107 21 / 0.32);
+ --orange-a10: rgb(247 107 21 / 0.9);
+ --orange-a11: rgb(247 107 21 / 0.82);
+
+ /* ── 琥珀(Radix amber,用于待确认状态) ── */
+ --amber-9: #ffc53d;
+ --amber-10: #ffba18;
+ --amber-11: #ab6400;
}
/* 主题切换过渡 */
html.theme-transitioning {
transition-property:
- --amll-background, --amll-foreground, --amll-card, --amll-card-foreground, --amll-popover,
+ --amll-background, --amll-foreground, --amll-card, --amll-popover,
--amll-popover-foreground, --amll-primary, --amll-primary-hover, --amll-primary-foreground,
--amll-muted, --amll-muted-foreground, --amll-border, --amll-input, --amll-ring, --amll-surface,
--amll-surface-2, --amll-ink, --amll-ink-2, --amll-ink-3, --amll-line, --amll-primary-soft,
@@ -389,12 +472,6 @@ html.theme-transitioning *::after {
html.dark {
color-scheme: dark;
}
-/* 深色下日历/时钟指示器反向提亮 */
-html.dark input[type='date']::-webkit-calendar-picker-indicator,
-html.dark input[type='time']::-webkit-calendar-picker-indicator,
-html.dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
- filter: invert(0.85);
-}
html body[data-scroll-locked] {
padding-right: 0 !important;
margin-right: 0 !important;
@@ -423,21 +500,6 @@ select option:focus {
color: var(--amll-primary);
}
-/* date / time / datetime-local 输入框:让日历/时钟图标可见 */
-input[type='date']::-webkit-calendar-picker-indicator,
-input[type='time']::-webkit-calendar-picker-indicator,
-input[type='datetime-local']::-webkit-calendar-picker-indicator {
- cursor: pointer;
- filter: invert(0.5);
- opacity: 0.7;
-}
-
-input[type='date']::-webkit-calendar-picker-indicator:hover,
-input[type='time']::-webkit-calendar-picker-indicator:hover,
-input[type='datetime-local']::-webkit-calendar-picker-indicator:hover {
- opacity: 1;
-}
-
/* 骨架屏 shimmer */
@keyframes amll-shimmer {
100% {
@@ -788,31 +850,3 @@ html.dark .amll-skeleton::after {
scrollbar-width: thin;
scrollbar-color: rgb(128 128 128 / 0.35) transparent;
}
-
-.lyric-words-track {
- /* 横向保留可见细条;纵向由组件的 overflow-y-hidden 处理 */
- scrollbar-width: thin;
- scrollbar-color: rgb(128 128 128 / 0.4) transparent;
-}
-.lyric-words-track::-webkit-scrollbar {
- width: 10px;
- height: 10px;
- background: transparent;
- border-top: solid 1px var(--color-border);
-}
-.lyric-words-track::-webkit-scrollbar-track {
- background: transparent;
-}
-.lyric-words-track::-webkit-scrollbar-thumb {
- border: solid 3px transparent;
- border-radius: 8px;
- background-color: rgb(128 128 128 / 0.45);
- background-clip: content-box;
- box-shadow: none;
-}
-.lyric-words-track::-webkit-scrollbar-thumb:hover {
- background-color: rgb(128 128 128 / 0.7);
-}
-.lyric-words-track::-webkit-scrollbar-corner {
- background: transparent;
-}
diff --git a/frontend/src/lib/__tests__/editor-audio-sources.test.ts b/frontend/src/lib/__tests__/editor-audio-sources.test.ts
new file mode 100644
index 0000000..3bcda55
--- /dev/null
+++ b/frontend/src/lib/__tests__/editor-audio-sources.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest';
+import { buildAudioUrl, buildEditorAudioSources } from '@/lib/review/audio-sources';
+import type { SubmissionAudio } from '@/lib/types';
+
+function audio(partial: Partial): SubmissionAudio {
+ return {
+ id: 1,
+ submissionId: 35,
+ fileName: 'music/35-1.mp3',
+ title: '',
+ artist: '',
+ album: '',
+ platform: '',
+ platformId: '',
+ uploadedBy: 'u',
+ uploadedAt: '2026-01-01T00:00:00Z',
+ ...partial,
+ };
+}
+
+const NCM = { url: 'https://ncm.example/song.mp3', name: '某首歌' };
+
+describe('buildAudioUrl', () => {
+ it('拼出与详情页一致的播放地址', () => {
+ expect(buildAudioUrl({ fileName: 'music/35-1.mp3' }, '')).toBe(
+ '/api/v1/uploads/file/music/35-1.mp3'
+ );
+ });
+
+ it('带 apiBase 时使用绝对地址', () => {
+ expect(buildAudioUrl({ fileName: 'music/35-1.mp3' }, 'http://localhost:8080')).toBe(
+ 'http://localhost:8080/api/v1/uploads/file/music/35-1.mp3'
+ );
+ });
+
+ it('缺 fileName 返回空串', () => {
+ expect(buildAudioUrl({}, '')).toBe('');
+ });
+});
+
+describe('buildEditorAudioSources', () => {
+ it('只有上传音频时,列表只有一条', () => {
+ const list = buildEditorAudioSources([audio({})], '', null, '');
+ expect(list).toHaveLength(1);
+ expect(list[0]!.key).toBe('music/35-1.mp3');
+ });
+
+ it('只有网易云时,列表只有网易云那条', () => {
+ const list = buildEditorAudioSources([], '1791651504365', NCM, '');
+ expect(list).toHaveLength(1);
+ expect(list[0]!.key).toBe('ncm:1791651504365');
+ expect(list[0]!.src).toBe(NCM.url);
+ expect(list[0]!.label).toContain('网易云');
+ });
+
+ it('同时有上传音频与网易云时,两条都要在(上传在前)', () => {
+ const list = buildEditorAudioSources([audio({})], '1791651504365', NCM, '');
+ expect(list).toHaveLength(2);
+ expect(list[0]!.key).toBe('music/35-1.mp3');
+ expect(list[1]!.key).toBe('ncm:1791651504365');
+ });
+
+ it('多个上传音频按原顺序排在前,网易云固定在最后', () => {
+ const list = buildEditorAudioSources(
+ [audio({ id: 1, fileName: 'music/a.mp3' }), audio({ id: 2, fileName: 'music/b.mp3' })],
+ '123',
+ NCM,
+ ''
+ );
+ expect(list.map((s) => s.key)).toEqual(['music/a.mp3', 'music/b.mp3', 'ncm:123']);
+ });
+
+ it('网易云解析失败(无 url)时不加入列表,上传音频仍保留', () => {
+ const list = buildEditorAudioSources([audio({})], '123', { url: '', name: 'x' }, '');
+ expect(list).toHaveLength(1);
+ expect(list[0]!.key).toBe('music/35-1.mp3');
+ });
+
+ it('没有 ncmId 时即使有解析结果也不加网易云', () => {
+ const list = buildEditorAudioSources([audio({})], '', NCM, '');
+ expect(list).toHaveLength(1);
+ });
+
+ it('缺 fileName 的上传音频被过滤掉', () => {
+ const list = buildEditorAudioSources([audio({ fileName: '' })], '', null, '');
+ expect(list).toHaveLength(0);
+ });
+
+ it('label 优先用 title,其次 artist,最后 fileName', () => {
+ expect(buildEditorAudioSources([audio({ title: '歌名' })], '', null, '')[0]!.label).toBe(
+ '歌名'
+ );
+ expect(buildEditorAudioSources([audio({ artist: '歌手' })], '', null, '')[0]!.label).toBe(
+ '歌手'
+ );
+ expect(buildEditorAudioSources([audio({})], '', null, '')[0]!.label).toBe('music/35-1.mp3');
+ });
+});
diff --git a/frontend/src/lib/__tests__/editor-save-bundle-diff.test.ts b/frontend/src/lib/__tests__/editor-save-bundle-diff.test.ts
index 58fdb09..592f20f 100644
--- a/frontend/src/lib/__tests__/editor-save-bundle-diff.test.ts
+++ b/frontend/src/lib/__tests__/editor-save-bundle-diff.test.ts
@@ -193,6 +193,9 @@ describe('审核报告:逐字时轴改动能否被检测到', () => {
* 背景:审核员在元数据弹窗里改字段是常见操作,而 `buildEditReport` 与
* `buildReviewReportFromDiffs` 都只看 `lyricLines`,元数据改动在报告里
* 完全看不见 —— 用户明确要求「元数据等其他数据也要 diff」。
+ *
+ * 产出格式:每个 key 的改动按「增 / 删」拆成独立条目,模板统一为
+ * 「元数据(类型)需要增加/删除内容为 `值` 的数据」。
*/
describe('审核报告:元数据改动能否被检测到', () => {
function withMetadata(metadata: Array<{ key: string; value: string[] }>): TTMLLyric {
@@ -205,11 +208,32 @@ describe('审核报告:元数据改动能否被检测到', () => {
const blocks = buildMetadataChanges(freeze, staged);
- expect(blocks).toHaveLength(1);
+ // 改值 = 删旧 + 增新,拆成两条独立动作
+ expect(blocks).toHaveLength(2);
const rendered = renderReviewReport(createReviewReport(blocks));
expect(rendered).toContain('标题');
expect(rendered).toContain('旧标题');
expect(rendered).toContain('新标题');
+ expect(rendered).toContain('删除');
+ expect(rendered).toContain('增加');
+ });
+
+ it('元数据条目应使用专属格式(类型 + 增删动作 + 内容)', () => {
+ const freeze = withMetadata([{ key: 'title', value: ['歌'] }]);
+ const staged = withMetadata([{ key: 'title', value: ['歌', '新歌'] }]);
+
+ const blocks = buildMetadataChanges(freeze, staged);
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0]).toMatchObject({
+ kind: 'metadata',
+ action: 'add',
+ key: 'title',
+ label: '标题',
+ items: ['新歌'],
+ });
+ expect(renderReviewReport(createReviewReport(blocks))).toContain(
+ '元数据(标题)需要增加内容为 `新歌` 的数据'
+ );
});
it('新增 / 删除 key 都要能识别', () => {
@@ -221,7 +245,7 @@ describe('审核报告:元数据改动能否被检测到', () => {
const blocks = buildMetadataChanges(freeze, staged);
expect(blocks).toHaveLength(1);
- expect(renderReviewReport(createReviewReport(blocks))).toContain('新增');
+ expect(renderReviewReport(createReviewReport(blocks))).toContain('增加');
// 反向:删掉 album
const removed = buildMetadataChanges(staged, freeze);
diff --git a/frontend/src/lib/review/audio-sources.ts b/frontend/src/lib/review/audio-sources.ts
new file mode 100644
index 0000000..edfcd3c
--- /dev/null
+++ b/frontend/src/lib/review/audio-sources.ts
@@ -0,0 +1,49 @@
+import type { NcmMusicInfo, SubmissionAudio } from '@/lib/types';
+
+/** 编辑器可选的音源:上传音频或网易云解析出的直链 */
+export interface EditorAudioSource {
+ /** 稳定标识:上传音频用 fileName,网易云用 `ncm:` */
+ key: string;
+ /** 可直接用于 fetch / audio 元素的播放地址 */
+ src: string;
+ /** 展示给人看的名称 */
+ label: string;
+}
+
+/** 上传音频的播放地址 */
+export function buildAudioUrl(
+ audio: { fileName?: string },
+ apiBase = import.meta.env.VITE_API_BASE ?? ''
+): string {
+ const key = audio.fileName;
+ if (!key) return '';
+ return `${apiBase}/api/v1/uploads/file/${key}`;
+}
+
+// 组装编辑器的参考音源列表
+export function buildEditorAudioSources(
+ audios: SubmissionAudio[],
+ ncmId: string,
+ ncm: Pick | null,
+ apiBase = import.meta.env.VITE_API_BASE ?? ''
+): EditorAudioSource[] {
+ const list: EditorAudioSource[] = audios
+ .map((a) => ({
+ key: a.fileName || String(a.id ?? ''),
+ src: buildAudioUrl(a, apiBase),
+ label: a.title || a.artist || a.fileName || '音频',
+ }))
+ // 没有可播放地址的条目(缺 fileName)直接丢弃,避免选中后加载必然失败
+ .filter((s) => s.src !== '');
+
+ const ncmUrl = ncm?.url?.trim();
+ if (ncmId && ncmUrl) {
+ list.push({
+ key: `ncm:${ncmId}`,
+ src: ncmUrl,
+ label: ncm?.name ? `网易云 · ${ncm.name}` : `网易云音源(${ncmId})`,
+ });
+ }
+
+ return list;
+}
diff --git a/frontend/src/pages/BindAccount.tsx b/frontend/src/pages/BindAccount.tsx
index 8659149..d69ea22 100644
--- a/frontend/src/pages/BindAccount.tsx
+++ b/frontend/src/pages/BindAccount.tsx
@@ -135,7 +135,9 @@ export function BindAccount() {
name="account"
render={({ field }) => (
- 用户名或邮箱
+
+ 用户名或邮箱
+
ctx?.audios ?? [], [ctx?.audios]);
const ncmId = ctx?.ncmId ?? '';
- // 网易云来源的投稿没有上传音频,靠解析接口拿直链当打轴基准
- const needNcm = audios.length === 0 && !!ncmId;
+ const needNcm = !!ncmId;
const ncmQuery = useQuery({
queryKey: queryKeys.ncmMusic(ncmId),
queryFn: () => api.parseNcmMusic(ncmId),
@@ -67,26 +75,24 @@ export function LyricEditorPage() {
staleTime: 5 * 60_000,
retry: false,
});
- const ncmSrc = needNcm ? (ncmQuery.data?.url ?? '') : '';
-
- // 音源列表:上传音频在前,网易云兜底(与详情页播放器一致)
- const sources = useMemo(() => {
- const list = audios.map((a) => ({
- key: a.fileName || String(a.id ?? ''),
- src: buildAudioUrl(a),
- label: a.title || a.artist || a.fileName || '音频',
- }));
- if (ncmSrc) {
- list.push({
- key: `ncm:${ncmId}`,
- src: ncmSrc,
- label: ncmQuery.data?.name || `网易云 ${ncmId}`,
- });
- }
- return list;
- }, [audios, ncmSrc, ncmId, ncmQuery.data?.name]);
- const audioSrc = sources[Math.min(audioIndex, Math.max(0, sources.length - 1))]?.src ?? '';
+ // 音源列表
+ const sources = useMemo(
+ () => buildEditorAudioSources(audios, ncmId, ncmQuery.data ?? null),
+ [audios, ncmId, ncmQuery.data]
+ );
+
+ const safeAudioIndex = Math.min(audioIndex, Math.max(0, sources.length - 1));
+ const audioSrc = sources[safeAudioIndex]?.src ?? '';
+
+ // 音源变化诊断
+ useEffect(() => {
+ console.info('[editor-audio] sources 变化', {
+ audioIndex: safeAudioIndex,
+ audioSrc,
+ sources: sources.map((s) => ({ key: s.key, src: s.src, label: s.label })),
+ });
+ }, [sources, safeAudioIndex, audioSrc]);
// 打开时标记审核中,离开时释放占用
useEffect(() => {
@@ -115,27 +121,45 @@ export function LyricEditorPage() {
useEffect(() => {
if (!audioSrc) return;
let cancelled = false;
- void audioEngine.loadMusicFromUrl(audioSrc).catch((err) => {
+ const timer = window.setTimeout(() => {
if (cancelled) return;
- /**
- * 不要直接 `String(err)` —— FFmpeg 链路抛出的可能是
- * `{ code, message }` 这种普通对象,`String()` 会得到
- * 字面量 "[object Object]",把真正的原因吞掉。
- */
- const detail =
- err instanceof Error
- ? err.message
- : typeof err === 'string'
- ? err
- : typeof err === 'object' && err !== null && 'message' in err
- ? String((err as { message: unknown }).message)
- : JSON.stringify(err);
- toast.error(`参考音频加载失败:${detail}`);
- });
+ console.info('[editor-audio] 开始加载', { audioSrc, audioIndex: safeAudioIndex });
+ void audioEngine.loadMusicFromUrl(audioSrc).catch((err) => {
+ if (cancelled) return;
+ // 切换音频时被更新的请求取代属于正常流程,不打扰用户
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes('已被更新的请求取代')) {
+ console.info('[editor-audio] 本次加载被更新的请求取代,忽略', { audioSrc });
+ return;
+ }
+ console.error('[editor-audio] 加载失败', { audioSrc, audioIndex: safeAudioIndex, error: err });
+ /**
+ * 不要直接 `String(err)` —— FFmpeg 链路抛出的可能是
+ * `{ code, message }` 这种普通对象,`String()` 会得到
+ * 字面量 "[object Object]",把真正的原因吞掉。
+ */
+ const detail =
+ err instanceof Error
+ ? err.message
+ : typeof err === 'string'
+ ? err
+ : typeof err === 'object' && err !== null && 'message' in err
+ ? String((err as { message: unknown }).message)
+ : JSON.stringify(err);
+
+ const hint = detail.includes('Failed to fetch')
+ ? '(后端服务可能未启动或网络中断,请确认后端在运行后重试)'
+ : '';
+ toast.error(`参考音频加载失败:${detail}${hint}`);
+ });
+ }, 120);
+
return () => {
cancelled = true;
+ window.clearTimeout(timer);
};
- }, [audioSrc]);
+ // 依赖收敛后的索引:越界被夹回时也要重新判定,而不是只盯 URL 字符串
+ }, [audioSrc, safeAudioIndex]);
// 离开前提示未保存(编辑期间有 IndexedDB 草稿,但提交才算最终保存)
useEffect(() => {
@@ -197,6 +221,8 @@ export function LyricEditorPage() {
onSuccess: (_data, input) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.submission(submissionId) });
void queryClient.invalidateQueries({ queryKey: ['submissions'] });
+ // 提交成功后把当前内容记为已保存基线,顶栏不再显示「有未保存改动」
+ markLyricLinesSaved();
setReviewDialog({ open: false, title: '' });
toast.success(input.uploadTtml ? '修订版已提交,等待投稿者确认' : '审核结果已提交');
void api.releaseReview(submissionId, true).catch(() => {});
@@ -289,6 +315,9 @@ export function LyricEditorPage() {
+ {/* 音频源就绪后先让审核员确认/选择参考音频(多个时可选,单个时确认) */}
+
+
{effectiveReport && (
);
}
-
-/** 上传音频的播放地址:与详情页 AudioCard 的 fileUrl 保持一致 */
-function buildAudioUrl(audio: { fileName?: string }): string {
- const key = audio.fileName;
- if (!key) return '';
- return `${import.meta.env.VITE_API_BASE ?? ''}/api/v1/uploads/file/${key}`;
-}
From 6f98cdca3f624be3b45eecc254e633d495887e44 Mon Sep 17 00:00:00 2001
From: cybaka520
Date: Sun, 11 Oct 2026 17:11:35 +0800
Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=E5=B0=86I18nKey=E7=B1=BB?=
=?UTF-8?q?=E5=9E=8B=E4=BB=8EParseKeys=E6=94=B9=E4=B8=BAstring?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/editor/modules/keyboard/types.ts | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/frontend/src/editor/modules/keyboard/types.ts b/frontend/src/editor/modules/keyboard/types.ts
index c292cf1..f1b3036 100644
--- a/frontend/src/editor/modules/keyboard/types.ts
+++ b/frontend/src/editor/modules/keyboard/types.ts
@@ -1,8 +1,6 @@
-import type { ParseKeys } from 'i18next';
import type { WritableAtom } from 'jotai';
import type { RESET_KEYBINDING } from '$/utils/keybindings';
-
-export type I18nKey = ParseKeys<'translation'>;
+export type I18nKey = string;
export type KeyBindingsConfig = string[];
export interface KeyBindingCommand {
From ce645df08cc09f437ab50ff8bd53b25a4888b711 Mon Sep 17 00:00:00 2001
From: cybaka520
Date: Sun, 11 Oct 2026 17:14:11 +0800
Subject: [PATCH 3/4] =?UTF-8?q?style:=20=E6=A0=BC=E5=BC=8F=E5=8C=96?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=A4=9A=E4=BD=99?=
=?UTF-8?q?=E7=A9=BA=E8=A1=8C=E5=B9=B6=E8=B0=83=E6=95=B4=E4=BB=A3=E7=A0=81?=
=?UTF-8?q?=E6=8D=A2=E8=A1=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/editor/components/ReduceStutterDialog.tsx | 4 ++--
frontend/src/editor/modules/audio/audio-engine.ts | 1 -
frontend/src/index.css | 8 ++++----
frontend/src/pages/BindAccount.tsx | 4 +---
frontend/src/pages/LyricEditorPage.tsx | 6 +++++-
5 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/frontend/src/editor/components/ReduceStutterDialog.tsx b/frontend/src/editor/components/ReduceStutterDialog.tsx
index 8dfe179..616746a 100644
--- a/frontend/src/editor/components/ReduceStutterDialog.tsx
+++ b/frontend/src/editor/components/ReduceStutterDialog.tsx
@@ -155,8 +155,8 @@ export function ReduceStutterDialog() {
{pair.prevWord.word} [{msToTimestamp(pair.prevWord.startTime)}~
{msToTimestamp(pair.prevWord.endTime)}] - {pair.nextWord.word} [
- {msToTimestamp(pair.nextWord.startTime)}~
- {msToTimestamp(pair.nextWord.endTime)}] : {pair.gap} ms
+ {msToTimestamp(pair.nextWord.startTime)}~{msToTimestamp(pair.nextWord.endTime)}]
+ : {pair.gap} ms
))}
diff --git a/frontend/src/editor/modules/audio/audio-engine.ts b/frontend/src/editor/modules/audio/audio-engine.ts
index 86c9158..b55fd91 100644
--- a/frontend/src/editor/modules/audio/audio-engine.ts
+++ b/frontend/src/editor/modules/audio/audio-engine.ts
@@ -331,7 +331,6 @@ class AudioEngineWrapper extends EventTarget {
}
async loadMusicFromUrl(url: string): Promise {
-
const inflight = this._inflightLoads.get(url);
if (inflight) return inflight;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 8af92cc..1200209 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -362,7 +362,7 @@ html {
--gray-12: rgb(29 29 31);
--gray-a3: rgb(29 29 31 / 0.13);
--gray-a4: rgb(29 29 31 / 0.26);
-/* 间距刻度:1 单位 = 4px */
+ /* 间距刻度:1 单位 = 4px */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
@@ -455,9 +455,9 @@ html {
/* 主题切换过渡 */
html.theme-transitioning {
transition-property:
- --amll-background, --amll-foreground, --amll-card, --amll-popover,
- --amll-popover-foreground, --amll-primary, --amll-primary-hover, --amll-primary-foreground,
- --amll-muted, --amll-muted-foreground, --amll-border, --amll-input, --amll-ring, --amll-surface,
+ --amll-background, --amll-foreground, --amll-card, --amll-popover, --amll-popover-foreground,
+ --amll-primary, --amll-primary-hover, --amll-primary-foreground, --amll-muted,
+ --amll-muted-foreground, --amll-border, --amll-input, --amll-ring, --amll-surface,
--amll-surface-2, --amll-ink, --amll-ink-2, --amll-ink-3, --amll-line, --amll-primary-soft,
--amll-primary-tint, --amll-header-bg, --amll-header-bg-scrolled, --amll-state-success,
--amll-state-warning, --amll-state-error, --amll-state-info;
diff --git a/frontend/src/pages/BindAccount.tsx b/frontend/src/pages/BindAccount.tsx
index d69ea22..8659149 100644
--- a/frontend/src/pages/BindAccount.tsx
+++ b/frontend/src/pages/BindAccount.tsx
@@ -135,9 +135,7 @@ export function BindAccount() {
name="account"
render={({ field }) => (
-
- 用户名或邮箱
-
+ 用户名或邮箱
Date: Sun, 11 Oct 2026 17:21:37 +0800
Subject: [PATCH 4/4] =?UTF-8?q?ci:=20=E5=B0=86=20Lighthouse=20=E6=80=A7?=
=?UTF-8?q?=E8=83=BD=E6=96=AD=E8=A8=80=E7=BA=A7=E5=88=AB=E4=BB=8E=20error?=
=?UTF-8?q?=20=E6=94=B9=E4=B8=BA=20warn?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/lighthouserc.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/lighthouserc.json b/frontend/lighthouserc.json
index 0060f70..ca7480d 100644
--- a/frontend/lighthouserc.json
+++ b/frontend/lighthouserc.json
@@ -12,7 +12,7 @@
},
"assert": {
"assertions": {
- "categories:performance": ["error", { "minScore": 0.6 }],
+ "categories:performance": ["warn", { "minScore": 0.6 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.12 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 3000 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 4000 }],