Map1en commited on
chore: replace prettier with oxfmt and apply formatting (#472)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .editorconfig +18 -0
- .oxfmtrc.json +22 -0
- .prettierrc +0 -6
- .vscode/extensions.json +9 -0
- bun.lock +43 -4
- package.json +11 -11
- ui/.oxlintrc.json +2 -14
- ui/app/(app)/layout.tsx +1 -1
- ui/app/(app)/page.tsx +8 -12
- ui/app/global-error.tsx +1 -5
- ui/app/globals.css +2 -2
- ui/app/layout.tsx +2 -6
- ui/app/providers.tsx +4 -3
- ui/components/ActivityBubble.tsx +24 -46
- ui/components/AppErrorBoundary.tsx +6 -8
- ui/components/AppInitializationSkeleton.tsx +8 -13
- ui/components/Canvas.tsx +1 -4
- ui/components/Image.tsx +3 -9
- ui/components/MenuBar.tsx +27 -65
- ui/components/Navigator.tsx +22 -43
- ui/components/PageManagerDialog.tsx +19 -48
- ui/components/Panels.tsx +9 -19
- ui/components/SettingsDialog.tsx +80 -173
- ui/components/Updater.tsx +18 -40
- ui/components/canvas/CanvasToolbar.tsx +42 -111
- ui/components/canvas/StatusBar.tsx +5 -4
- ui/components/canvas/TextBlockLayer.tsx +8 -17
- ui/components/canvas/ToolRail.tsx +16 -33
- ui/components/canvas/Workspace.tsx +50 -81
- ui/components/canvas/zoomGestures.ts +2 -8
- ui/components/panels/LayersPanel.tsx +17 -39
- ui/components/panels/RenderControlsPanel.tsx +48 -104
- ui/components/panels/TextBlocksPanel.tsx +22 -35
- ui/components/ui/accordion.tsx +5 -7
- ui/components/ui/alert-dialog.tsx +11 -24
- ui/components/ui/button.tsx +7 -9
- ui/components/ui/color-picker.tsx +5 -9
- ui/components/ui/context-menu.tsx +19 -47
- ui/components/ui/dialog.tsx +10 -18
- ui/components/ui/draft-textarea.tsx +1 -0
- ui/components/ui/font-select.tsx +34 -68
- ui/components/ui/input.tsx +3 -3
- ui/components/ui/label.tsx +2 -5
- ui/components/ui/menubar.tsx +20 -44
- ui/components/ui/popover.tsx +5 -11
- ui/components/ui/progress.tsx +3 -6
- ui/components/ui/scroll-area.tsx +5 -7
- ui/components/ui/select.tsx +12 -27
- ui/components/ui/separator.tsx +2 -2
- ui/components/ui/slider.tsx +5 -10
.editorconfig
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
root = true
|
| 2 |
+
|
| 3 |
+
[*]
|
| 4 |
+
charset = utf-8
|
| 5 |
+
end_of_line = lf
|
| 6 |
+
insert_final_newline = true
|
| 7 |
+
trim_trailing_whitespace = true
|
| 8 |
+
|
| 9 |
+
[*.{js,jsx,ts,tsx,json,jsonc,css,md,yml,yaml}]
|
| 10 |
+
indent_style = space
|
| 11 |
+
indent_size = 2
|
| 12 |
+
|
| 13 |
+
[*.rs]
|
| 14 |
+
indent_style = space
|
| 15 |
+
indent_size = 4
|
| 16 |
+
|
| 17 |
+
[*.md]
|
| 18 |
+
trim_trailing_whitespace = false
|
.oxfmtrc.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
| 3 |
+
"semi": false,
|
| 4 |
+
"singleQuote": true,
|
| 5 |
+
"jsxSingleQuote": true,
|
| 6 |
+
"sortImports": {
|
| 7 |
+
"internalPattern": ["@/"],
|
| 8 |
+
"newlinesBetween": true,
|
| 9 |
+
"groups": [
|
| 10 |
+
"builtin",
|
| 11 |
+
"external",
|
| 12 |
+
"internal",
|
| 13 |
+
["parent", "sibling", "index"],
|
| 14 |
+
"style",
|
| 15 |
+
"unknown"
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
"sortTailwindcss": {
|
| 19 |
+
"stylesheet": "ui/app/globals.css",
|
| 20 |
+
"functions": ["cn", "clsx", "cva", "tw"]
|
| 21 |
+
}
|
| 22 |
+
}
|
.prettierrc
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"plugins": ["prettier-plugin-tailwindcss"],
|
| 3 |
-
"semi": false,
|
| 4 |
-
"singleQuote": true,
|
| 5 |
-
"jsxSingleQuote": true
|
| 6 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.vscode/extensions.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"recommendations": [
|
| 3 |
+
"oxc.oxc-vscode",
|
| 4 |
+
"EditorConfig.EditorConfig",
|
| 5 |
+
"bradlc.vscode-tailwindcss",
|
| 6 |
+
"rust-lang.rust-analyzer"
|
| 7 |
+
],
|
| 8 |
+
"unwantedRecommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
| 9 |
+
}
|
bun.lock
CHANGED
|
@@ -7,8 +7,7 @@
|
|
| 7 |
"@playwright/test": "^1.59.1",
|
| 8 |
"@tauri-apps/cli": "^2.10.1",
|
| 9 |
"git-cliff": "^2.12.0",
|
| 10 |
-
"
|
| 11 |
-
"prettier-plugin-tailwindcss": "^0.7.2",
|
| 12 |
},
|
| 13 |
},
|
| 14 |
"ui": {
|
|
@@ -371,6 +370,44 @@
|
|
| 371 |
|
| 372 |
"@orval/zod": ["@orval/zod@8.8.0", "", { "dependencies": { "@orval/core": "8.8.0", "remeda": "^2.33.6" } }, "sha512-ouKzo7srJXuwsZmNzp8nXkM6lwiYCZoSRYFH1JFAYF77SK7AEoFul8AYObzebqmHIXC4GLUNOsxHT9N0NE8zmQ=="],
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
|
| 375 |
|
| 376 |
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
|
|
@@ -1211,6 +1248,8 @@
|
|
| 1211 |
|
| 1212 |
"orval": ["orval@8.8.0", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.8.0", "@orval/axios": "8.8.0", "@orval/core": "8.8.0", "@orval/fetch": "8.8.0", "@orval/hono": "8.8.0", "@orval/mcp": "8.8.0", "@orval/mock": "8.8.0", "@orval/query": "8.8.0", "@orval/solid-start": "8.8.0", "@orval/swr": "8.8.0", "@orval/zod": "8.8.0", "@scalar/json-magic": "^0.12.4", "@scalar/openapi-parser": "^0.25.6", "@scalar/openapi-types": "0.6.1", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-jcHcAmXCvC0g+1acsUOv722ICuXCJ0tmRl3+e0kj1lgFVsuGYame+jRZUrNtpkEZgF1RlDtmL91yFsVHv3X1eA=="],
|
| 1213 |
|
|
|
|
|
|
|
| 1214 |
"oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
|
| 1215 |
|
| 1216 |
"p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
|
|
@@ -1255,8 +1294,6 @@
|
|
| 1255 |
|
| 1256 |
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
| 1257 |
|
| 1258 |
-
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
|
| 1259 |
-
|
| 1260 |
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
| 1261 |
|
| 1262 |
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
|
@@ -1375,6 +1412,8 @@
|
|
| 1375 |
|
| 1376 |
"terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
|
| 1377 |
|
|
|
|
|
|
|
| 1378 |
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
| 1379 |
|
| 1380 |
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
|
|
|
| 7 |
"@playwright/test": "^1.59.1",
|
| 8 |
"@tauri-apps/cli": "^2.10.1",
|
| 9 |
"git-cliff": "^2.12.0",
|
| 10 |
+
"oxfmt": "^0.45.0",
|
|
|
|
| 11 |
},
|
| 12 |
},
|
| 13 |
"ui": {
|
|
|
|
| 370 |
|
| 371 |
"@orval/zod": ["@orval/zod@8.8.0", "", { "dependencies": { "@orval/core": "8.8.0", "remeda": "^2.33.6" } }, "sha512-ouKzo7srJXuwsZmNzp8nXkM6lwiYCZoSRYFH1JFAYF77SK7AEoFul8AYObzebqmHIXC4GLUNOsxHT9N0NE8zmQ=="],
|
| 372 |
|
| 373 |
+
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.45.0", "", { "os": "android", "cpu": "arm" }, "sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w=="],
|
| 374 |
+
|
| 375 |
+
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.45.0", "", { "os": "android", "cpu": "arm64" }, "sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg=="],
|
| 376 |
+
|
| 377 |
+
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.45.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww=="],
|
| 378 |
+
|
| 379 |
+
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.45.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ=="],
|
| 380 |
+
|
| 381 |
+
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.45.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg=="],
|
| 382 |
+
|
| 383 |
+
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ=="],
|
| 384 |
+
|
| 385 |
+
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w=="],
|
| 386 |
+
|
| 387 |
+
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A=="],
|
| 388 |
+
|
| 389 |
+
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g=="],
|
| 390 |
+
|
| 391 |
+
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.45.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA=="],
|
| 392 |
+
|
| 393 |
+
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ=="],
|
| 394 |
+
|
| 395 |
+
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg=="],
|
| 396 |
+
|
| 397 |
+
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.45.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w=="],
|
| 398 |
+
|
| 399 |
+
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg=="],
|
| 400 |
+
|
| 401 |
+
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw=="],
|
| 402 |
+
|
| 403 |
+
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.45.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg=="],
|
| 404 |
+
|
| 405 |
+
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.45.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg=="],
|
| 406 |
+
|
| 407 |
+
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.45.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA=="],
|
| 408 |
+
|
| 409 |
+
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.45.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug=="],
|
| 410 |
+
|
| 411 |
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
|
| 412 |
|
| 413 |
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
|
|
|
|
| 1248 |
|
| 1249 |
"orval": ["orval@8.8.0", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.8.0", "@orval/axios": "8.8.0", "@orval/core": "8.8.0", "@orval/fetch": "8.8.0", "@orval/hono": "8.8.0", "@orval/mcp": "8.8.0", "@orval/mock": "8.8.0", "@orval/query": "8.8.0", "@orval/solid-start": "8.8.0", "@orval/swr": "8.8.0", "@orval/zod": "8.8.0", "@scalar/json-magic": "^0.12.4", "@scalar/openapi-parser": "^0.25.6", "@scalar/openapi-types": "0.6.1", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-jcHcAmXCvC0g+1acsUOv722ICuXCJ0tmRl3+e0kj1lgFVsuGYame+jRZUrNtpkEZgF1RlDtmL91yFsVHv3X1eA=="],
|
| 1250 |
|
| 1251 |
+
"oxfmt": ["oxfmt@0.45.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.45.0", "@oxfmt/binding-android-arm64": "0.45.0", "@oxfmt/binding-darwin-arm64": "0.45.0", "@oxfmt/binding-darwin-x64": "0.45.0", "@oxfmt/binding-freebsd-x64": "0.45.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.45.0", "@oxfmt/binding-linux-arm-musleabihf": "0.45.0", "@oxfmt/binding-linux-arm64-gnu": "0.45.0", "@oxfmt/binding-linux-arm64-musl": "0.45.0", "@oxfmt/binding-linux-ppc64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-musl": "0.45.0", "@oxfmt/binding-linux-s390x-gnu": "0.45.0", "@oxfmt/binding-linux-x64-gnu": "0.45.0", "@oxfmt/binding-linux-x64-musl": "0.45.0", "@oxfmt/binding-openharmony-arm64": "0.45.0", "@oxfmt/binding-win32-arm64-msvc": "0.45.0", "@oxfmt/binding-win32-ia32-msvc": "0.45.0", "@oxfmt/binding-win32-x64-msvc": "0.45.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg=="],
|
| 1252 |
+
|
| 1253 |
"oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
|
| 1254 |
|
| 1255 |
"p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
|
|
|
|
| 1294 |
|
| 1295 |
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
| 1296 |
|
|
|
|
|
|
|
| 1297 |
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
| 1298 |
|
| 1299 |
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
|
|
|
| 1412 |
|
| 1413 |
"terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
|
| 1414 |
|
| 1415 |
+
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
| 1416 |
+
|
| 1417 |
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
| 1418 |
|
| 1419 |
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
package.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
| 3 |
-
"
|
| 4 |
-
|
| 5 |
-
"git-cliff": "^2.12.0",
|
| 6 |
-
"prettier": "^3.8.3",
|
| 7 |
-
"prettier-plugin-tailwindcss": "^0.7.2"
|
| 8 |
-
},
|
| 9 |
"scripts": {
|
| 10 |
"dev": "bun run scripts/dev.ts tauri dev -- --profile=release-with-debug",
|
| 11 |
"build": "bun run scripts/dev.ts tauri build --no-bundle",
|
| 12 |
"cargo": "bun run scripts/dev.ts cargo",
|
| 13 |
"lint:ui": "bun run --filter ui lint",
|
| 14 |
-
"format": "
|
|
|
|
| 15 |
"test:e2e": "playwright test"
|
| 16 |
},
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"workspaces": [
|
| 3 |
+
"ui/"
|
| 4 |
+
],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"scripts": {
|
| 6 |
"dev": "bun run scripts/dev.ts tauri dev -- --profile=release-with-debug",
|
| 7 |
"build": "bun run scripts/dev.ts tauri build --no-bundle",
|
| 8 |
"cargo": "bun run scripts/dev.ts cargo",
|
| 9 |
"lint:ui": "bun run --filter ui lint",
|
| 10 |
+
"format": "oxfmt ui package.json .oxfmtrc.json .vscode",
|
| 11 |
+
"format:check": "oxfmt --check ui package.json .oxfmtrc.json .vscode",
|
| 12 |
"test:e2e": "playwright test"
|
| 13 |
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"@playwright/test": "^1.59.1",
|
| 16 |
+
"@tauri-apps/cli": "^2.10.1",
|
| 17 |
+
"git-cliff": "^2.12.0",
|
| 18 |
+
"oxfmt": "^0.45.0"
|
| 19 |
+
}
|
| 20 |
}
|
ui/.oxlintrc.json
CHANGED
|
@@ -1,12 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
| 3 |
-
"plugins": [
|
| 4 |
-
"eslint",
|
| 5 |
-
"typescript",
|
| 6 |
-
"unicorn",
|
| 7 |
-
"oxc",
|
| 8 |
-
"react"
|
| 9 |
-
],
|
| 10 |
"categories": {
|
| 11 |
"correctness": "error"
|
| 12 |
},
|
|
@@ -30,11 +24,5 @@
|
|
| 30 |
"typescript/no-this-alias": "warn",
|
| 31 |
"unicorn/prefer-string-starts-ends-with": "warn"
|
| 32 |
},
|
| 33 |
-
"ignorePatterns": [
|
| 34 |
-
".next/",
|
| 35 |
-
"out/",
|
| 36 |
-
"dist/",
|
| 37 |
-
"build/",
|
| 38 |
-
"coverage/"
|
| 39 |
-
]
|
| 40 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
| 3 |
+
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"categories": {
|
| 5 |
"correctness": "error"
|
| 6 |
},
|
|
|
|
| 24 |
"typescript/no-this-alias": "warn",
|
| 25 |
"unicorn/prefer-string-starts-ends-with": "warn"
|
| 26 |
},
|
| 27 |
+
"ignorePatterns": [".next/", "out/", "dist/", "build/", "coverage/"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
ui/app/(app)/layout.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { MenuBar } from '@/components/MenuBar'
|
|
| 4 |
|
| 5 |
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
| 6 |
return (
|
| 7 |
-
<div className='
|
| 8 |
<MenuBar />
|
| 9 |
{children}
|
| 10 |
</div>
|
|
|
|
| 4 |
|
| 5 |
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
| 6 |
return (
|
| 7 |
+
<div className='flex h-screen w-screen flex-col overflow-hidden bg-background'>
|
| 8 |
<MenuBar />
|
| 9 |
{children}
|
| 10 |
</div>
|
ui/app/(app)/page.tsx
CHANGED
|
@@ -1,18 +1,14 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
|
| 5 |
-
import { Navigator } from '@/components/Navigator'
|
| 6 |
import { ActivityBubble } from '@/components/ActivityBubble'
|
|
|
|
| 7 |
import { AppInitializationSkeleton } from '@/components/AppInitializationSkeleton'
|
|
|
|
|
|
|
|
|
|
| 8 |
import { useGetMeta } from '@/lib/api/system/system'
|
| 9 |
-
import {
|
| 10 |
-
Group,
|
| 11 |
-
Panel,
|
| 12 |
-
Separator,
|
| 13 |
-
useDefaultLayout,
|
| 14 |
-
} from 'react-resizable-panels'
|
| 15 |
-
import { AppErrorBoundary } from '@/components/AppErrorBoundary'
|
| 16 |
|
| 17 |
const LAYOUT_ID = 'koharu-main-layout-v2'
|
| 18 |
|
|
@@ -46,7 +42,7 @@ export default function Page() {
|
|
| 46 |
<Panel id='left' defaultSize={180} minSize={120} maxSize={300}>
|
| 47 |
<Navigator />
|
| 48 |
</Panel>
|
| 49 |
-
<Separator className='bg-border/40 hover:bg-border
|
| 50 |
<Panel id='center' minSize={480}>
|
| 51 |
<AppErrorBoundary>
|
| 52 |
<div className='flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
|
|
@@ -55,7 +51,7 @@ export default function Page() {
|
|
| 55 |
</div>
|
| 56 |
</AppErrorBoundary>
|
| 57 |
</Panel>
|
| 58 |
-
<Separator className='bg-border/40 hover:bg-border
|
| 59 |
<Panel id='right' defaultSize={280} minSize={260} maxSize={400}>
|
| 60 |
<AppErrorBoundary>
|
| 61 |
<Panels />
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels'
|
| 4 |
+
|
|
|
|
| 5 |
import { ActivityBubble } from '@/components/ActivityBubble'
|
| 6 |
+
import { AppErrorBoundary } from '@/components/AppErrorBoundary'
|
| 7 |
import { AppInitializationSkeleton } from '@/components/AppInitializationSkeleton'
|
| 8 |
+
import { Workspace, StatusBar } from '@/components/Canvas'
|
| 9 |
+
import { Navigator } from '@/components/Navigator'
|
| 10 |
+
import { Panels } from '@/components/Panels'
|
| 11 |
import { useGetMeta } from '@/lib/api/system/system'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
const LAYOUT_ID = 'koharu-main-layout-v2'
|
| 14 |
|
|
|
|
| 42 |
<Panel id='left' defaultSize={180} minSize={120} maxSize={300}>
|
| 43 |
<Navigator />
|
| 44 |
</Panel>
|
| 45 |
+
<Separator className='w-1 bg-border/40 transition-colors hover:bg-border' />
|
| 46 |
<Panel id='center' minSize={480}>
|
| 47 |
<AppErrorBoundary>
|
| 48 |
<div className='flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
|
|
|
|
| 51 |
</div>
|
| 52 |
</AppErrorBoundary>
|
| 53 |
</Panel>
|
| 54 |
+
<Separator className='w-1 bg-border/40 transition-colors hover:bg-border' />
|
| 55 |
<Panel id='right' defaultSize={280} minSize={260} maxSize={400}>
|
| 56 |
<AppErrorBoundary>
|
| 57 |
<Panels />
|
ui/app/global-error.tsx
CHANGED
|
@@ -4,11 +4,7 @@ import * as Sentry from '@sentry/nextjs'
|
|
| 4 |
import NextError from 'next/error'
|
| 5 |
import { useEffect } from 'react'
|
| 6 |
|
| 7 |
-
export default function GlobalError({
|
| 8 |
-
error,
|
| 9 |
-
}: {
|
| 10 |
-
error: Error & { digest?: string }
|
| 11 |
-
}) {
|
| 12 |
useEffect(() => {
|
| 13 |
Sentry.captureException(error)
|
| 14 |
}, [error])
|
|
|
|
| 4 |
import NextError from 'next/error'
|
| 5 |
import { useEffect } from 'react'
|
| 6 |
|
| 7 |
+
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
useEffect(() => {
|
| 9 |
Sentry.captureException(error)
|
| 10 |
}, [error])
|
ui/app/globals.css
CHANGED
|
@@ -137,8 +137,8 @@
|
|
| 137 |
body {
|
| 138 |
@apply bg-background text-foreground select-none;
|
| 139 |
font-family:
|
| 140 |
-
var(--font-inter), var(--font-noto-sc), var(--font-noto-tc),
|
| 141 |
-
|
| 142 |
}
|
| 143 |
input,
|
| 144 |
textarea {
|
|
|
|
| 137 |
body {
|
| 138 |
@apply bg-background text-foreground select-none;
|
| 139 |
font-family:
|
| 140 |
+
var(--font-inter), var(--font-noto-sc), var(--font-noto-tc), var(--font-noto-jp),
|
| 141 |
+
ui-sans-serif, system-ui, sans-serif;
|
| 142 |
}
|
| 143 |
input,
|
| 144 |
textarea {
|
ui/app/layout.tsx
CHANGED
|
@@ -1,10 +1,6 @@
|
|
| 1 |
import type { Metadata } from 'next'
|
| 2 |
-
import {
|
| 3 |
-
|
| 4 |
-
Noto_Sans_JP,
|
| 5 |
-
Noto_Sans_SC,
|
| 6 |
-
Noto_Sans_TC,
|
| 7 |
-
} from 'next/font/google'
|
| 8 |
import './globals.css'
|
| 9 |
import Providers from '@/app/providers'
|
| 10 |
|
|
|
|
| 1 |
import type { Metadata } from 'next'
|
| 2 |
+
import { Inter, Noto_Sans_JP, Noto_Sans_SC, Noto_Sans_TC } from 'next/font/google'
|
| 3 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import './globals.css'
|
| 5 |
import Providers from '@/app/providers'
|
| 6 |
|
ui/app/providers.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import { useEffect, type ReactNode } from 'react'
|
| 4 |
import { I18nextProvider } from 'react-i18next'
|
| 5 |
-
|
| 6 |
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
| 7 |
import ClientOnly from '@/components/ClientOnly'
|
| 8 |
-
import { UpdaterProvider } from '@/components/Updater'
|
| 9 |
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
|
|
| 10 |
import i18n from '@/lib/i18n'
|
| 11 |
import { ProcessingProvider } from '@/lib/machines'
|
| 12 |
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
| 4 |
+
import { ThemeProvider } from 'next-themes'
|
| 5 |
import { useEffect, type ReactNode } from 'react'
|
| 6 |
import { I18nextProvider } from 'react-i18next'
|
| 7 |
+
|
|
|
|
| 8 |
import ClientOnly from '@/components/ClientOnly'
|
|
|
|
| 9 |
import { TooltipProvider } from '@/components/ui/tooltip'
|
| 10 |
+
import { UpdaterProvider } from '@/components/Updater'
|
| 11 |
import i18n from '@/lib/i18n'
|
| 12 |
import { ProcessingProvider } from '@/lib/machines'
|
| 13 |
|
ui/components/ActivityBubble.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { type ReactNode } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
-
|
| 6 |
-
import { useListDownloads } from '@/lib/api/downloads/downloads'
|
| 7 |
import { Button } from '@/components/ui/button'
|
| 8 |
-
import {
|
| 9 |
import { useProcessing } from '@/lib/machines'
|
|
|
|
| 10 |
|
| 11 |
type TranslateFunc = ReturnType<typeof useTranslation>['t']
|
| 12 |
|
|
@@ -17,7 +18,7 @@ const clampProgress = (value?: number) => {
|
|
| 17 |
|
| 18 |
function BubbleCard({ children }: { children: ReactNode }) {
|
| 19 |
return (
|
| 20 |
-
<div className='border-border bg-card/95
|
| 21 |
{children}
|
| 22 |
</div>
|
| 23 |
)
|
|
@@ -26,18 +27,18 @@ function BubbleCard({ children }: { children: ReactNode }) {
|
|
| 26 |
function ProgressBar({ percent }: { percent?: number }) {
|
| 27 |
return (
|
| 28 |
<div className='mt-3 flex items-center gap-2'>
|
| 29 |
-
<div className='
|
| 30 |
{typeof percent === 'number' ? (
|
| 31 |
<div
|
| 32 |
-
className='
|
| 33 |
style={{ width: `${percent}%` }}
|
| 34 |
/>
|
| 35 |
) : (
|
| 36 |
-
<div className='activity-progress-indeterminate
|
| 37 |
)}
|
| 38 |
</div>
|
| 39 |
{typeof percent === 'number' && (
|
| 40 |
-
<span className='
|
| 41 |
{percent}%
|
| 42 |
</span>
|
| 43 |
)}
|
|
@@ -57,14 +58,10 @@ function DownloadCard({
|
|
| 57 |
return (
|
| 58 |
<BubbleCard>
|
| 59 |
<div className='flex items-start gap-3'>
|
| 60 |
-
<div className='
|
| 61 |
<div className='flex-1'>
|
| 62 |
-
<div className='text-
|
| 63 |
-
|
| 64 |
-
</div>
|
| 65 |
-
<div className='text-muted-foreground truncate text-xs'>
|
| 66 |
-
{filename}
|
| 67 |
-
</div>
|
| 68 |
<ProgressBar percent={percent} />
|
| 69 |
</div>
|
| 70 |
</div>
|
|
@@ -82,7 +79,7 @@ function ErrorCard({
|
|
| 82 |
t: TranslateFunc
|
| 83 |
}) {
|
| 84 |
return (
|
| 85 |
-
<div className='
|
| 86 |
<div className='flex items-start gap-3'>
|
| 87 |
<div className='mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-950/70 dark:text-red-400'>
|
| 88 |
<CircleXIcon className='size-4' />
|
|
@@ -140,14 +137,9 @@ function OperationCard({
|
|
| 140 |
}) {
|
| 141 |
const ctx = machineState.context
|
| 142 |
const hasProgressNumbers = ctx.total > 0
|
| 143 |
-
const progress = clampProgress(
|
| 144 |
-
hasProgressNumbers ? (ctx.current / ctx.total) * 100 : undefined,
|
| 145 |
-
)
|
| 146 |
const displayCurrent = hasProgressNumbers
|
| 147 |
-
? Math.min(
|
| 148 |
-
ctx.total,
|
| 149 |
-
Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1),
|
| 150 |
-
)
|
| 151 |
: undefined
|
| 152 |
const total = hasProgressNumbers ? ctx.total : undefined
|
| 153 |
|
|
@@ -182,27 +174,24 @@ function OperationCard({
|
|
| 182 |
const subtitleParts = isPipelineAll
|
| 183 |
? [stepLabel]
|
| 184 |
: [imageText, stepText ?? stepLabel].filter(Boolean)
|
| 185 |
-
const subtitle =
|
| 186 |
-
subtitleParts.filter(Boolean).join(' \u00b7 ') || t('operations.inProgress')
|
| 187 |
|
| 188 |
const title = getOperationTitle(machineState, t)
|
| 189 |
|
| 190 |
return (
|
| 191 |
<BubbleCard>
|
| 192 |
<div data-testid='operation-card' className='flex items-start gap-3'>
|
| 193 |
-
<div className='
|
| 194 |
<div className='flex-1'>
|
| 195 |
<div className='flex items-start justify-between gap-2'>
|
| 196 |
<div className='flex flex-col gap-1'>
|
| 197 |
-
<div className='text-
|
| 198 |
-
|
| 199 |
-
</div>
|
| 200 |
-
<div className='text-muted-foreground text-xs'>
|
| 201 |
{subtitle || t('operations.inProgress')}
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
{isPipelineAll && total && typeof displayCurrent === 'number' ? (
|
| 205 |
-
<span className='
|
| 206 |
{t('operations.imageProgress', {
|
| 207 |
current: displayCurrent,
|
| 208 |
total,
|
|
@@ -248,22 +237,16 @@ export function ActivityBubble() {
|
|
| 248 |
.filter((d) => d.status === 'started' || d.status === 'downloading')
|
| 249 |
.map((d) => ({
|
| 250 |
...d,
|
| 251 |
-
percent:
|
| 252 |
-
d.total && d.total > 0
|
| 253 |
-
? Math.round((d.downloaded / d.total) * 100)
|
| 254 |
-
: undefined,
|
| 255 |
}))
|
| 256 |
|
| 257 |
const errorMessage = uiError?.message
|
| 258 |
|
| 259 |
-
if (!errorMessage && !isProcessing && activeDownloads.length === 0)
|
| 260 |
-
return null
|
| 261 |
|
| 262 |
return (
|
| 263 |
<div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
|
| 264 |
-
{errorMessage &&
|
| 265 |
-
<ErrorCard message={errorMessage} onDismiss={clearUiError} t={t} />
|
| 266 |
-
)}
|
| 267 |
{isProcessing && (
|
| 268 |
<OperationCard
|
| 269 |
machineState={machineState}
|
|
@@ -273,12 +256,7 @@ export function ActivityBubble() {
|
|
| 273 |
/>
|
| 274 |
)}
|
| 275 |
{activeDownloads.map((d) => (
|
| 276 |
-
<DownloadCard
|
| 277 |
-
key={d.filename}
|
| 278 |
-
filename={d.filename}
|
| 279 |
-
percent={d.percent}
|
| 280 |
-
t={t}
|
| 281 |
-
/>
|
| 282 |
))}
|
| 283 |
</div>
|
| 284 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { CircleXIcon } from 'lucide-react'
|
| 4 |
import { type ReactNode } from 'react'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
+
|
|
|
|
| 7 |
import { Button } from '@/components/ui/button'
|
| 8 |
+
import { useListDownloads } from '@/lib/api/downloads/downloads'
|
| 9 |
import { useProcessing } from '@/lib/machines'
|
| 10 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 11 |
|
| 12 |
type TranslateFunc = ReturnType<typeof useTranslation>['t']
|
| 13 |
|
|
|
|
| 18 |
|
| 19 |
function BubbleCard({ children }: { children: ReactNode }) {
|
| 20 |
return (
|
| 21 |
+
<div className='rounded-2xl border border-border bg-card/95 p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur'>
|
| 22 |
{children}
|
| 23 |
</div>
|
| 24 |
)
|
|
|
|
| 27 |
function ProgressBar({ percent }: { percent?: number }) {
|
| 28 |
return (
|
| 29 |
<div className='mt-3 flex items-center gap-2'>
|
| 30 |
+
<div className='relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted'>
|
| 31 |
{typeof percent === 'number' ? (
|
| 32 |
<div
|
| 33 |
+
className='h-full rounded-full bg-primary transition-[width] duration-700 ease-out'
|
| 34 |
style={{ width: `${percent}%` }}
|
| 35 |
/>
|
| 36 |
) : (
|
| 37 |
+
<div className='activity-progress-indeterminate absolute inset-0 w-1/2 rounded-full bg-linear-to-r from-primary/40 via-primary to-primary/40' />
|
| 38 |
)}
|
| 39 |
</div>
|
| 40 |
{typeof percent === 'number' && (
|
| 41 |
+
<span className='w-12 text-right text-[11px] font-semibold text-muted-foreground tabular-nums'>
|
| 42 |
{percent}%
|
| 43 |
</span>
|
| 44 |
)}
|
|
|
|
| 58 |
return (
|
| 59 |
<BubbleCard>
|
| 60 |
<div className='flex items-start gap-3'>
|
| 61 |
+
<div className='mt-1 h-2.5 w-2.5 animate-pulse rounded-full bg-primary shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
|
| 62 |
<div className='flex-1'>
|
| 63 |
+
<div className='text-sm font-semibold text-foreground'>{t('download.title')}</div>
|
| 64 |
+
<div className='truncate text-xs text-muted-foreground'>{filename}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
<ProgressBar percent={percent} />
|
| 66 |
</div>
|
| 67 |
</div>
|
|
|
|
| 79 |
t: TranslateFunc
|
| 80 |
}) {
|
| 81 |
return (
|
| 82 |
+
<div className='rounded-2xl border border-red-200/80 bg-card/95 p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-red-900/80'>
|
| 83 |
<div className='flex items-start gap-3'>
|
| 84 |
<div className='mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-950/70 dark:text-red-400'>
|
| 85 |
<CircleXIcon className='size-4' />
|
|
|
|
| 137 |
}) {
|
| 138 |
const ctx = machineState.context
|
| 139 |
const hasProgressNumbers = ctx.total > 0
|
| 140 |
+
const progress = clampProgress(hasProgressNumbers ? (ctx.current / ctx.total) * 100 : undefined)
|
|
|
|
|
|
|
| 141 |
const displayCurrent = hasProgressNumbers
|
| 142 |
+
? Math.min(ctx.total, Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1))
|
|
|
|
|
|
|
|
|
|
| 143 |
: undefined
|
| 144 |
const total = hasProgressNumbers ? ctx.total : undefined
|
| 145 |
|
|
|
|
| 174 |
const subtitleParts = isPipelineAll
|
| 175 |
? [stepLabel]
|
| 176 |
: [imageText, stepText ?? stepLabel].filter(Boolean)
|
| 177 |
+
const subtitle = subtitleParts.filter(Boolean).join(' \u00b7 ') || t('operations.inProgress')
|
|
|
|
| 178 |
|
| 179 |
const title = getOperationTitle(machineState, t)
|
| 180 |
|
| 181 |
return (
|
| 182 |
<BubbleCard>
|
| 183 |
<div data-testid='operation-card' className='flex items-start gap-3'>
|
| 184 |
+
<div className='mt-1 h-2.5 w-2.5 rounded-full bg-primary shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
|
| 185 |
<div className='flex-1'>
|
| 186 |
<div className='flex items-start justify-between gap-2'>
|
| 187 |
<div className='flex flex-col gap-1'>
|
| 188 |
+
<div className='text-sm font-semibold text-foreground'>{title}</div>
|
| 189 |
+
<div className='text-xs text-muted-foreground'>
|
|
|
|
|
|
|
| 190 |
{subtitle || t('operations.inProgress')}
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
{isPipelineAll && total && typeof displayCurrent === 'number' ? (
|
| 194 |
+
<span className='rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground'>
|
| 195 |
{t('operations.imageProgress', {
|
| 196 |
current: displayCurrent,
|
| 197 |
total,
|
|
|
|
| 237 |
.filter((d) => d.status === 'started' || d.status === 'downloading')
|
| 238 |
.map((d) => ({
|
| 239 |
...d,
|
| 240 |
+
percent: d.total && d.total > 0 ? Math.round((d.downloaded / d.total) * 100) : undefined,
|
|
|
|
|
|
|
|
|
|
| 241 |
}))
|
| 242 |
|
| 243 |
const errorMessage = uiError?.message
|
| 244 |
|
| 245 |
+
if (!errorMessage && !isProcessing && activeDownloads.length === 0) return null
|
|
|
|
| 246 |
|
| 247 |
return (
|
| 248 |
<div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
|
| 249 |
+
{errorMessage && <ErrorCard message={errorMessage} onDismiss={clearUiError} t={t} />}
|
|
|
|
|
|
|
| 250 |
{isProcessing && (
|
| 251 |
<OperationCard
|
| 252 |
machineState={machineState}
|
|
|
|
| 256 |
/>
|
| 257 |
)}
|
| 258 |
{activeDownloads.map((d) => (
|
| 259 |
+
<DownloadCard key={d.filename} filename={d.filename} percent={d.percent} t={t} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
))}
|
| 261 |
</div>
|
| 262 |
)
|
ui/components/AppErrorBoundary.tsx
CHANGED
|
@@ -2,22 +2,20 @@
|
|
| 2 |
|
| 3 |
import * as Sentry from '@sentry/nextjs'
|
| 4 |
import { useQueryClient } from '@tanstack/react-query'
|
| 5 |
-
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
|
| 6 |
import { type ReactNode } from 'react'
|
|
|
|
|
|
|
| 7 |
import { Button } from '@/components/ui/button'
|
| 8 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 9 |
|
| 10 |
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
| 11 |
const queryClient = useQueryClient()
|
| 12 |
-
const errorMessage =
|
| 13 |
-
error instanceof Error ? error.message : 'Unexpected error'
|
| 14 |
|
| 15 |
return (
|
| 16 |
-
<div className='
|
| 17 |
-
<p className='text-
|
| 18 |
-
|
| 19 |
-
</p>
|
| 20 |
-
<p className='text-muted-foreground max-w-md text-xs'>{errorMessage}</p>
|
| 21 |
<div className='flex flex-wrap items-center justify-center gap-2'>
|
| 22 |
<Button size='sm' variant='outline' onClick={resetErrorBoundary}>
|
| 23 |
Retry
|
|
|
|
| 2 |
|
| 3 |
import * as Sentry from '@sentry/nextjs'
|
| 4 |
import { useQueryClient } from '@tanstack/react-query'
|
|
|
|
| 5 |
import { type ReactNode } from 'react'
|
| 6 |
+
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
|
| 7 |
+
|
| 8 |
import { Button } from '@/components/ui/button'
|
| 9 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 10 |
|
| 11 |
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
| 12 |
const queryClient = useQueryClient()
|
| 13 |
+
const errorMessage = error instanceof Error ? error.message : 'Unexpected error'
|
|
|
|
| 14 |
|
| 15 |
return (
|
| 16 |
+
<div className='flex h-full min-h-0 w-full flex-col items-center justify-center gap-3 bg-muted/40 p-4 text-center'>
|
| 17 |
+
<p className='text-sm font-semibold text-foreground'>Something went wrong.</p>
|
| 18 |
+
<p className='max-w-md text-xs text-muted-foreground'>{errorMessage}</p>
|
|
|
|
|
|
|
| 19 |
<div className='flex flex-wrap items-center justify-center gap-2'>
|
| 20 |
<Button size='sm' variant='outline' onClick={resetErrorBoundary}>
|
| 21 |
Retry
|
ui/components/AppInitializationSkeleton.tsx
CHANGED
|
@@ -2,8 +2,9 @@
|
|
| 2 |
|
| 3 |
import { useMemo } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
-
|
| 6 |
import { Progress } from '@/components/ui/progress'
|
|
|
|
| 7 |
import type { DownloadState } from '@/lib/api/schemas'
|
| 8 |
|
| 9 |
const summarizeDownloads = (downloads?: DownloadState[] | null) => {
|
|
@@ -14,15 +15,11 @@ const summarizeDownloads = (downloads?: DownloadState[] | null) => {
|
|
| 14 |
for (const d of downloads) {
|
| 15 |
total += d.total ?? 0
|
| 16 |
downloaded += d.downloaded
|
| 17 |
-
if (d.status === 'started' || d.status === 'downloading')
|
| 18 |
-
active = d.filename
|
| 19 |
}
|
| 20 |
return {
|
| 21 |
filename: active,
|
| 22 |
-
percent:
|
| 23 |
-
total > 0
|
| 24 |
-
? Math.min(100, Math.round((downloaded / total) * 100))
|
| 25 |
-
: undefined,
|
| 26 |
}
|
| 27 |
}
|
| 28 |
|
|
@@ -35,7 +32,7 @@ export function AppInitializationSkeleton() {
|
|
| 35 |
const progress = useMemo(() => summarizeDownloads(downloads), [downloads])
|
| 36 |
|
| 37 |
return (
|
| 38 |
-
<div className='
|
| 39 |
<div className='flex flex-col items-center gap-6'>
|
| 40 |
<img
|
| 41 |
src='/icon-large.png'
|
|
@@ -45,16 +42,14 @@ export function AppInitializationSkeleton() {
|
|
| 45 |
/>
|
| 46 |
|
| 47 |
<div className='flex flex-col items-center gap-1'>
|
| 48 |
-
<h1 className='text-
|
| 49 |
Koharu
|
| 50 |
</h1>
|
| 51 |
-
<p className='text-
|
| 52 |
-
{t('common.initializing')}
|
| 53 |
-
</p>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
<div className='w-56'>
|
| 57 |
-
<p className='
|
| 58 |
{progress?.filename ?? '\u00A0'}
|
| 59 |
</p>
|
| 60 |
<Progress
|
|
|
|
| 2 |
|
| 3 |
import { useMemo } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
+
|
| 6 |
import { Progress } from '@/components/ui/progress'
|
| 7 |
+
import { useListDownloads } from '@/lib/api/downloads/downloads'
|
| 8 |
import type { DownloadState } from '@/lib/api/schemas'
|
| 9 |
|
| 10 |
const summarizeDownloads = (downloads?: DownloadState[] | null) => {
|
|
|
|
| 15 |
for (const d of downloads) {
|
| 16 |
total += d.total ?? 0
|
| 17 |
downloaded += d.downloaded
|
| 18 |
+
if (d.status === 'started' || d.status === 'downloading') active = d.filename
|
|
|
|
| 19 |
}
|
| 20 |
return {
|
| 21 |
filename: active,
|
| 22 |
+
percent: total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : undefined,
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
|
|
|
| 32 |
const progress = useMemo(() => summarizeDownloads(downloads), [downloads])
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div className='flex min-h-0 flex-1 items-center justify-center bg-background'>
|
| 36 |
<div className='flex flex-col items-center gap-6'>
|
| 37 |
<img
|
| 38 |
src='/icon-large.png'
|
|
|
|
| 42 |
/>
|
| 43 |
|
| 44 |
<div className='flex flex-col items-center gap-1'>
|
| 45 |
+
<h1 className='text-lg font-semibold tracking-widest text-foreground uppercase'>
|
| 46 |
Koharu
|
| 47 |
</h1>
|
| 48 |
+
<p className='text-xs text-muted-foreground'>{t('common.initializing')}</p>
|
|
|
|
|
|
|
| 49 |
</div>
|
| 50 |
|
| 51 |
<div className='w-56'>
|
| 52 |
+
<p className='mb-1.5 h-4 truncate text-center text-[11px] text-muted-foreground'>
|
| 53 |
{progress?.filename ?? '\u00A0'}
|
| 54 |
</p>
|
| 55 |
<Progress
|
ui/components/Canvas.tsx
CHANGED
|
@@ -2,7 +2,4 @@
|
|
| 2 |
|
| 3 |
export { Workspace } from '@/components/canvas/Workspace'
|
| 4 |
export { StatusBar } from '@/components/canvas/StatusBar'
|
| 5 |
-
export {
|
| 6 |
-
fitCanvasToViewport,
|
| 7 |
-
resetCanvasScale,
|
| 8 |
-
} from '@/components/canvas/canvasViewport'
|
|
|
|
| 2 |
|
| 3 |
export { Workspace } from '@/components/canvas/Workspace'
|
| 4 |
export { StatusBar } from '@/components/canvas/StatusBar'
|
| 5 |
+
export { fitCanvasToViewport, resetCanvasScale } from '@/components/canvas/canvasViewport'
|
|
|
|
|
|
|
|
|
ui/components/Image.tsx
CHANGED
|
@@ -2,11 +2,8 @@
|
|
| 2 |
|
| 3 |
import type { CSSProperties } from 'react'
|
| 4 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
convertToBlob,
|
| 8 |
-
revokeObjectUrlLater,
|
| 9 |
-
} from '@/lib/util'
|
| 10 |
|
| 11 |
type ImageProps = {
|
| 12 |
data?: Uint8Array
|
|
@@ -213,10 +210,7 @@ export function Image({
|
|
| 213 |
style={{
|
| 214 |
...baseStyle,
|
| 215 |
opacity: nextSrc ? (crossfade ? 0 : opacity) : opacity,
|
| 216 |
-
transition:
|
| 217 |
-
nextSrc && crossfade
|
| 218 |
-
? `opacity ${FADE_DURATION_MS}ms ease`
|
| 219 |
-
: undefined,
|
| 220 |
}}
|
| 221 |
/>
|
| 222 |
)}
|
|
|
|
| 2 |
|
| 3 |
import type { CSSProperties } from 'react'
|
| 4 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 5 |
+
|
| 6 |
+
import { cancelObjectUrlRevoke, convertToBlob, revokeObjectUrlLater } from '@/lib/util'
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
type ImageProps = {
|
| 9 |
data?: Uint8Array
|
|
|
|
| 210 |
style={{
|
| 211 |
...baseStyle,
|
| 212 |
opacity: nextSrc ? (crossfade ? 0 : opacity) : opacity,
|
| 213 |
+
transition: nextSrc && crossfade ? `opacity ${FADE_DURATION_MS}ms ease` : undefined,
|
|
|
|
|
|
|
|
|
|
| 214 |
}}
|
| 215 |
/>
|
| 216 |
)}
|
ui/components/MenuBar.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useCallback, useEffect, useState } from 'react'
|
| 4 |
import { MinusIcon, SquareIcon, XIcon, CopyIcon } from 'lucide-react'
|
| 5 |
-
import {
|
| 6 |
import { useTranslation } from 'react-i18next'
|
| 7 |
|
|
|
|
|
|
|
| 8 |
const isMacOS = () =>
|
| 9 |
-
typeof navigator !== 'undefined' &&
|
| 10 |
-
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
|
| 11 |
|
| 12 |
const windowControls = {
|
| 13 |
async close() {
|
|
@@ -27,8 +27,10 @@ const windowControls = {
|
|
| 27 |
return getCurrentWindow().isMaximized()
|
| 28 |
},
|
| 29 |
}
|
| 30 |
-
import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
|
| 31 |
import Image from 'next/image'
|
|
|
|
|
|
|
|
|
|
| 32 |
import {
|
| 33 |
Menubar,
|
| 34 |
MenubarContent,
|
|
@@ -37,11 +39,10 @@ import {
|
|
| 37 |
MenubarSeparator,
|
| 38 |
MenubarTrigger,
|
| 39 |
} from '@/components/ui/menubar'
|
| 40 |
-
import
|
| 41 |
import { useProcessing } from '@/lib/machines'
|
| 42 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 43 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 44 |
-
import type { PipelineJobRequest } from '@/lib/api/schemas'
|
| 45 |
|
| 46 |
type MenuItem = {
|
| 47 |
label: string
|
|
@@ -61,9 +62,7 @@ export function MenuBar() {
|
|
| 61 |
const { send } = useProcessing()
|
| 62 |
const [settingsOpen, setSettingsOpen] = useState(false)
|
| 63 |
const [settingsTab, setSettingsTab] = useState<TabId>('appearance')
|
| 64 |
-
const hasDocument = useEditorUiStore(
|
| 65 |
-
(state) => state.currentDocumentId !== null,
|
| 66 |
-
)
|
| 67 |
|
| 68 |
const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
|
| 69 |
const { selectedTarget, selectedLanguage, renderEffect, renderStroke } =
|
|
@@ -88,26 +87,22 @@ export function MenuBar() {
|
|
| 88 |
const fileMenuItems: MenuItem[] = [
|
| 89 |
{
|
| 90 |
label: t('menu.openFile'),
|
| 91 |
-
onSelect: () =>
|
| 92 |
-
send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }),
|
| 93 |
testId: 'menu-file-open',
|
| 94 |
},
|
| 95 |
{
|
| 96 |
label: t('menu.addFile'),
|
| 97 |
-
onSelect: () =>
|
| 98 |
-
send({ type: 'START_IMPORT', mode: 'append', source: 'files' }),
|
| 99 |
testId: 'menu-file-add',
|
| 100 |
},
|
| 101 |
{
|
| 102 |
label: t('menu.openFolder'),
|
| 103 |
-
onSelect: () =>
|
| 104 |
-
send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }),
|
| 105 |
testId: 'menu-file-open-folder',
|
| 106 |
},
|
| 107 |
{
|
| 108 |
label: t('menu.addFolder'),
|
| 109 |
-
onSelect: () =>
|
| 110 |
-
send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }),
|
| 111 |
testId: 'menu-file-add-folder',
|
| 112 |
},
|
| 113 |
{
|
|
@@ -180,8 +175,7 @@ export function MenuBar() {
|
|
| 180 |
},
|
| 181 |
{
|
| 182 |
label: t('menu.processAll'),
|
| 183 |
-
onSelect: () =>
|
| 184 |
-
send({ type: 'START_PIPELINE', request: buildPipelineRequest() }),
|
| 185 |
testId: 'menu-process-all',
|
| 186 |
},
|
| 187 |
],
|
|
@@ -203,19 +197,13 @@ export function MenuBar() {
|
|
| 203 |
const isWindowsTauri = isTauri() && !isMacOS()
|
| 204 |
|
| 205 |
return (
|
| 206 |
-
<div className='
|
| 207 |
{/* macOS traffic lights */}
|
| 208 |
{isNativeMacOS && <MacOSControls />}
|
| 209 |
|
| 210 |
{/* Logo */}
|
| 211 |
<div className='flex h-full items-center pl-2 select-none'>
|
| 212 |
-
<Image
|
| 213 |
-
src='/icon.png'
|
| 214 |
-
alt='Koharu'
|
| 215 |
-
width={18}
|
| 216 |
-
height={18}
|
| 217 |
-
draggable={false}
|
| 218 |
-
/>
|
| 219 |
</div>
|
| 220 |
|
| 221 |
{/* Menu items */}
|
|
@@ -223,16 +211,11 @@ export function MenuBar() {
|
|
| 223 |
<MenubarMenu>
|
| 224 |
<MenubarTrigger
|
| 225 |
data-testid='menu-file-trigger'
|
| 226 |
-
className='hover:bg-accent data-[state=open]:bg-accent
|
| 227 |
>
|
| 228 |
{t('menu.file')}
|
| 229 |
</MenubarTrigger>
|
| 230 |
-
<MenubarContent
|
| 231 |
-
className='min-w-36'
|
| 232 |
-
align='start'
|
| 233 |
-
sideOffset={5}
|
| 234 |
-
alignOffset={-3}
|
| 235 |
-
>
|
| 236 |
{fileMenuItems.map((item) => (
|
| 237 |
<MenubarItem
|
| 238 |
key={item.label}
|
|
@@ -266,16 +249,11 @@ export function MenuBar() {
|
|
| 266 |
<MenubarMenu key={label}>
|
| 267 |
<MenubarTrigger
|
| 268 |
data-testid={triggerTestId}
|
| 269 |
-
className='hover:bg-accent data-[state=open]:bg-accent
|
| 270 |
>
|
| 271 |
{label}
|
| 272 |
</MenubarTrigger>
|
| 273 |
-
<MenubarContent
|
| 274 |
-
className='min-w-36'
|
| 275 |
-
align='start'
|
| 276 |
-
sideOffset={5}
|
| 277 |
-
alignOffset={-3}
|
| 278 |
-
>
|
| 279 |
{items.map((item) => (
|
| 280 |
<MenubarItem
|
| 281 |
key={item.label}
|
|
@@ -297,15 +275,10 @@ export function MenuBar() {
|
|
| 297 |
</MenubarMenu>
|
| 298 |
))}
|
| 299 |
<MenubarMenu>
|
| 300 |
-
<MenubarTrigger className='hover:bg-accent data-[state=open]:bg-accent
|
| 301 |
{t('menu.help')}
|
| 302 |
</MenubarTrigger>
|
| 303 |
-
<MenubarContent
|
| 304 |
-
className='min-w-36'
|
| 305 |
-
align='start'
|
| 306 |
-
sideOffset={5}
|
| 307 |
-
alignOffset={-3}
|
| 308 |
-
>
|
| 309 |
{helpMenuItems.map((item) => (
|
| 310 |
<MenubarItem
|
| 311 |
key={item.label}
|
|
@@ -337,19 +310,12 @@ export function MenuBar() {
|
|
| 337 |
</Menubar>
|
| 338 |
|
| 339 |
{/* Draggable region */}
|
| 340 |
-
<div
|
| 341 |
-
data-tauri-drag-region
|
| 342 |
-
className='flex h-full flex-1 items-center justify-center'
|
| 343 |
-
/>
|
| 344 |
|
| 345 |
{/* Window controls for Windows */}
|
| 346 |
{isWindowsTauri && <WindowControls />}
|
| 347 |
|
| 348 |
-
<SettingsDialog
|
| 349 |
-
open={settingsOpen}
|
| 350 |
-
onOpenChange={setSettingsOpen}
|
| 351 |
-
defaultTab={settingsTab}
|
| 352 |
-
/>
|
| 353 |
</div>
|
| 354 |
)
|
| 355 |
}
|
|
@@ -407,7 +373,7 @@ function WindowControls() {
|
|
| 407 |
<div className='flex h-full'>
|
| 408 |
<button
|
| 409 |
onClick={() => void windowControls.minimize()}
|
| 410 |
-
className='
|
| 411 |
>
|
| 412 |
<MinusIcon className='size-4' />
|
| 413 |
</button>
|
|
@@ -415,13 +381,9 @@ function WindowControls() {
|
|
| 415 |
onClick={() => {
|
| 416 |
void windowControls.toggleMaximize().then(updateMaximized)
|
| 417 |
}}
|
| 418 |
-
className='
|
| 419 |
>
|
| 420 |
-
{maximized ?
|
| 421 |
-
<CopyIcon className='size-3.5' />
|
| 422 |
-
) : (
|
| 423 |
-
<SquareIcon className='size-3.5' />
|
| 424 |
-
)}
|
| 425 |
</button>
|
| 426 |
<button
|
| 427 |
onClick={() => void windowControls.close()}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { MinusIcon, SquareIcon, XIcon, CopyIcon } from 'lucide-react'
|
| 4 |
+
import { useCallback, useEffect, useState } from 'react'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
|
| 7 |
+
import { isTauri, openExternalUrl } from '@/lib/backend'
|
| 8 |
+
|
| 9 |
const isMacOS = () =>
|
| 10 |
+
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
|
|
|
|
| 11 |
|
| 12 |
const windowControls = {
|
| 13 |
async close() {
|
|
|
|
| 27 |
return getCurrentWindow().isMaximized()
|
| 28 |
},
|
| 29 |
}
|
|
|
|
| 30 |
import Image from 'next/image'
|
| 31 |
+
|
| 32 |
+
import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
|
| 33 |
+
import { SettingsDialog, type TabId } from '@/components/SettingsDialog'
|
| 34 |
import {
|
| 35 |
Menubar,
|
| 36 |
MenubarContent,
|
|
|
|
| 39 |
MenubarSeparator,
|
| 40 |
MenubarTrigger,
|
| 41 |
} from '@/components/ui/menubar'
|
| 42 |
+
import type { PipelineJobRequest } from '@/lib/api/schemas'
|
| 43 |
import { useProcessing } from '@/lib/machines'
|
| 44 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 45 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
|
|
| 46 |
|
| 47 |
type MenuItem = {
|
| 48 |
label: string
|
|
|
|
| 62 |
const { send } = useProcessing()
|
| 63 |
const [settingsOpen, setSettingsOpen] = useState(false)
|
| 64 |
const [settingsTab, setSettingsTab] = useState<TabId>('appearance')
|
| 65 |
+
const hasDocument = useEditorUiStore((state) => state.currentDocumentId !== null)
|
|
|
|
|
|
|
| 66 |
|
| 67 |
const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
|
| 68 |
const { selectedTarget, selectedLanguage, renderEffect, renderStroke } =
|
|
|
|
| 87 |
const fileMenuItems: MenuItem[] = [
|
| 88 |
{
|
| 89 |
label: t('menu.openFile'),
|
| 90 |
+
onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }),
|
|
|
|
| 91 |
testId: 'menu-file-open',
|
| 92 |
},
|
| 93 |
{
|
| 94 |
label: t('menu.addFile'),
|
| 95 |
+
onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'files' }),
|
|
|
|
| 96 |
testId: 'menu-file-add',
|
| 97 |
},
|
| 98 |
{
|
| 99 |
label: t('menu.openFolder'),
|
| 100 |
+
onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }),
|
|
|
|
| 101 |
testId: 'menu-file-open-folder',
|
| 102 |
},
|
| 103 |
{
|
| 104 |
label: t('menu.addFolder'),
|
| 105 |
+
onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }),
|
|
|
|
| 106 |
testId: 'menu-file-add-folder',
|
| 107 |
},
|
| 108 |
{
|
|
|
|
| 175 |
},
|
| 176 |
{
|
| 177 |
label: t('menu.processAll'),
|
| 178 |
+
onSelect: () => send({ type: 'START_PIPELINE', request: buildPipelineRequest() }),
|
|
|
|
| 179 |
testId: 'menu-process-all',
|
| 180 |
},
|
| 181 |
],
|
|
|
|
| 197 |
const isWindowsTauri = isTauri() && !isMacOS()
|
| 198 |
|
| 199 |
return (
|
| 200 |
+
<div className='flex h-8 items-center border-b border-border bg-background text-[13px] text-foreground'>
|
| 201 |
{/* macOS traffic lights */}
|
| 202 |
{isNativeMacOS && <MacOSControls />}
|
| 203 |
|
| 204 |
{/* Logo */}
|
| 205 |
<div className='flex h-full items-center pl-2 select-none'>
|
| 206 |
+
<Image src='/icon.png' alt='Koharu' width={18} height={18} draggable={false} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
|
| 209 |
{/* Menu items */}
|
|
|
|
| 211 |
<MenubarMenu>
|
| 212 |
<MenubarTrigger
|
| 213 |
data-testid='menu-file-trigger'
|
| 214 |
+
className='rounded px-3 py-1.5 font-medium hover:bg-accent data-[state=open]:bg-accent'
|
| 215 |
>
|
| 216 |
{t('menu.file')}
|
| 217 |
</MenubarTrigger>
|
| 218 |
+
<MenubarContent className='min-w-36' align='start' sideOffset={5} alignOffset={-3}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
{fileMenuItems.map((item) => (
|
| 220 |
<MenubarItem
|
| 221 |
key={item.label}
|
|
|
|
| 249 |
<MenubarMenu key={label}>
|
| 250 |
<MenubarTrigger
|
| 251 |
data-testid={triggerTestId}
|
| 252 |
+
className='rounded px-3 py-1.5 font-medium hover:bg-accent data-[state=open]:bg-accent'
|
| 253 |
>
|
| 254 |
{label}
|
| 255 |
</MenubarTrigger>
|
| 256 |
+
<MenubarContent className='min-w-36' align='start' sideOffset={5} alignOffset={-3}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
{items.map((item) => (
|
| 258 |
<MenubarItem
|
| 259 |
key={item.label}
|
|
|
|
| 275 |
</MenubarMenu>
|
| 276 |
))}
|
| 277 |
<MenubarMenu>
|
| 278 |
+
<MenubarTrigger className='rounded px-3 py-1.5 font-medium hover:bg-accent data-[state=open]:bg-accent'>
|
| 279 |
{t('menu.help')}
|
| 280 |
</MenubarTrigger>
|
| 281 |
+
<MenubarContent className='min-w-36' align='start' sideOffset={5} alignOffset={-3}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
{helpMenuItems.map((item) => (
|
| 283 |
<MenubarItem
|
| 284 |
key={item.label}
|
|
|
|
| 310 |
</Menubar>
|
| 311 |
|
| 312 |
{/* Draggable region */}
|
| 313 |
+
<div data-tauri-drag-region className='flex h-full flex-1 items-center justify-center' />
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
{/* Window controls for Windows */}
|
| 316 |
{isWindowsTauri && <WindowControls />}
|
| 317 |
|
| 318 |
+
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab={settingsTab} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
</div>
|
| 320 |
)
|
| 321 |
}
|
|
|
|
| 373 |
<div className='flex h-full'>
|
| 374 |
<button
|
| 375 |
onClick={() => void windowControls.minimize()}
|
| 376 |
+
className='flex h-full w-11 items-center justify-center hover:bg-accent'
|
| 377 |
>
|
| 378 |
<MinusIcon className='size-4' />
|
| 379 |
</button>
|
|
|
|
| 381 |
onClick={() => {
|
| 382 |
void windowControls.toggleMaximize().then(updateMaximized)
|
| 383 |
}}
|
| 384 |
+
className='flex h-full w-11 items-center justify-center hover:bg-accent'
|
| 385 |
>
|
| 386 |
+
{maximized ? <CopyIcon className='size-3.5' /> : <SquareIcon className='size-3.5' />}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
</button>
|
| 388 |
<button
|
| 389 |
onClick={() => void windowControls.close()}
|
ui/components/Navigator.tsx
CHANGED
|
@@ -1,22 +1,18 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useRef, useState } from 'react'
|
| 4 |
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 5 |
import { LayoutGridIcon } from 'lucide-react'
|
|
|
|
| 6 |
import { useTranslation } from 'react-i18next'
|
| 7 |
-
|
| 8 |
-
useListDocuments,
|
| 9 |
-
getGetDocumentThumbnailUrl,
|
| 10 |
-
} from '@/lib/api/documents/documents'
|
| 11 |
-
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 12 |
-
import { Button } from '@/components/ui/button'
|
| 13 |
import { PageManagerDialog } from '@/components/PageManagerDialog'
|
|
|
|
| 14 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const THUMBNAIL_DPR =
|
| 17 |
-
typeof window !== 'undefined'
|
| 18 |
-
? Math.min(Math.ceil(window.devicePixelRatio || 1), 3)
|
| 19 |
-
: 2
|
| 20 |
|
| 21 |
// Fixed row height: thumbnail (aspect 3:4 in ~150px width ≈ 200px) + page number + padding
|
| 22 |
const ROW_HEIGHT = 230
|
|
@@ -26,12 +22,8 @@ export function Navigator() {
|
|
| 26 |
const { data: documents = [] } = useListDocuments()
|
| 27 |
const totalPages = documents.length
|
| 28 |
const currentDocumentId = useEditorUiStore((state) => state.currentDocumentId)
|
| 29 |
-
const setCurrentDocumentId = useEditorUiStore(
|
| 30 |
-
|
| 31 |
-
)
|
| 32 |
-
const currentDocumentIndex = documents.findIndex(
|
| 33 |
-
(d) => d.id === currentDocumentId,
|
| 34 |
-
)
|
| 35 |
const viewportRef = useRef<HTMLDivElement | null>(null)
|
| 36 |
const { t } = useTranslation()
|
| 37 |
const [pageManagerOpen, setPageManagerOpen] = useState(false)
|
|
@@ -47,17 +39,15 @@ export function Navigator() {
|
|
| 47 |
<div
|
| 48 |
data-testid='navigator-panel'
|
| 49 |
data-total-pages={totalPages}
|
| 50 |
-
className='
|
| 51 |
>
|
| 52 |
-
<div className='
|
| 53 |
<div>
|
| 54 |
-
<p className='text-
|
| 55 |
{t('navigator.title')}
|
| 56 |
</p>
|
| 57 |
-
<p className='text-
|
| 58 |
-
{totalPages
|
| 59 |
-
? t('navigator.pages', { count: totalPages })
|
| 60 |
-
: t('navigator.empty')}
|
| 61 |
</p>
|
| 62 |
</div>
|
| 63 |
{totalPages > 1 && (
|
|
@@ -74,9 +64,9 @@ export function Navigator() {
|
|
| 74 |
)}
|
| 75 |
</div>
|
| 76 |
|
| 77 |
-
<div className='
|
| 78 |
{totalPages > 0 ? (
|
| 79 |
-
<span className='bg-secondary
|
| 80 |
#{currentDocumentIndex + 1}
|
| 81 |
</span>
|
| 82 |
) : (
|
|
@@ -85,10 +75,7 @@ export function Navigator() {
|
|
| 85 |
</div>
|
| 86 |
|
| 87 |
<ScrollArea className='min-h-0 flex-1' viewportRef={viewportRef}>
|
| 88 |
-
<div
|
| 89 |
-
className='relative w-full'
|
| 90 |
-
style={{ height: virtualizer.getTotalSize() }}
|
| 91 |
-
>
|
| 92 |
{virtualizer.getVirtualItems().map((virtualRow) => {
|
| 93 |
const doc = documents[virtualRow.index]
|
| 94 |
return (
|
|
@@ -113,10 +100,7 @@ export function Navigator() {
|
|
| 113 |
</div>
|
| 114 |
</ScrollArea>
|
| 115 |
|
| 116 |
-
<PageManagerDialog
|
| 117 |
-
open={pageManagerOpen}
|
| 118 |
-
onOpenChange={setPageManagerOpen}
|
| 119 |
-
/>
|
| 120 |
</div>
|
| 121 |
)
|
| 122 |
}
|
|
@@ -128,12 +112,7 @@ type PagePreviewProps = {
|
|
| 128 |
onSelect: () => void
|
| 129 |
}
|
| 130 |
|
| 131 |
-
function PagePreview({
|
| 132 |
-
index,
|
| 133 |
-
documentId,
|
| 134 |
-
selected,
|
| 135 |
-
onSelect,
|
| 136 |
-
}: PagePreviewProps) {
|
| 137 |
const src = documentId
|
| 138 |
? getGetDocumentThumbnailUrl(documentId, { size: 200 * THUMBNAIL_DPR })
|
| 139 |
: undefined
|
|
@@ -145,7 +124,7 @@ function PagePreview({
|
|
| 145 |
data-testid={`navigator-page-${index}`}
|
| 146 |
data-page-index={index}
|
| 147 |
data-selected={selected}
|
| 148 |
-
className='
|
| 149 |
>
|
| 150 |
<div className='flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded'>
|
| 151 |
{src ? (
|
|
@@ -156,11 +135,11 @@ function PagePreview({
|
|
| 156 |
className='max-h-full max-w-full rounded object-contain'
|
| 157 |
/>
|
| 158 |
) : (
|
| 159 |
-
<div className='
|
| 160 |
)}
|
| 161 |
</div>
|
| 162 |
-
<div className='
|
| 163 |
-
<div className='
|
| 164 |
</div>
|
| 165 |
</Button>
|
| 166 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 4 |
import { LayoutGridIcon } from 'lucide-react'
|
| 5 |
+
import { useRef, useState } from 'react'
|
| 6 |
import { useTranslation } from 'react-i18next'
|
| 7 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import { PageManagerDialog } from '@/components/PageManagerDialog'
|
| 9 |
+
import { Button } from '@/components/ui/button'
|
| 10 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 11 |
+
import { useListDocuments, getGetDocumentThumbnailUrl } from '@/lib/api/documents/documents'
|
| 12 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 13 |
|
| 14 |
const THUMBNAIL_DPR =
|
| 15 |
+
typeof window !== 'undefined' ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) : 2
|
|
|
|
|
|
|
| 16 |
|
| 17 |
// Fixed row height: thumbnail (aspect 3:4 in ~150px width ≈ 200px) + page number + padding
|
| 18 |
const ROW_HEIGHT = 230
|
|
|
|
| 22 |
const { data: documents = [] } = useListDocuments()
|
| 23 |
const totalPages = documents.length
|
| 24 |
const currentDocumentId = useEditorUiStore((state) => state.currentDocumentId)
|
| 25 |
+
const setCurrentDocumentId = useEditorUiStore((state) => state.setCurrentDocumentId)
|
| 26 |
+
const currentDocumentIndex = documents.findIndex((d) => d.id === currentDocumentId)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const viewportRef = useRef<HTMLDivElement | null>(null)
|
| 28 |
const { t } = useTranslation()
|
| 29 |
const [pageManagerOpen, setPageManagerOpen] = useState(false)
|
|
|
|
| 39 |
<div
|
| 40 |
data-testid='navigator-panel'
|
| 41 |
data-total-pages={totalPages}
|
| 42 |
+
className='flex h-full min-h-0 w-full flex-col border-r bg-muted/50'
|
| 43 |
>
|
| 44 |
+
<div className='flex items-center justify-between border-b border-border px-2 py-1.5'>
|
| 45 |
<div>
|
| 46 |
+
<p className='text-xs tracking-wide text-muted-foreground uppercase'>
|
| 47 |
{t('navigator.title')}
|
| 48 |
</p>
|
| 49 |
+
<p className='text-xs font-semibold text-foreground'>
|
| 50 |
+
{totalPages ? t('navigator.pages', { count: totalPages }) : t('navigator.empty')}
|
|
|
|
|
|
|
| 51 |
</p>
|
| 52 |
</div>
|
| 53 |
{totalPages > 1 && (
|
|
|
|
| 64 |
)}
|
| 65 |
</div>
|
| 66 |
|
| 67 |
+
<div className='flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground'>
|
| 68 |
{totalPages > 0 ? (
|
| 69 |
+
<span className='bg-secondary px-2 py-0.5 font-mono text-[10px] text-secondary-foreground'>
|
| 70 |
#{currentDocumentIndex + 1}
|
| 71 |
</span>
|
| 72 |
) : (
|
|
|
|
| 75 |
</div>
|
| 76 |
|
| 77 |
<ScrollArea className='min-h-0 flex-1' viewportRef={viewportRef}>
|
| 78 |
+
<div className='relative w-full' style={{ height: virtualizer.getTotalSize() }}>
|
|
|
|
|
|
|
|
|
|
| 79 |
{virtualizer.getVirtualItems().map((virtualRow) => {
|
| 80 |
const doc = documents[virtualRow.index]
|
| 81 |
return (
|
|
|
|
| 100 |
</div>
|
| 101 |
</ScrollArea>
|
| 102 |
|
| 103 |
+
<PageManagerDialog open={pageManagerOpen} onOpenChange={setPageManagerOpen} />
|
|
|
|
|
|
|
|
|
|
| 104 |
</div>
|
| 105 |
)
|
| 106 |
}
|
|
|
|
| 112 |
onSelect: () => void
|
| 113 |
}
|
| 114 |
|
| 115 |
+
function PagePreview({ index, documentId, selected, onSelect }: PagePreviewProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const src = documentId
|
| 117 |
? getGetDocumentThumbnailUrl(documentId, { size: 200 * THUMBNAIL_DPR })
|
| 118 |
: undefined
|
|
|
|
| 124 |
data-testid={`navigator-page-${index}`}
|
| 125 |
data-page-index={index}
|
| 126 |
data-selected={selected}
|
| 127 |
+
className='flex h-full w-full flex-col gap-0.5 rounded border border-transparent bg-card p-1.5 text-left shadow-sm data-[selected=true]:border-primary'
|
| 128 |
>
|
| 129 |
<div className='flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded'>
|
| 130 |
{src ? (
|
|
|
|
| 135 |
className='max-h-full max-w-full rounded object-contain'
|
| 136 |
/>
|
| 137 |
) : (
|
| 138 |
+
<div className='h-full w-full rounded bg-muted' />
|
| 139 |
)}
|
| 140 |
</div>
|
| 141 |
+
<div className='flex shrink-0 items-center text-xs text-muted-foreground'>
|
| 142 |
+
<div className='mx-auto font-semibold text-foreground'>{index + 1}</div>
|
| 143 |
</div>
|
| 144 |
</Button>
|
| 145 |
)
|
ui/components/PageManagerDialog.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useCallback, useEffect, useMemo, useState } from 'react'
|
| 4 |
import {
|
| 5 |
DndContext,
|
| 6 |
KeyboardSensor,
|
|
@@ -20,35 +19,27 @@ import {
|
|
| 20 |
import { CSS } from '@dnd-kit/utilities'
|
| 21 |
import { useQueryClient } from '@tanstack/react-query'
|
| 22 |
import { GripVerticalIcon, Loader2Icon } from 'lucide-react'
|
|
|
|
| 23 |
import { useTranslation } from 'react-i18next'
|
|
|
|
|
|
|
|
|
|
| 24 |
import {
|
| 25 |
getGetDocumentThumbnailUrl,
|
| 26 |
getListDocumentsQueryKey,
|
| 27 |
useListDocuments,
|
| 28 |
useReorderDocuments,
|
| 29 |
} from '@/lib/api/documents/documents'
|
| 30 |
-
import { Button } from '@/components/ui/button'
|
| 31 |
-
import {
|
| 32 |
-
Dialog,
|
| 33 |
-
DialogContent,
|
| 34 |
-
DialogDescription,
|
| 35 |
-
DialogTitle,
|
| 36 |
-
} from '@/components/ui/dialog'
|
| 37 |
|
| 38 |
const THUMBNAIL_DPR =
|
| 39 |
-
typeof window !== 'undefined'
|
| 40 |
-
? Math.min(Math.ceil(window.devicePixelRatio || 1), 3)
|
| 41 |
-
: 2
|
| 42 |
|
| 43 |
type PageManagerDialogProps = {
|
| 44 |
open: boolean
|
| 45 |
onOpenChange: (open: boolean) => void
|
| 46 |
}
|
| 47 |
|
| 48 |
-
export function PageManagerDialog({
|
| 49 |
-
open,
|
| 50 |
-
onOpenChange,
|
| 51 |
-
}: PageManagerDialogProps) {
|
| 52 |
const { data: documents = [] } = useListDocuments()
|
| 53 |
const queryClient = useQueryClient()
|
| 54 |
const { t } = useTranslation()
|
|
@@ -56,10 +47,7 @@ export function PageManagerDialog({
|
|
| 56 |
|
| 57 |
const [orderedIds, setOrderedIds] = useState<string[]>([])
|
| 58 |
|
| 59 |
-
const docsById = useMemo(
|
| 60 |
-
() => Object.fromEntries(documents.map((d) => [d.id, d])),
|
| 61 |
-
[documents],
|
| 62 |
-
)
|
| 63 |
|
| 64 |
useEffect(() => {
|
| 65 |
if (open) {
|
|
@@ -116,9 +104,7 @@ export function PageManagerDialog({
|
|
| 116 |
<div className='flex items-center justify-between px-6 pt-6 pb-2'>
|
| 117 |
<div>
|
| 118 |
<DialogTitle>{t('navigator.pageManager.title')}</DialogTitle>
|
| 119 |
-
<DialogDescription>
|
| 120 |
-
{t('navigator.pageManager.description')}
|
| 121 |
-
</DialogDescription>
|
| 122 |
</div>
|
| 123 |
</div>
|
| 124 |
|
|
@@ -128,28 +114,20 @@ export function PageManagerDialog({
|
|
| 128 |
collisionDetection={closestCenter}
|
| 129 |
onDragEnd={handleDragEnd}
|
| 130 |
>
|
| 131 |
-
<SortableContext
|
| 132 |
-
items={orderedIds}
|
| 133 |
-
strategy={rectSortingStrategy}
|
| 134 |
-
>
|
| 135 |
<div
|
| 136 |
data-testid='page-manager-grid'
|
| 137 |
className='grid grid-cols-3 gap-3 sm:grid-cols-4'
|
| 138 |
>
|
| 139 |
{orderedIds.map((id, index) => (
|
| 140 |
-
<SortablePageCard
|
| 141 |
-
key={id}
|
| 142 |
-
id={id}
|
| 143 |
-
index={index}
|
| 144 |
-
name={docsById[id]?.name}
|
| 145 |
-
/>
|
| 146 |
))}
|
| 147 |
</div>
|
| 148 |
</SortableContext>
|
| 149 |
</DndContext>
|
| 150 |
</div>
|
| 151 |
|
| 152 |
-
<div className='
|
| 153 |
<Button
|
| 154 |
variant='outline'
|
| 155 |
onClick={() => onOpenChange(false)}
|
|
@@ -162,9 +140,7 @@ export function PageManagerDialog({
|
|
| 162 |
onClick={handleSave}
|
| 163 |
disabled={!hasChanges || reorderMutation.isPending}
|
| 164 |
>
|
| 165 |
-
{reorderMutation.isPending &&
|
| 166 |
-
<Loader2Icon className='mr-2 h-4 w-4 animate-spin' />
|
| 167 |
-
)}
|
| 168 |
{t('common.save')}
|
| 169 |
</Button>
|
| 170 |
</div>
|
|
@@ -180,14 +156,9 @@ type SortablePageCardProps = {
|
|
| 180 |
}
|
| 181 |
|
| 182 |
function SortablePageCard({ id, index, name }: SortablePageCardProps) {
|
| 183 |
-
const {
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
setNodeRef,
|
| 187 |
-
transform,
|
| 188 |
-
transition,
|
| 189 |
-
isDragging,
|
| 190 |
-
} = useSortable({ id })
|
| 191 |
|
| 192 |
const style: React.CSSProperties = {
|
| 193 |
transform: CSS.Transform.toString(transform),
|
|
@@ -215,8 +186,8 @@ function PageCard({ id, index, name, dragging }: PageCardProps) {
|
|
| 215 |
return (
|
| 216 |
<div
|
| 217 |
data-testid={`page-manager-card-${index}`}
|
| 218 |
-
className={`
|
| 219 |
-
dragging ? '
|
| 220 |
}`}
|
| 221 |
>
|
| 222 |
<div className='flex aspect-3/4 w-full items-center justify-center overflow-hidden rounded'>
|
|
@@ -228,9 +199,9 @@ function PageCard({ id, index, name, dragging }: PageCardProps) {
|
|
| 228 |
className='max-h-full max-w-full rounded object-contain'
|
| 229 |
/>
|
| 230 |
</div>
|
| 231 |
-
<div className='
|
| 232 |
<GripVerticalIcon className='h-3.5 w-3.5 shrink-0' />
|
| 233 |
-
<span className='
|
| 234 |
</div>
|
| 235 |
</div>
|
| 236 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import {
|
| 4 |
DndContext,
|
| 5 |
KeyboardSensor,
|
|
|
|
| 19 |
import { CSS } from '@dnd-kit/utilities'
|
| 20 |
import { useQueryClient } from '@tanstack/react-query'
|
| 21 |
import { GripVerticalIcon, Loader2Icon } from 'lucide-react'
|
| 22 |
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
| 23 |
import { useTranslation } from 'react-i18next'
|
| 24 |
+
|
| 25 |
+
import { Button } from '@/components/ui/button'
|
| 26 |
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
| 27 |
import {
|
| 28 |
getGetDocumentThumbnailUrl,
|
| 29 |
getListDocumentsQueryKey,
|
| 30 |
useListDocuments,
|
| 31 |
useReorderDocuments,
|
| 32 |
} from '@/lib/api/documents/documents'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
const THUMBNAIL_DPR =
|
| 35 |
+
typeof window !== 'undefined' ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) : 2
|
|
|
|
|
|
|
| 36 |
|
| 37 |
type PageManagerDialogProps = {
|
| 38 |
open: boolean
|
| 39 |
onOpenChange: (open: boolean) => void
|
| 40 |
}
|
| 41 |
|
| 42 |
+
export function PageManagerDialog({ open, onOpenChange }: PageManagerDialogProps) {
|
|
|
|
|
|
|
|
|
|
| 43 |
const { data: documents = [] } = useListDocuments()
|
| 44 |
const queryClient = useQueryClient()
|
| 45 |
const { t } = useTranslation()
|
|
|
|
| 47 |
|
| 48 |
const [orderedIds, setOrderedIds] = useState<string[]>([])
|
| 49 |
|
| 50 |
+
const docsById = useMemo(() => Object.fromEntries(documents.map((d) => [d.id, d])), [documents])
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
useEffect(() => {
|
| 53 |
if (open) {
|
|
|
|
| 104 |
<div className='flex items-center justify-between px-6 pt-6 pb-2'>
|
| 105 |
<div>
|
| 106 |
<DialogTitle>{t('navigator.pageManager.title')}</DialogTitle>
|
| 107 |
+
<DialogDescription>{t('navigator.pageManager.description')}</DialogDescription>
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
|
|
|
|
| 114 |
collisionDetection={closestCenter}
|
| 115 |
onDragEnd={handleDragEnd}
|
| 116 |
>
|
| 117 |
+
<SortableContext items={orderedIds} strategy={rectSortingStrategy}>
|
|
|
|
|
|
|
|
|
|
| 118 |
<div
|
| 119 |
data-testid='page-manager-grid'
|
| 120 |
className='grid grid-cols-3 gap-3 sm:grid-cols-4'
|
| 121 |
>
|
| 122 |
{orderedIds.map((id, index) => (
|
| 123 |
+
<SortablePageCard key={id} id={id} index={index} name={docsById[id]?.name} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
))}
|
| 125 |
</div>
|
| 126 |
</SortableContext>
|
| 127 |
</DndContext>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
+
<div className='flex items-center justify-end gap-2 border-t border-border px-6 py-4'>
|
| 131 |
<Button
|
| 132 |
variant='outline'
|
| 133 |
onClick={() => onOpenChange(false)}
|
|
|
|
| 140 |
onClick={handleSave}
|
| 141 |
disabled={!hasChanges || reorderMutation.isPending}
|
| 142 |
>
|
| 143 |
+
{reorderMutation.isPending && <Loader2Icon className='mr-2 h-4 w-4 animate-spin' />}
|
|
|
|
|
|
|
| 144 |
{t('common.save')}
|
| 145 |
</Button>
|
| 146 |
</div>
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
function SortablePageCard({ id, index, name }: SortablePageCardProps) {
|
| 159 |
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
| 160 |
+
id,
|
| 161 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
const style: React.CSSProperties = {
|
| 164 |
transform: CSS.Transform.toString(transform),
|
|
|
|
| 186 |
return (
|
| 187 |
<div
|
| 188 |
data-testid={`page-manager-card-${index}`}
|
| 189 |
+
className={`flex flex-col items-center gap-1 rounded border bg-card p-2 shadow-sm select-none ${
|
| 190 |
+
dragging ? 'shadow-lg ring-2 ring-primary' : ''
|
| 191 |
}`}
|
| 192 |
>
|
| 193 |
<div className='flex aspect-3/4 w-full items-center justify-center overflow-hidden rounded'>
|
|
|
|
| 199 |
className='max-h-full max-w-full rounded object-contain'
|
| 200 |
/>
|
| 201 |
</div>
|
| 202 |
+
<div className='flex w-full items-center justify-center gap-1 text-xs text-muted-foreground'>
|
| 203 |
<GripVerticalIcon className='h-3.5 w-3.5 shrink-0' />
|
| 204 |
+
<span className='font-semibold text-foreground'>{index + 1}</span>
|
| 205 |
</div>
|
| 206 |
</div>
|
| 207 |
)
|
ui/components/Panels.tsx
CHANGED
|
@@ -1,39 +1,32 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useTranslation } from 'react-i18next'
|
| 4 |
import { LayersIcon, SlidersHorizontalIcon } from 'lucide-react'
|
|
|
|
|
|
|
| 5 |
import { LayersPanel } from '@/components/panels/LayersPanel'
|
| 6 |
import { RenderControlsPanel } from '@/components/panels/RenderControlsPanel'
|
| 7 |
import { TextBlocksPanel } from '@/components/panels/TextBlocksPanel'
|
| 8 |
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
| 9 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
|
|
| 10 |
|
| 11 |
export function Panels() {
|
| 12 |
const { t } = useTranslation()
|
| 13 |
|
| 14 |
return (
|
| 15 |
-
<div className='
|
| 16 |
<Tabs
|
| 17 |
defaultValue='layers'
|
| 18 |
-
className='
|
| 19 |
data-testid='panels-settings-tabs'
|
| 20 |
>
|
| 21 |
-
<TabsList className='
|
| 22 |
-
<TabsTrigger
|
| 23 |
-
value='layers'
|
| 24 |
-
data-testid='panels-tab-layers'
|
| 25 |
-
className='gap-1'
|
| 26 |
-
>
|
| 27 |
<LayersIcon className='size-3.5' />
|
| 28 |
<span className='text-xs font-semibold tracking-wide uppercase'>
|
| 29 |
{t('layers.title')}
|
| 30 |
</span>
|
| 31 |
</TabsTrigger>
|
| 32 |
-
<TabsTrigger
|
| 33 |
-
value='layout'
|
| 34 |
-
data-testid='panels-tab-layout'
|
| 35 |
-
className='gap-1'
|
| 36 |
-
>
|
| 37 |
<SlidersHorizontalIcon className='size-3.5' />
|
| 38 |
<span className='text-xs font-semibold tracking-wide uppercase'>
|
| 39 |
{t('panels.render')}
|
|
@@ -56,10 +49,7 @@ export function Panels() {
|
|
| 56 |
className='min-h-0 flex-1 px-2 pb-2 data-[state=inactive]:hidden'
|
| 57 |
data-testid='panels-layout'
|
| 58 |
>
|
| 59 |
-
<ScrollArea
|
| 60 |
-
className='h-full'
|
| 61 |
-
viewportClassName='pr-1 [&>div]:!block'
|
| 62 |
-
>
|
| 63 |
<div className='pt-1'>
|
| 64 |
<RenderControlsPanel />
|
| 65 |
</div>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { LayersIcon, SlidersHorizontalIcon } from 'lucide-react'
|
| 4 |
+
import { useTranslation } from 'react-i18next'
|
| 5 |
+
|
| 6 |
import { LayersPanel } from '@/components/panels/LayersPanel'
|
| 7 |
import { RenderControlsPanel } from '@/components/panels/RenderControlsPanel'
|
| 8 |
import { TextBlocksPanel } from '@/components/panels/TextBlocksPanel'
|
|
|
|
| 9 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 10 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
| 11 |
|
| 12 |
export function Panels() {
|
| 13 |
const { t } = useTranslation()
|
| 14 |
|
| 15 |
return (
|
| 16 |
+
<div className='flex h-full min-h-0 w-full flex-col border-l bg-muted/50'>
|
| 17 |
<Tabs
|
| 18 |
defaultValue='layers'
|
| 19 |
+
className='h-60 shrink-0 gap-0 border-b border-border'
|
| 20 |
data-testid='panels-settings-tabs'
|
| 21 |
>
|
| 22 |
+
<TabsList className='m-2 mb-0 grid w-[calc(100%-1rem)] grid-cols-2 bg-muted/70'>
|
| 23 |
+
<TabsTrigger value='layers' data-testid='panels-tab-layers' className='gap-1'>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
<LayersIcon className='size-3.5' />
|
| 25 |
<span className='text-xs font-semibold tracking-wide uppercase'>
|
| 26 |
{t('layers.title')}
|
| 27 |
</span>
|
| 28 |
</TabsTrigger>
|
| 29 |
+
<TabsTrigger value='layout' data-testid='panels-tab-layout' className='gap-1'>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
<SlidersHorizontalIcon className='size-3.5' />
|
| 31 |
<span className='text-xs font-semibold tracking-wide uppercase'>
|
| 32 |
{t('panels.render')}
|
|
|
|
| 49 |
className='min-h-0 flex-1 px-2 pb-2 data-[state=inactive]:hidden'
|
| 50 |
data-testid='panels-layout'
|
| 51 |
>
|
| 52 |
+
<ScrollArea className='h-full' viewportClassName='pr-1 [&>div]:!block'>
|
|
|
|
|
|
|
|
|
|
| 53 |
<div className='pt-1'>
|
| 54 |
<RenderControlsPanel />
|
| 55 |
</div>
|
ui/components/SettingsDialog.tsx
CHANGED
|
@@ -1,9 +1,6 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
| 4 |
import { useQueryClient } from '@tanstack/react-query'
|
| 5 |
-
import { useTheme } from 'next-themes'
|
| 6 |
-
import { useTranslation } from 'react-i18next'
|
| 7 |
import {
|
| 8 |
SunIcon,
|
| 9 |
MoonIcon,
|
|
@@ -21,29 +18,16 @@ import {
|
|
| 21 |
RotateCcwIcon,
|
| 22 |
AlertTriangleIcon,
|
| 23 |
} from 'lucide-react'
|
| 24 |
-
import {
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
DialogDescription,
|
| 29 |
-
} from '@/components/ui/dialog'
|
| 30 |
import {
|
| 31 |
Accordion,
|
| 32 |
AccordionItem,
|
| 33 |
AccordionTrigger,
|
| 34 |
AccordionContent,
|
| 35 |
} from '@/components/ui/accordion'
|
| 36 |
-
import {
|
| 37 |
-
Select,
|
| 38 |
-
SelectContent,
|
| 39 |
-
SelectItem,
|
| 40 |
-
SelectTrigger,
|
| 41 |
-
SelectValue,
|
| 42 |
-
} from '@/components/ui/select'
|
| 43 |
-
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 44 |
-
import { Button } from '@/components/ui/button'
|
| 45 |
-
import { Input } from '@/components/ui/input'
|
| 46 |
-
import { Label } from '@/components/ui/label'
|
| 47 |
import {
|
| 48 |
AlertDialog,
|
| 49 |
AlertDialogAction,
|
|
@@ -52,15 +36,29 @@ import {
|
|
| 52 |
AlertDialogDescription,
|
| 53 |
AlertDialogTitle,
|
| 54 |
} from '@/components/ui/alert-dialog'
|
| 55 |
-
import {
|
| 56 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 57 |
import {
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
import { getLlmCatalog, getGetLlmCatalogQueryKey } from '@/lib/api/llm/llm'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
import {
|
| 65 |
getPlatform,
|
| 66 |
formatShortcut,
|
|
@@ -68,14 +66,7 @@ import {
|
|
| 68 |
areShortcutsEqual,
|
| 69 |
isModifierKey,
|
| 70 |
} from '@/lib/shortcutUtils'
|
| 71 |
-
import { supportedLanguages } from '@/lib/i18n'
|
| 72 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 73 |
-
import type {
|
| 74 |
-
UpdateConfigBody,
|
| 75 |
-
ProviderConfig,
|
| 76 |
-
LlmProviderCatalog,
|
| 77 |
-
GetEngineCatalog200,
|
| 78 |
-
} from '@/lib/api/schemas'
|
| 79 |
|
| 80 |
const GITHUB_REPO = 'mayocream/koharu'
|
| 81 |
|
|
@@ -113,20 +104,15 @@ export function SettingsDialog({
|
|
| 113 |
}, [defaultTab, open])
|
| 114 |
|
| 115 |
const [appConfig, setAppConfig] = useState<UpdateConfigBody | null>(null)
|
| 116 |
-
const [providerCatalogs, setProviderCatalogs] = useState<
|
| 117 |
-
LlmProviderCatalog[]
|
| 118 |
-
>([])
|
| 119 |
const [apiKeyDrafts, setApiKeyDrafts] = useState<Record<string, string>>({})
|
| 120 |
const [dataPathDraft, setDataPathDraft] = useState('')
|
| 121 |
const [httpConnectTimeoutDraft, setHttpConnectTimeoutDraft] = useState('')
|
| 122 |
const [httpReadTimeoutDraft, setHttpReadTimeoutDraft] = useState('')
|
| 123 |
const [httpMaxRetriesDraft, setHttpMaxRetriesDraft] = useState('')
|
| 124 |
-
const [storageSettingsError, setStorageSettingsError] = useState<
|
| 125 |
-
string | null
|
| 126 |
-
>(null)
|
| 127 |
const [isSavingStorageSettings, setIsSavingStorageSettings] = useState(false)
|
| 128 |
-
const [engineCatalog, setEngineCatalog] =
|
| 129 |
-
useState<GetEngineCatalog200 | null>(null)
|
| 130 |
const [appVersion, setAppVersion] = useState<string>()
|
| 131 |
const updater = useUpdater()
|
| 132 |
|
|
@@ -142,7 +128,7 @@ export function SettingsDialog({
|
|
| 142 |
setAppConfig(config)
|
| 143 |
setProviderCatalogs(catalog.providers)
|
| 144 |
setEngineCatalog(engines)
|
| 145 |
-
} catch {
|
| 146 |
})()
|
| 147 |
}, [open])
|
| 148 |
|
|
@@ -174,12 +160,8 @@ export function SettingsDialog({
|
|
| 174 |
setHttpConnectTimeoutDraft(
|
| 175 |
String(appConfig.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT),
|
| 176 |
)
|
| 177 |
-
setHttpReadTimeoutDraft(
|
| 178 |
-
|
| 179 |
-
)
|
| 180 |
-
setHttpMaxRetriesDraft(
|
| 181 |
-
String(appConfig.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES),
|
| 182 |
-
)
|
| 183 |
setStorageSettingsError(null)
|
| 184 |
}, [appConfig])
|
| 185 |
|
|
@@ -196,10 +178,7 @@ export function SettingsDialog({
|
|
| 196 |
}
|
| 197 |
}
|
| 198 |
|
| 199 |
-
const upsertProvider = (
|
| 200 |
-
id: string,
|
| 201 |
-
updater: (p: ProviderConfig) => ProviderConfig,
|
| 202 |
-
) => {
|
| 203 |
if (!appConfig) return
|
| 204 |
const providers = [...(appConfig.providers ?? [])]
|
| 205 |
const idx = providers.findIndex((p) => p.id === id)
|
|
@@ -263,13 +242,10 @@ export function SettingsDialog({
|
|
| 263 |
const storageSettingsUnchanged =
|
| 264 |
dataPathDraft.trim() === appConfig?.data?.path &&
|
| 265 |
httpConnectTimeoutDraft.trim() ===
|
| 266 |
-
|
| 267 |
-
appConfig?.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT,
|
| 268 |
-
) &&
|
| 269 |
httpReadTimeoutDraft.trim() ===
|
| 270 |
-
|
| 271 |
-
httpMaxRetriesDraft.trim() ===
|
| 272 |
-
String(appConfig?.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES)
|
| 273 |
|
| 274 |
return (
|
| 275 |
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
@@ -279,8 +255,8 @@ export function SettingsDialog({
|
|
| 279 |
|
| 280 |
<div className='flex h-full'>
|
| 281 |
{/* Sidebar */}
|
| 282 |
-
<nav className='
|
| 283 |
-
<p className='
|
| 284 |
{t('settings.title')}
|
| 285 |
</p>
|
| 286 |
{TABS.map(({ id, icon: Icon, labelKey }) => (
|
|
@@ -288,7 +264,7 @@ export function SettingsDialog({
|
|
| 288 |
key={id}
|
| 289 |
onClick={() => setTab(id)}
|
| 290 |
data-active={tab === id}
|
| 291 |
-
className='text-muted-foreground hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground
|
| 292 |
>
|
| 293 |
<Icon className='size-4 shrink-0' />
|
| 294 |
{t(labelKey)}
|
|
@@ -322,12 +298,8 @@ export function SettingsDialog({
|
|
| 322 |
base_url: v || null,
|
| 323 |
}))
|
| 324 |
}
|
| 325 |
-
onBaseUrlBlur={() =>
|
| 326 |
-
|
| 327 |
-
}
|
| 328 |
-
onApiKeyChange={(id, v) =>
|
| 329 |
-
setApiKeyDrafts((c) => ({ ...c, [id]: v }))
|
| 330 |
-
}
|
| 331 |
onSaveKey={(id) => {
|
| 332 |
const key = apiKeyDrafts[id]?.trim()
|
| 333 |
if (!key || !appConfig) return
|
|
@@ -349,8 +321,7 @@ export function SettingsDialog({
|
|
| 349 |
if (!appConfig) return
|
| 350 |
const providers = [...(appConfig.providers ?? [])]
|
| 351 |
const idx = providers.findIndex((p) => p.id === id)
|
| 352 |
-
if (idx >= 0)
|
| 353 |
-
providers[idx] = { ...providers[idx], api_key: null }
|
| 354 |
void persistConfig({ ...appConfig, providers }).then(() =>
|
| 355 |
setApiKeyDrafts((c) => {
|
| 356 |
const n = { ...c }
|
|
@@ -428,7 +399,7 @@ function AppearancePane() {
|
|
| 428 |
key={value}
|
| 429 |
onClick={() => setTheme(value)}
|
| 430 |
data-active={theme === value}
|
| 431 |
-
className='border-border bg-card text-muted-foreground hover:border-foreground/30 data-[active=true]:border-primary data-[active=true]:text-foreground
|
| 432 |
>
|
| 433 |
<Icon className='size-5' />
|
| 434 |
<span className='text-xs font-medium'>{t(labelKey)}</span>
|
|
@@ -438,10 +409,7 @@ function AppearancePane() {
|
|
| 438 |
</Section>
|
| 439 |
|
| 440 |
<Section title={t('settings.language')}>
|
| 441 |
-
<Select
|
| 442 |
-
value={i18n.language}
|
| 443 |
-
onValueChange={(v) => i18n.changeLanguage(v)}
|
| 444 |
-
>
|
| 445 |
<SelectTrigger className='w-full'>
|
| 446 |
<SelectValue />
|
| 447 |
</SelectTrigger>
|
|
@@ -512,9 +480,7 @@ function EnginesPane({
|
|
| 512 |
|
| 513 |
return (
|
| 514 |
<div className='space-y-4'>
|
| 515 |
-
<p className='text-
|
| 516 |
-
{t('settings.enginesDescription')}
|
| 517 |
-
</p>
|
| 518 |
{sections.map(({ label, key, engines }) => (
|
| 519 |
<div key={key} className='space-y-1.5'>
|
| 520 |
<Label className='text-xs'>{label}</Label>
|
|
@@ -564,17 +530,14 @@ function ProvidersPane({
|
|
| 564 |
|
| 565 |
if (!catalogs.length)
|
| 566 |
return (
|
| 567 |
-
<p className='
|
| 568 |
{t('settings.loadingProviders')}
|
| 569 |
</p>
|
| 570 |
)
|
| 571 |
|
| 572 |
return (
|
| 573 |
<div className='space-y-6'>
|
| 574 |
-
<Section
|
| 575 |
-
title={t('settings.apiKeys')}
|
| 576 |
-
description={t('settings.providersDescription')}
|
| 577 |
-
>
|
| 578 |
<Accordion type='multiple' className='-mx-1'>
|
| 579 |
{catalogs.map((provider) => {
|
| 580 |
const cfg = config?.providers?.find((p) => p.id === provider.id)
|
|
@@ -590,37 +553,25 @@ function ProvidersPane({
|
|
| 590 |
: 'bg-muted-foreground'
|
| 591 |
|
| 592 |
return (
|
| 593 |
-
<AccordionItem
|
| 594 |
-
key={provider.id}
|
| 595 |
-
value={provider.id}
|
| 596 |
-
className='border-border'
|
| 597 |
-
>
|
| 598 |
<AccordionTrigger className='px-1 py-3 hover:no-underline'>
|
| 599 |
<div className='flex items-center gap-2.5'>
|
| 600 |
-
<span
|
| 601 |
-
className={`size-2 shrink-0 rounded-full ${statusColor}`}
|
| 602 |
-
/>
|
| 603 |
<span className='text-sm font-medium'>{provider.name}</span>
|
| 604 |
</div>
|
| 605 |
</AccordionTrigger>
|
| 606 |
<AccordionContent className='space-y-4 px-1 pt-1 pb-4'>
|
| 607 |
{provider.error && (
|
| 608 |
-
<p className='text-
|
| 609 |
-
{provider.error}
|
| 610 |
-
</p>
|
| 611 |
)}
|
| 612 |
|
| 613 |
{provider.requiresBaseUrl && (
|
| 614 |
<div className='space-y-1.5'>
|
| 615 |
-
<Label className='text-xs'>
|
| 616 |
-
{t('settings.localLlmBaseUrl')}
|
| 617 |
-
</Label>
|
| 618 |
<Input
|
| 619 |
type='url'
|
| 620 |
value={cfg?.base_url ?? ''}
|
| 621 |
-
onChange={(e) =>
|
| 622 |
-
onBaseUrlChange(provider.id, e.target.value)
|
| 623 |
-
}
|
| 624 |
onBlur={onBaseUrlBlur}
|
| 625 |
placeholder='https://api.example.com/v1'
|
| 626 |
/>
|
|
@@ -633,12 +584,9 @@ function ProvidersPane({
|
|
| 633 |
<Input
|
| 634 |
type='password'
|
| 635 |
value={draft}
|
| 636 |
-
onChange={(e) =>
|
| 637 |
-
onApiKeyChange(provider.id, e.target.value)
|
| 638 |
-
}
|
| 639 |
onKeyDown={(e) => {
|
| 640 |
-
if (e.key === 'Enter' && hasDraft)
|
| 641 |
-
onSaveKey(provider.id)
|
| 642 |
}}
|
| 643 |
placeholder={
|
| 644 |
cfg?.api_key === '[REDACTED]'
|
|
@@ -648,10 +596,7 @@ function ProvidersPane({
|
|
| 648 |
className='[&::-ms-reveal]:hidden'
|
| 649 |
/>
|
| 650 |
{hasDraft ? (
|
| 651 |
-
<Button
|
| 652 |
-
size='sm'
|
| 653 |
-
onClick={() => onSaveKey(provider.id)}
|
| 654 |
-
>
|
| 655 |
{t('settings.apiKeySave')}
|
| 656 |
</Button>
|
| 657 |
) : cfg?.api_key === '[REDACTED]' ? (
|
|
@@ -697,9 +642,7 @@ function KeybindsPane() {
|
|
| 697 |
const { t } = useTranslation()
|
| 698 |
const shortcuts = usePreferencesStore((state) => state.shortcuts)
|
| 699 |
const setShortcuts = usePreferencesStore((state) => state.setShortcuts)
|
| 700 |
-
const resetShortcutsStore = usePreferencesStore(
|
| 701 |
-
(state) => state.resetShortcuts,
|
| 702 |
-
)
|
| 703 |
const [pendingShortcuts, setPendingShortcuts] = useState(shortcuts)
|
| 704 |
const [recordingKey, setRecordingKey] = useState<string | null>(null)
|
| 705 |
const [error, setError] = useState<string | null>(null)
|
|
@@ -821,20 +764,14 @@ function KeybindsPane() {
|
|
| 821 |
return (
|
| 822 |
<div className='flex h-full flex-col gap-6'>
|
| 823 |
<div className='grow space-y-6 overflow-y-auto pr-2'>
|
| 824 |
-
<Section
|
| 825 |
-
|
| 826 |
-
description={t('settings.keybindsDescription')}
|
| 827 |
-
>
|
| 828 |
-
<div className='bg-card border-border divide-border divide-y overflow-hidden rounded-xl border'>
|
| 829 |
{SHORTCUT_ITEMS.map((item) => {
|
| 830 |
const currentVal = pendingShortcuts[item.key]
|
| 831 |
const hasConflict = (conflictCounts.get(currentVal) || 0) > 1
|
| 832 |
|
| 833 |
return (
|
| 834 |
-
<div
|
| 835 |
-
key={item.key}
|
| 836 |
-
className='flex items-center justify-between px-4 py-3'
|
| 837 |
-
>
|
| 838 |
<div className='flex items-center gap-2'>
|
| 839 |
<span className='text-sm'>{t(item.labelKey)}</span>
|
| 840 |
{hasConflict && (
|
|
@@ -863,11 +800,11 @@ function KeybindsPane() {
|
|
| 863 |
</Section>
|
| 864 |
</div>
|
| 865 |
|
| 866 |
-
<div className='
|
| 867 |
<Button
|
| 868 |
variant='ghost'
|
| 869 |
size='sm'
|
| 870 |
-
className='text-muted-foreground hover:text-foreground
|
| 871 |
onClick={handleReset}
|
| 872 |
>
|
| 873 |
<RotateCcwIcon className='size-4' />
|
|
@@ -898,9 +835,7 @@ function KeybindsPane() {
|
|
| 898 |
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
|
| 899 |
<AlertDialogContent>
|
| 900 |
<AlertDialogTitle>{t('settings.shortcutReset')}</AlertDialogTitle>
|
| 901 |
-
<AlertDialogDescription>
|
| 902 |
-
{t('settings.shortcutResetDescription')}
|
| 903 |
-
</AlertDialogDescription>
|
| 904 |
<div className='flex justify-end gap-2'>
|
| 905 |
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
| 906 |
<AlertDialogAction onClick={handleConfirmReset}>
|
|
@@ -947,27 +882,18 @@ function StoragePane({
|
|
| 947 |
|
| 948 |
return (
|
| 949 |
<>
|
| 950 |
-
<Section
|
| 951 |
-
title={t('settings.runtime')}
|
| 952 |
-
description={t('settings.runtimeDescription')}
|
| 953 |
-
>
|
| 954 |
<div className='space-y-1.5'>
|
| 955 |
<Label className='text-xs'>{t('settings.dataPath')}</Label>
|
| 956 |
-
<Input
|
| 957 |
-
|
| 958 |
-
value={dataPath}
|
| 959 |
-
onChange={(e) => onPathChange(e.target.value)}
|
| 960 |
-
/>
|
| 961 |
-
<p className='text-muted-foreground text-xs leading-relaxed'>
|
| 962 |
{t('settings.dataPathDescription')}
|
| 963 |
</p>
|
| 964 |
</div>
|
| 965 |
|
| 966 |
<div className='grid gap-4 md:grid-cols-2'>
|
| 967 |
<div className='space-y-1.5'>
|
| 968 |
-
<Label className='text-xs'>
|
| 969 |
-
{t('settings.httpConnectTimeout')}
|
| 970 |
-
</Label>
|
| 971 |
<Input
|
| 972 |
type='number'
|
| 973 |
min='1'
|
|
@@ -976,7 +902,7 @@ function StoragePane({
|
|
| 976 |
value={httpConnectTimeout}
|
| 977 |
onChange={(e) => onHttpConnectTimeoutChange(e.target.value)}
|
| 978 |
/>
|
| 979 |
-
<p className='text-
|
| 980 |
{t('settings.httpConnectTimeoutDescription')}
|
| 981 |
</p>
|
| 982 |
</div>
|
|
@@ -991,7 +917,7 @@ function StoragePane({
|
|
| 991 |
value={httpReadTimeout}
|
| 992 |
onChange={(e) => onHttpReadTimeoutChange(e.target.value)}
|
| 993 |
/>
|
| 994 |
-
<p className='text-
|
| 995 |
{t('settings.httpReadTimeoutDescription')}
|
| 996 |
</p>
|
| 997 |
</div>
|
|
@@ -1007,20 +933,18 @@ function StoragePane({
|
|
| 1007 |
value={httpMaxRetries}
|
| 1008 |
onChange={(e) => onHttpMaxRetriesChange(e.target.value)}
|
| 1009 |
/>
|
| 1010 |
-
<p className='text-
|
| 1011 |
{t('settings.httpMaxRetriesDescription')}
|
| 1012 |
</p>
|
| 1013 |
</div>
|
| 1014 |
|
| 1015 |
-
{error && <p className='text-
|
| 1016 |
<div className='flex justify-end pt-1'>
|
| 1017 |
<Button
|
| 1018 |
onClick={() => setConfirmOpen(true)}
|
| 1019 |
disabled={!dataPath.trim() || saving || unchanged}
|
| 1020 |
>
|
| 1021 |
-
{saving
|
| 1022 |
-
? t('settings.restartApplying')
|
| 1023 |
-
: t('settings.restartApply')}
|
| 1024 |
</Button>
|
| 1025 |
</div>
|
| 1026 |
</Section>
|
|
@@ -1067,30 +991,19 @@ function AboutPane({
|
|
| 1067 |
|
| 1068 |
return (
|
| 1069 |
<div className='flex h-full flex-col items-center justify-center gap-5 py-8'>
|
| 1070 |
-
<img
|
| 1071 |
-
src='/icon-large.png'
|
| 1072 |
-
alt='Koharu'
|
| 1073 |
-
className='size-20'
|
| 1074 |
-
draggable={false}
|
| 1075 |
-
/>
|
| 1076 |
<div className='text-center'>
|
| 1077 |
-
<h2 className='text-
|
| 1078 |
-
|
| 1079 |
-
</h2>
|
| 1080 |
-
<p className='text-muted-foreground mt-1 text-sm'>
|
| 1081 |
-
{t('settings.aboutTagline')}
|
| 1082 |
-
</p>
|
| 1083 |
</div>
|
| 1084 |
|
| 1085 |
-
<div className='
|
| 1086 |
<div className='space-y-3 text-sm'>
|
| 1087 |
<InfoRow label={t('settings.aboutVersion')}>
|
| 1088 |
<div className='flex flex-col items-end gap-0.5'>
|
| 1089 |
-
<span className='font-mono text-xs font-medium'>
|
| 1090 |
-
{version || '...'}
|
| 1091 |
-
</span>
|
| 1092 |
{status === 'loading' && (
|
| 1093 |
-
<LoaderIcon className='
|
| 1094 |
)}
|
| 1095 |
{status === 'latest' && (
|
| 1096 |
<span className='flex items-center gap-1 text-xs text-green-500'>
|
|
@@ -1120,9 +1033,7 @@ function AboutPane({
|
|
| 1120 |
<Button
|
| 1121 |
variant='link'
|
| 1122 |
size='xs'
|
| 1123 |
-
onClick={() =>
|
| 1124 |
-
void openExternalUrl('https://github.com/mayocream')
|
| 1125 |
-
}
|
| 1126 |
>
|
| 1127 |
Mayo
|
| 1128 |
</Button>
|
|
@@ -1131,9 +1042,7 @@ function AboutPane({
|
|
| 1131 |
<Button
|
| 1132 |
variant='link'
|
| 1133 |
size='xs'
|
| 1134 |
-
onClick={() =>
|
| 1135 |
-
void openExternalUrl(`https://github.com/${GITHUB_REPO}`)
|
| 1136 |
-
}
|
| 1137 |
>
|
| 1138 |
GitHub
|
| 1139 |
</Button>
|
|
@@ -1158,11 +1067,9 @@ function Section({
|
|
| 1158 |
return (
|
| 1159 |
<div className='space-y-3'>
|
| 1160 |
<div>
|
| 1161 |
-
<h3 className='text-
|
| 1162 |
{description && (
|
| 1163 |
-
<p className='
|
| 1164 |
-
{description}
|
| 1165 |
-
</p>
|
| 1166 |
)}
|
| 1167 |
</div>
|
| 1168 |
{children}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
|
|
| 4 |
import {
|
| 5 |
SunIcon,
|
| 6 |
MoonIcon,
|
|
|
|
| 18 |
RotateCcwIcon,
|
| 19 |
AlertTriangleIcon,
|
| 20 |
} from 'lucide-react'
|
| 21 |
+
import { useTheme } from 'next-themes'
|
| 22 |
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
| 23 |
+
import { useTranslation } from 'react-i18next'
|
| 24 |
+
|
|
|
|
|
|
|
| 25 |
import {
|
| 26 |
Accordion,
|
| 27 |
AccordionItem,
|
| 28 |
AccordionTrigger,
|
| 29 |
AccordionContent,
|
| 30 |
} from '@/components/ui/accordion'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
import {
|
| 32 |
AlertDialog,
|
| 33 |
AlertDialogAction,
|
|
|
|
| 36 |
AlertDialogDescription,
|
| 37 |
AlertDialogTitle,
|
| 38 |
} from '@/components/ui/alert-dialog'
|
| 39 |
+
import { Button } from '@/components/ui/button'
|
| 40 |
+
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
| 41 |
+
import { Input } from '@/components/ui/input'
|
| 42 |
+
import { Label } from '@/components/ui/label'
|
| 43 |
+
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 44 |
import {
|
| 45 |
+
Select,
|
| 46 |
+
SelectContent,
|
| 47 |
+
SelectItem,
|
| 48 |
+
SelectTrigger,
|
| 49 |
+
SelectValue,
|
| 50 |
+
} from '@/components/ui/select'
|
| 51 |
+
import { useUpdater, type UpdaterStatus } from '@/components/Updater'
|
| 52 |
import { getLlmCatalog, getGetLlmCatalogQueryKey } from '@/lib/api/llm/llm'
|
| 53 |
+
import type {
|
| 54 |
+
UpdateConfigBody,
|
| 55 |
+
ProviderConfig,
|
| 56 |
+
LlmProviderCatalog,
|
| 57 |
+
GetEngineCatalog200,
|
| 58 |
+
} from '@/lib/api/schemas'
|
| 59 |
+
import { getConfig, getEngineCatalog, getMeta, updateConfig } from '@/lib/api/system/system'
|
| 60 |
+
import { isTauri, openExternalUrl } from '@/lib/backend'
|
| 61 |
+
import { supportedLanguages } from '@/lib/i18n'
|
| 62 |
import {
|
| 63 |
getPlatform,
|
| 64 |
formatShortcut,
|
|
|
|
| 66 |
areShortcutsEqual,
|
| 67 |
isModifierKey,
|
| 68 |
} from '@/lib/shortcutUtils'
|
|
|
|
| 69 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
const GITHUB_REPO = 'mayocream/koharu'
|
| 72 |
|
|
|
|
| 104 |
}, [defaultTab, open])
|
| 105 |
|
| 106 |
const [appConfig, setAppConfig] = useState<UpdateConfigBody | null>(null)
|
| 107 |
+
const [providerCatalogs, setProviderCatalogs] = useState<LlmProviderCatalog[]>([])
|
|
|
|
|
|
|
| 108 |
const [apiKeyDrafts, setApiKeyDrafts] = useState<Record<string, string>>({})
|
| 109 |
const [dataPathDraft, setDataPathDraft] = useState('')
|
| 110 |
const [httpConnectTimeoutDraft, setHttpConnectTimeoutDraft] = useState('')
|
| 111 |
const [httpReadTimeoutDraft, setHttpReadTimeoutDraft] = useState('')
|
| 112 |
const [httpMaxRetriesDraft, setHttpMaxRetriesDraft] = useState('')
|
| 113 |
+
const [storageSettingsError, setStorageSettingsError] = useState<string | null>(null)
|
|
|
|
|
|
|
| 114 |
const [isSavingStorageSettings, setIsSavingStorageSettings] = useState(false)
|
| 115 |
+
const [engineCatalog, setEngineCatalog] = useState<GetEngineCatalog200 | null>(null)
|
|
|
|
| 116 |
const [appVersion, setAppVersion] = useState<string>()
|
| 117 |
const updater = useUpdater()
|
| 118 |
|
|
|
|
| 128 |
setAppConfig(config)
|
| 129 |
setProviderCatalogs(catalog.providers)
|
| 130 |
setEngineCatalog(engines)
|
| 131 |
+
} catch {}
|
| 132 |
})()
|
| 133 |
}, [open])
|
| 134 |
|
|
|
|
| 160 |
setHttpConnectTimeoutDraft(
|
| 161 |
String(appConfig.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT),
|
| 162 |
)
|
| 163 |
+
setHttpReadTimeoutDraft(String(appConfig.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT))
|
| 164 |
+
setHttpMaxRetriesDraft(String(appConfig.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
setStorageSettingsError(null)
|
| 166 |
}, [appConfig])
|
| 167 |
|
|
|
|
| 178 |
}
|
| 179 |
}
|
| 180 |
|
| 181 |
+
const upsertProvider = (id: string, updater: (p: ProviderConfig) => ProviderConfig) => {
|
|
|
|
|
|
|
|
|
|
| 182 |
if (!appConfig) return
|
| 183 |
const providers = [...(appConfig.providers ?? [])]
|
| 184 |
const idx = providers.findIndex((p) => p.id === id)
|
|
|
|
| 242 |
const storageSettingsUnchanged =
|
| 243 |
dataPathDraft.trim() === appConfig?.data?.path &&
|
| 244 |
httpConnectTimeoutDraft.trim() ===
|
| 245 |
+
String(appConfig?.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT) &&
|
|
|
|
|
|
|
| 246 |
httpReadTimeoutDraft.trim() ===
|
| 247 |
+
String(appConfig?.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT) &&
|
| 248 |
+
httpMaxRetriesDraft.trim() === String(appConfig?.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES)
|
|
|
|
| 249 |
|
| 250 |
return (
|
| 251 |
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
| 255 |
|
| 256 |
<div className='flex h-full'>
|
| 257 |
{/* Sidebar */}
|
| 258 |
+
<nav className='flex w-[180px] shrink-0 flex-col gap-1 border-r border-border bg-muted/30 p-3'>
|
| 259 |
+
<p className='mb-3 px-3 text-[10px] font-semibold tracking-widest text-muted-foreground uppercase'>
|
| 260 |
{t('settings.title')}
|
| 261 |
</p>
|
| 262 |
{TABS.map(({ id, icon: Icon, labelKey }) => (
|
|
|
|
| 264 |
key={id}
|
| 265 |
onClick={() => setTab(id)}
|
| 266 |
data-active={tab === id}
|
| 267 |
+
className='flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground'
|
| 268 |
>
|
| 269 |
<Icon className='size-4 shrink-0' />
|
| 270 |
{t(labelKey)}
|
|
|
|
| 298 |
base_url: v || null,
|
| 299 |
}))
|
| 300 |
}
|
| 301 |
+
onBaseUrlBlur={() => appConfig && void persistConfig(appConfig)}
|
| 302 |
+
onApiKeyChange={(id, v) => setApiKeyDrafts((c) => ({ ...c, [id]: v }))}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
onSaveKey={(id) => {
|
| 304 |
const key = apiKeyDrafts[id]?.trim()
|
| 305 |
if (!key || !appConfig) return
|
|
|
|
| 321 |
if (!appConfig) return
|
| 322 |
const providers = [...(appConfig.providers ?? [])]
|
| 323 |
const idx = providers.findIndex((p) => p.id === id)
|
| 324 |
+
if (idx >= 0) providers[idx] = { ...providers[idx], api_key: null }
|
|
|
|
| 325 |
void persistConfig({ ...appConfig, providers }).then(() =>
|
| 326 |
setApiKeyDrafts((c) => {
|
| 327 |
const n = { ...c }
|
|
|
|
| 399 |
key={value}
|
| 400 |
onClick={() => setTheme(value)}
|
| 401 |
data-active={theme === value}
|
| 402 |
+
className='flex flex-col items-center gap-2 rounded-xl border border-border bg-card px-4 py-4 text-muted-foreground transition hover:border-foreground/30 data-[active=true]:border-primary data-[active=true]:text-foreground'
|
| 403 |
>
|
| 404 |
<Icon className='size-5' />
|
| 405 |
<span className='text-xs font-medium'>{t(labelKey)}</span>
|
|
|
|
| 409 |
</Section>
|
| 410 |
|
| 411 |
<Section title={t('settings.language')}>
|
| 412 |
+
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
|
|
|
|
|
|
|
|
|
|
| 413 |
<SelectTrigger className='w-full'>
|
| 414 |
<SelectValue />
|
| 415 |
</SelectTrigger>
|
|
|
|
| 480 |
|
| 481 |
return (
|
| 482 |
<div className='space-y-4'>
|
| 483 |
+
<p className='text-xs text-muted-foreground'>{t('settings.enginesDescription')}</p>
|
|
|
|
|
|
|
| 484 |
{sections.map(({ label, key, engines }) => (
|
| 485 |
<div key={key} className='space-y-1.5'>
|
| 486 |
<Label className='text-xs'>{label}</Label>
|
|
|
|
| 530 |
|
| 531 |
if (!catalogs.length)
|
| 532 |
return (
|
| 533 |
+
<p className='py-12 text-center text-sm text-muted-foreground'>
|
| 534 |
{t('settings.loadingProviders')}
|
| 535 |
</p>
|
| 536 |
)
|
| 537 |
|
| 538 |
return (
|
| 539 |
<div className='space-y-6'>
|
| 540 |
+
<Section title={t('settings.apiKeys')} description={t('settings.providersDescription')}>
|
|
|
|
|
|
|
|
|
|
| 541 |
<Accordion type='multiple' className='-mx-1'>
|
| 542 |
{catalogs.map((provider) => {
|
| 543 |
const cfg = config?.providers?.find((p) => p.id === provider.id)
|
|
|
|
| 553 |
: 'bg-muted-foreground'
|
| 554 |
|
| 555 |
return (
|
| 556 |
+
<AccordionItem key={provider.id} value={provider.id} className='border-border'>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
<AccordionTrigger className='px-1 py-3 hover:no-underline'>
|
| 558 |
<div className='flex items-center gap-2.5'>
|
| 559 |
+
<span className={`size-2 shrink-0 rounded-full ${statusColor}`} />
|
|
|
|
|
|
|
| 560 |
<span className='text-sm font-medium'>{provider.name}</span>
|
| 561 |
</div>
|
| 562 |
</AccordionTrigger>
|
| 563 |
<AccordionContent className='space-y-4 px-1 pt-1 pb-4'>
|
| 564 |
{provider.error && (
|
| 565 |
+
<p className='text-xs text-muted-foreground'>{provider.error}</p>
|
|
|
|
|
|
|
| 566 |
)}
|
| 567 |
|
| 568 |
{provider.requiresBaseUrl && (
|
| 569 |
<div className='space-y-1.5'>
|
| 570 |
+
<Label className='text-xs'>{t('settings.localLlmBaseUrl')}</Label>
|
|
|
|
|
|
|
| 571 |
<Input
|
| 572 |
type='url'
|
| 573 |
value={cfg?.base_url ?? ''}
|
| 574 |
+
onChange={(e) => onBaseUrlChange(provider.id, e.target.value)}
|
|
|
|
|
|
|
| 575 |
onBlur={onBaseUrlBlur}
|
| 576 |
placeholder='https://api.example.com/v1'
|
| 577 |
/>
|
|
|
|
| 584 |
<Input
|
| 585 |
type='password'
|
| 586 |
value={draft}
|
| 587 |
+
onChange={(e) => onApiKeyChange(provider.id, e.target.value)}
|
|
|
|
|
|
|
| 588 |
onKeyDown={(e) => {
|
| 589 |
+
if (e.key === 'Enter' && hasDraft) onSaveKey(provider.id)
|
|
|
|
| 590 |
}}
|
| 591 |
placeholder={
|
| 592 |
cfg?.api_key === '[REDACTED]'
|
|
|
|
| 596 |
className='[&::-ms-reveal]:hidden'
|
| 597 |
/>
|
| 598 |
{hasDraft ? (
|
| 599 |
+
<Button size='sm' onClick={() => onSaveKey(provider.id)}>
|
|
|
|
|
|
|
|
|
|
| 600 |
{t('settings.apiKeySave')}
|
| 601 |
</Button>
|
| 602 |
) : cfg?.api_key === '[REDACTED]' ? (
|
|
|
|
| 642 |
const { t } = useTranslation()
|
| 643 |
const shortcuts = usePreferencesStore((state) => state.shortcuts)
|
| 644 |
const setShortcuts = usePreferencesStore((state) => state.setShortcuts)
|
| 645 |
+
const resetShortcutsStore = usePreferencesStore((state) => state.resetShortcuts)
|
|
|
|
|
|
|
| 646 |
const [pendingShortcuts, setPendingShortcuts] = useState(shortcuts)
|
| 647 |
const [recordingKey, setRecordingKey] = useState<string | null>(null)
|
| 648 |
const [error, setError] = useState<string | null>(null)
|
|
|
|
| 764 |
return (
|
| 765 |
<div className='flex h-full flex-col gap-6'>
|
| 766 |
<div className='grow space-y-6 overflow-y-auto pr-2'>
|
| 767 |
+
<Section title={t('settings.keybinds')} description={t('settings.keybindsDescription')}>
|
| 768 |
+
<div className='divide-y divide-border overflow-hidden rounded-xl border border-border bg-card'>
|
|
|
|
|
|
|
|
|
|
| 769 |
{SHORTCUT_ITEMS.map((item) => {
|
| 770 |
const currentVal = pendingShortcuts[item.key]
|
| 771 |
const hasConflict = (conflictCounts.get(currentVal) || 0) > 1
|
| 772 |
|
| 773 |
return (
|
| 774 |
+
<div key={item.key} className='flex items-center justify-between px-4 py-3'>
|
|
|
|
|
|
|
|
|
|
| 775 |
<div className='flex items-center gap-2'>
|
| 776 |
<span className='text-sm'>{t(item.labelKey)}</span>
|
| 777 |
{hasConflict && (
|
|
|
|
| 800 |
</Section>
|
| 801 |
</div>
|
| 802 |
|
| 803 |
+
<div className='flex items-center justify-between border-t border-border pt-4'>
|
| 804 |
<Button
|
| 805 |
variant='ghost'
|
| 806 |
size='sm'
|
| 807 |
+
className='gap-2 text-muted-foreground hover:text-foreground'
|
| 808 |
onClick={handleReset}
|
| 809 |
>
|
| 810 |
<RotateCcwIcon className='size-4' />
|
|
|
|
| 835 |
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
|
| 836 |
<AlertDialogContent>
|
| 837 |
<AlertDialogTitle>{t('settings.shortcutReset')}</AlertDialogTitle>
|
| 838 |
+
<AlertDialogDescription>{t('settings.shortcutResetDescription')}</AlertDialogDescription>
|
|
|
|
|
|
|
| 839 |
<div className='flex justify-end gap-2'>
|
| 840 |
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
| 841 |
<AlertDialogAction onClick={handleConfirmReset}>
|
|
|
|
| 882 |
|
| 883 |
return (
|
| 884 |
<>
|
| 885 |
+
<Section title={t('settings.runtime')} description={t('settings.runtimeDescription')}>
|
|
|
|
|
|
|
|
|
|
| 886 |
<div className='space-y-1.5'>
|
| 887 |
<Label className='text-xs'>{t('settings.dataPath')}</Label>
|
| 888 |
+
<Input type='text' value={dataPath} onChange={(e) => onPathChange(e.target.value)} />
|
| 889 |
+
<p className='text-xs leading-relaxed text-muted-foreground'>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
{t('settings.dataPathDescription')}
|
| 891 |
</p>
|
| 892 |
</div>
|
| 893 |
|
| 894 |
<div className='grid gap-4 md:grid-cols-2'>
|
| 895 |
<div className='space-y-1.5'>
|
| 896 |
+
<Label className='text-xs'>{t('settings.httpConnectTimeout')}</Label>
|
|
|
|
|
|
|
| 897 |
<Input
|
| 898 |
type='number'
|
| 899 |
min='1'
|
|
|
|
| 902 |
value={httpConnectTimeout}
|
| 903 |
onChange={(e) => onHttpConnectTimeoutChange(e.target.value)}
|
| 904 |
/>
|
| 905 |
+
<p className='text-xs leading-relaxed text-muted-foreground'>
|
| 906 |
{t('settings.httpConnectTimeoutDescription')}
|
| 907 |
</p>
|
| 908 |
</div>
|
|
|
|
| 917 |
value={httpReadTimeout}
|
| 918 |
onChange={(e) => onHttpReadTimeoutChange(e.target.value)}
|
| 919 |
/>
|
| 920 |
+
<p className='text-xs leading-relaxed text-muted-foreground'>
|
| 921 |
{t('settings.httpReadTimeoutDescription')}
|
| 922 |
</p>
|
| 923 |
</div>
|
|
|
|
| 933 |
value={httpMaxRetries}
|
| 934 |
onChange={(e) => onHttpMaxRetriesChange(e.target.value)}
|
| 935 |
/>
|
| 936 |
+
<p className='text-xs leading-relaxed text-muted-foreground'>
|
| 937 |
{t('settings.httpMaxRetriesDescription')}
|
| 938 |
</p>
|
| 939 |
</div>
|
| 940 |
|
| 941 |
+
{error && <p className='text-xs text-destructive'>{error}</p>}
|
| 942 |
<div className='flex justify-end pt-1'>
|
| 943 |
<Button
|
| 944 |
onClick={() => setConfirmOpen(true)}
|
| 945 |
disabled={!dataPath.trim() || saving || unchanged}
|
| 946 |
>
|
| 947 |
+
{saving ? t('settings.restartApplying') : t('settings.restartApply')}
|
|
|
|
|
|
|
| 948 |
</Button>
|
| 949 |
</div>
|
| 950 |
</Section>
|
|
|
|
| 991 |
|
| 992 |
return (
|
| 993 |
<div className='flex h-full flex-col items-center justify-center gap-5 py-8'>
|
| 994 |
+
<img src='/icon-large.png' alt='Koharu' className='size-20' draggable={false} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
<div className='text-center'>
|
| 996 |
+
<h2 className='text-lg font-bold tracking-wide text-foreground'>Koharu</h2>
|
| 997 |
+
<p className='mt-1 text-sm text-muted-foreground'>{t('settings.aboutTagline')}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 998 |
</div>
|
| 999 |
|
| 1000 |
+
<div className='w-full max-w-sm rounded-xl border border-border bg-card p-4'>
|
| 1001 |
<div className='space-y-3 text-sm'>
|
| 1002 |
<InfoRow label={t('settings.aboutVersion')}>
|
| 1003 |
<div className='flex flex-col items-end gap-0.5'>
|
| 1004 |
+
<span className='font-mono text-xs font-medium'>{version || '...'}</span>
|
|
|
|
|
|
|
| 1005 |
{status === 'loading' && (
|
| 1006 |
+
<LoaderIcon className='size-3.5 animate-spin text-muted-foreground' />
|
| 1007 |
)}
|
| 1008 |
{status === 'latest' && (
|
| 1009 |
<span className='flex items-center gap-1 text-xs text-green-500'>
|
|
|
|
| 1033 |
<Button
|
| 1034 |
variant='link'
|
| 1035 |
size='xs'
|
| 1036 |
+
onClick={() => void openExternalUrl('https://github.com/mayocream')}
|
|
|
|
|
|
|
| 1037 |
>
|
| 1038 |
Mayo
|
| 1039 |
</Button>
|
|
|
|
| 1042 |
<Button
|
| 1043 |
variant='link'
|
| 1044 |
size='xs'
|
| 1045 |
+
onClick={() => void openExternalUrl(`https://github.com/${GITHUB_REPO}`)}
|
|
|
|
|
|
|
| 1046 |
>
|
| 1047 |
GitHub
|
| 1048 |
</Button>
|
|
|
|
| 1067 |
return (
|
| 1068 |
<div className='space-y-3'>
|
| 1069 |
<div>
|
| 1070 |
+
<h3 className='text-sm font-semibold text-foreground'>{title}</h3>
|
| 1071 |
{description && (
|
| 1072 |
+
<p className='mt-0.5 text-xs leading-relaxed text-muted-foreground'>{description}</p>
|
|
|
|
|
|
|
| 1073 |
)}
|
| 1074 |
</div>
|
| 1075 |
{children}
|
ui/components/Updater.tsx
CHANGED
|
@@ -1,24 +1,14 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
createContext,
|
| 5 |
-
useContext,
|
| 6 |
-
useEffect,
|
| 7 |
-
useState,
|
| 8 |
-
type ReactNode,
|
| 9 |
-
} from 'react'
|
| 10 |
import type { Update } from '@tauri-apps/plugin-updater'
|
|
|
|
|
|
|
|
|
|
| 11 |
import ReactMarkdown from 'react-markdown'
|
| 12 |
import remarkGfm from 'remark-gfm'
|
| 13 |
-
|
| 14 |
-
import { Download, RefreshCw, AlertCircle } from 'lucide-react'
|
| 15 |
-
import {
|
| 16 |
-
Dialog,
|
| 17 |
-
DialogContent,
|
| 18 |
-
DialogDescription,
|
| 19 |
-
DialogTitle,
|
| 20 |
-
} from '@/components/ui/dialog'
|
| 21 |
import { Button } from '@/components/ui/button'
|
|
|
|
| 22 |
import { Progress } from '@/components/ui/progress'
|
| 23 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 24 |
import { Separator } from '@/components/ui/separator'
|
|
@@ -197,19 +187,17 @@ function PromptView({
|
|
| 197 |
return (
|
| 198 |
<>
|
| 199 |
<header className='flex items-center gap-3 px-6 pt-6 pb-4'>
|
| 200 |
-
<div className='
|
| 201 |
<Download className='size-5' />
|
| 202 |
</div>
|
| 203 |
<div className='flex flex-col gap-0.5'>
|
| 204 |
-
<DialogTitle className='text-base'>
|
| 205 |
-
{t('updater.available.title')}
|
| 206 |
-
</DialogTitle>
|
| 207 |
<DialogDescription>
|
| 208 |
<Trans
|
| 209 |
i18nKey='updater.available.description'
|
| 210 |
values={{ version: update.version }}
|
| 211 |
components={{
|
| 212 |
-
strong: <span className='
|
| 213 |
}}
|
| 214 |
/>
|
| 215 |
</DialogDescription>
|
|
@@ -218,16 +206,12 @@ function PromptView({
|
|
| 218 |
<Separator />
|
| 219 |
{update.body ? (
|
| 220 |
<ScrollArea className='h-64'>
|
| 221 |
-
<div className='prose prose-sm dark:prose-invert
|
| 222 |
-
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 223 |
-
{update.body}
|
| 224 |
-
</ReactMarkdown>
|
| 225 |
</div>
|
| 226 |
</ScrollArea>
|
| 227 |
) : (
|
| 228 |
-
<div className='
|
| 229 |
-
{t('updater.noNotes')}
|
| 230 |
-
</div>
|
| 231 |
)}
|
| 232 |
<Separator />
|
| 233 |
<footer className='flex justify-end gap-2 px-6 py-4'>
|
|
@@ -258,21 +242,17 @@ function DownloadingView({
|
|
| 258 |
return (
|
| 259 |
<div className='flex flex-col gap-4 p-6'>
|
| 260 |
<div className='flex items-center gap-3'>
|
| 261 |
-
<div className='
|
| 262 |
<Download className='size-5 animate-pulse' />
|
| 263 |
</div>
|
| 264 |
<div className='flex flex-col gap-0.5'>
|
| 265 |
-
<DialogTitle className='text-base'>
|
| 266 |
-
|
| 267 |
-
</DialogTitle>
|
| 268 |
-
<DialogDescription>
|
| 269 |
-
{t('updater.downloading.subtitle', { version })}
|
| 270 |
-
</DialogDescription>
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
<div className='space-y-2'>
|
| 274 |
<Progress value={percent ?? undefined} />
|
| 275 |
-
<div className='
|
| 276 |
<span>
|
| 277 |
{formatBytes(downloaded)}
|
| 278 |
{total ? ` / ${formatBytes(total)}` : ''}
|
|
@@ -297,13 +277,11 @@ function ErrorView({
|
|
| 297 |
return (
|
| 298 |
<>
|
| 299 |
<header className='flex items-center gap-3 px-6 pt-6 pb-4'>
|
| 300 |
-
<div className='
|
| 301 |
<AlertCircle className='size-5' />
|
| 302 |
</div>
|
| 303 |
<div className='flex flex-col gap-0.5'>
|
| 304 |
-
<DialogTitle className='text-base'>
|
| 305 |
-
{t('updater.error.title')}
|
| 306 |
-
</DialogTitle>
|
| 307 |
<DialogDescription className='break-words'>
|
| 308 |
{t('updater.error.description')}
|
| 309 |
</DialogDescription>
|
|
@@ -311,7 +289,7 @@ function ErrorView({
|
|
| 311 |
</header>
|
| 312 |
<Separator />
|
| 313 |
<ScrollArea className='max-h-40'>
|
| 314 |
-
<pre className='
|
| 315 |
{message}
|
| 316 |
</pre>
|
| 317 |
</ScrollArea>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import type { Update } from '@tauri-apps/plugin-updater'
|
| 4 |
+
import { Download, RefreshCw, AlertCircle } from 'lucide-react'
|
| 5 |
+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
| 6 |
+
import { Trans, useTranslation } from 'react-i18next'
|
| 7 |
import ReactMarkdown from 'react-markdown'
|
| 8 |
import remarkGfm from 'remark-gfm'
|
| 9 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
import { Button } from '@/components/ui/button'
|
| 11 |
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
| 12 |
import { Progress } from '@/components/ui/progress'
|
| 13 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 14 |
import { Separator } from '@/components/ui/separator'
|
|
|
|
| 187 |
return (
|
| 188 |
<>
|
| 189 |
<header className='flex items-center gap-3 px-6 pt-6 pb-4'>
|
| 190 |
+
<div className='flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary'>
|
| 191 |
<Download className='size-5' />
|
| 192 |
</div>
|
| 193 |
<div className='flex flex-col gap-0.5'>
|
| 194 |
+
<DialogTitle className='text-base'>{t('updater.available.title')}</DialogTitle>
|
|
|
|
|
|
|
| 195 |
<DialogDescription>
|
| 196 |
<Trans
|
| 197 |
i18nKey='updater.available.description'
|
| 198 |
values={{ version: update.version }}
|
| 199 |
components={{
|
| 200 |
+
strong: <span className='font-medium text-foreground' />,
|
| 201 |
}}
|
| 202 |
/>
|
| 203 |
</DialogDescription>
|
|
|
|
| 206 |
<Separator />
|
| 207 |
{update.body ? (
|
| 208 |
<ScrollArea className='h-64'>
|
| 209 |
+
<div className='prose prose-sm dark:prose-invert max-w-none px-6 py-4 [&_a]:text-primary [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:tracking-wide [&_h3]:text-muted-foreground [&_h3]:uppercase [&_li]:my-0.5 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5'>
|
| 210 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{update.body}</ReactMarkdown>
|
|
|
|
|
|
|
| 211 |
</div>
|
| 212 |
</ScrollArea>
|
| 213 |
) : (
|
| 214 |
+
<div className='px-6 py-6 text-sm text-muted-foreground'>{t('updater.noNotes')}</div>
|
|
|
|
|
|
|
| 215 |
)}
|
| 216 |
<Separator />
|
| 217 |
<footer className='flex justify-end gap-2 px-6 py-4'>
|
|
|
|
| 242 |
return (
|
| 243 |
<div className='flex flex-col gap-4 p-6'>
|
| 244 |
<div className='flex items-center gap-3'>
|
| 245 |
+
<div className='flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary'>
|
| 246 |
<Download className='size-5 animate-pulse' />
|
| 247 |
</div>
|
| 248 |
<div className='flex flex-col gap-0.5'>
|
| 249 |
+
<DialogTitle className='text-base'>{t('updater.downloading.title')}</DialogTitle>
|
| 250 |
+
<DialogDescription>{t('updater.downloading.subtitle', { version })}</DialogDescription>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
</div>
|
| 252 |
</div>
|
| 253 |
<div className='space-y-2'>
|
| 254 |
<Progress value={percent ?? undefined} />
|
| 255 |
+
<div className='flex justify-between text-xs text-muted-foreground tabular-nums'>
|
| 256 |
<span>
|
| 257 |
{formatBytes(downloaded)}
|
| 258 |
{total ? ` / ${formatBytes(total)}` : ''}
|
|
|
|
| 277 |
return (
|
| 278 |
<>
|
| 279 |
<header className='flex items-center gap-3 px-6 pt-6 pb-4'>
|
| 280 |
+
<div className='flex size-10 items-center justify-center rounded-full bg-destructive/10 text-destructive'>
|
| 281 |
<AlertCircle className='size-5' />
|
| 282 |
</div>
|
| 283 |
<div className='flex flex-col gap-0.5'>
|
| 284 |
+
<DialogTitle className='text-base'>{t('updater.error.title')}</DialogTitle>
|
|
|
|
|
|
|
| 285 |
<DialogDescription className='break-words'>
|
| 286 |
{t('updater.error.description')}
|
| 287 |
</DialogDescription>
|
|
|
|
| 289 |
</header>
|
| 290 |
<Separator />
|
| 291 |
<ScrollArea className='max-h-40'>
|
| 292 |
+
<pre className='px-6 py-4 text-xs break-words whitespace-pre-wrap text-muted-foreground'>
|
| 293 |
{message}
|
| 294 |
</pre>
|
| 295 |
</ScrollArea>
|
ui/components/canvas/CanvasToolbar.tsx
CHANGED
|
@@ -1,9 +1,5 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useEffect, useMemo, useState } from 'react'
|
| 4 |
-
import { useTranslation } from 'react-i18next'
|
| 5 |
-
|
| 6 |
-
import { motion } from 'motion/react'
|
| 7 |
import {
|
| 8 |
ScanIcon,
|
| 9 |
ScanTextIcon,
|
|
@@ -12,10 +8,13 @@ import {
|
|
| 12 |
LoaderCircleIcon,
|
| 13 |
LanguagesIcon,
|
| 14 |
} from 'lucide-react'
|
| 15 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 16 |
import { Button } from '@/components/ui/button'
|
| 17 |
import { Input } from '@/components/ui/input'
|
| 18 |
-
import {
|
| 19 |
import {
|
| 20 |
Select,
|
| 21 |
SelectContent,
|
|
@@ -23,21 +22,14 @@ import {
|
|
| 23 |
SelectTrigger,
|
| 24 |
SelectValue,
|
| 25 |
} from '@/components/ui/select'
|
| 26 |
-
import {
|
| 27 |
-
|
| 28 |
-
PopoverContent,
|
| 29 |
-
PopoverTrigger,
|
| 30 |
-
} from '@/components/ui/popover'
|
| 31 |
-
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 32 |
-
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 33 |
import { useGetLlm, useGetLlmCatalog } from '@/lib/api/llm/llm'
|
| 34 |
-
import type {
|
| 35 |
-
LlmCatalog,
|
| 36 |
-
LlmCatalogModel,
|
| 37 |
-
LlmProviderCatalog,
|
| 38 |
-
} from '@/lib/api/schemas'
|
| 39 |
-
import { useProcessing } from '@/lib/machines'
|
| 40 |
import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets'
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
type SelectableLlmModel = {
|
| 43 |
model: LlmCatalogModel
|
|
@@ -48,15 +40,10 @@ const flattenCatalogModels = (catalog?: LlmCatalog): SelectableLlmModel[] => [
|
|
| 48 |
...(catalog?.localModels ?? []).map((model) => ({ model })),
|
| 49 |
...(catalog?.providers ?? [])
|
| 50 |
.filter((provider) => provider.status === 'ready')
|
| 51 |
-
.flatMap((provider) =>
|
| 52 |
-
provider.models.map((model) => ({ model, provider })),
|
| 53 |
-
),
|
| 54 |
]
|
| 55 |
|
| 56 |
-
const filterCatalogModels = (
|
| 57 |
-
models: SelectableLlmModel[],
|
| 58 |
-
query: string,
|
| 59 |
-
): SelectableLlmModel[] => {
|
| 60 |
const normalized = query.trim().toLowerCase()
|
| 61 |
if (!normalized) return models
|
| 62 |
|
|
@@ -68,15 +55,13 @@ const filterCatalogModels = (
|
|
| 68 |
provider?.name,
|
| 69 |
provider?.id,
|
| 70 |
]
|
| 71 |
-
return candidates.some((candidate) =>
|
| 72 |
-
candidate?.toLowerCase().includes(normalized),
|
| 73 |
-
)
|
| 74 |
})
|
| 75 |
}
|
| 76 |
|
| 77 |
export function CanvasToolbar() {
|
| 78 |
return (
|
| 79 |
-
<div className='
|
| 80 |
<WorkflowButtons />
|
| 81 |
<div className='flex-1' />
|
| 82 |
<LlmStatusPopover />
|
|
@@ -88,9 +73,7 @@ function WorkflowButtons() {
|
|
| 88 |
const { send, isProcessing, state } = useProcessing()
|
| 89 |
const { data: llmState } = useGetLlm()
|
| 90 |
const llmReady = llmState?.status === 'ready'
|
| 91 |
-
const hasDocument = useEditorUiStore(
|
| 92 |
-
(state) => state.currentDocumentId !== null,
|
| 93 |
-
)
|
| 94 |
const { t } = useTranslation()
|
| 95 |
|
| 96 |
const isDetecting = state.matches('detecting')
|
|
@@ -110,9 +93,7 @@ function WorkflowButtons() {
|
|
| 110 |
<Button
|
| 111 |
variant='ghost'
|
| 112 |
size='xs'
|
| 113 |
-
onClick={() =>
|
| 114 |
-
send({ type: 'START_DETECT', documentId: requireDocumentId() })
|
| 115 |
-
}
|
| 116 |
data-testid='toolbar-detect'
|
| 117 |
disabled={!hasDocument || isDetecting || isProcessing}
|
| 118 |
>
|
|
@@ -129,9 +110,7 @@ function WorkflowButtons() {
|
|
| 129 |
<Button
|
| 130 |
variant='ghost'
|
| 131 |
size='xs'
|
| 132 |
-
onClick={() =>
|
| 133 |
-
send({ type: 'START_RECOGNIZE', documentId: requireDocumentId() })
|
| 134 |
-
}
|
| 135 |
data-testid='toolbar-ocr'
|
| 136 |
disabled={!hasDocument || isOcr || isProcessing}
|
| 137 |
>
|
|
@@ -177,9 +156,7 @@ function WorkflowButtons() {
|
|
| 177 |
<Button
|
| 178 |
variant='ghost'
|
| 179 |
size='xs'
|
| 180 |
-
onClick={() =>
|
| 181 |
-
send({ type: 'START_INPAINT', documentId: requireDocumentId() })
|
| 182 |
-
}
|
| 183 |
data-testid='toolbar-inpaint'
|
| 184 |
disabled={!hasDocument || isInpainting || isProcessing}
|
| 185 |
>
|
|
@@ -226,20 +203,11 @@ function LlmStatusPopover() {
|
|
| 226 |
const { data: llmCatalog } = useGetLlmCatalog()
|
| 227 |
const [popoverOpen, setPopoverOpen] = useState(false)
|
| 228 |
const [modelSearchQuery, setModelSearchQuery] = useState('')
|
| 229 |
-
const llmModels = useMemo(
|
| 230 |
-
() => flattenCatalogModels(llmCatalog),
|
| 231 |
-
[llmCatalog],
|
| 232 |
-
)
|
| 233 |
const selectedTarget = useEditorUiStore((state) => state.selectedTarget)
|
| 234 |
-
const customSystemPrompt = usePreferencesStore(
|
| 235 |
-
|
| 236 |
-
)
|
| 237 |
-
const setCustomSystemPrompt = usePreferencesStore(
|
| 238 |
-
(state) => state.setCustomSystemPrompt,
|
| 239 |
-
)
|
| 240 |
-
const llmSelectedLanguage = useEditorUiStore(
|
| 241 |
-
(state) => state.selectedLanguage,
|
| 242 |
-
)
|
| 243 |
const { data: llmState } = useGetLlm()
|
| 244 |
const llmReady = llmState?.status === 'ready'
|
| 245 |
const { send, state } = useProcessing()
|
|
@@ -249,27 +217,19 @@ function LlmStatusPopover() {
|
|
| 249 |
const { t } = useTranslation()
|
| 250 |
|
| 251 |
const selectedModel = useMemo(
|
| 252 |
-
() =>
|
| 253 |
-
llmModels.find(({ model }) =>
|
| 254 |
-
sameLlmTarget(model.target, selectedTarget),
|
| 255 |
-
),
|
| 256 |
[llmModels, selectedTarget],
|
| 257 |
)
|
| 258 |
const filteredLlmModels = useMemo(
|
| 259 |
() => filterCatalogModels(llmModels, modelSearchQuery),
|
| 260 |
[llmModels, modelSearchQuery],
|
| 261 |
)
|
| 262 |
-
const selectedTargetKey = selectedTarget
|
| 263 |
-
? llmTargetKey(selectedTarget)
|
| 264 |
-
: undefined
|
| 265 |
const selectedModelLanguages = selectedModel?.model.languages ?? []
|
| 266 |
-
const selectedIsLoaded =
|
| 267 |
-
llmReady && sameLlmTarget(llmState?.target, selectedTarget)
|
| 268 |
|
| 269 |
const handleSetSelectedModel = (key: string) => {
|
| 270 |
-
const nextSelection = llmModels.find(
|
| 271 |
-
({ model }) => llmTargetKey(model.target) === key,
|
| 272 |
-
)
|
| 273 |
if (!nextSelection) return
|
| 274 |
|
| 275 |
const nextLanguages = nextSelection.model.languages
|
|
@@ -310,9 +270,7 @@ function LlmStatusPopover() {
|
|
| 310 |
useEffect(() => {
|
| 311 |
if (llmModels.length === 0) return
|
| 312 |
|
| 313 |
-
const hasCurrent = llmModels.some(({ model }) =>
|
| 314 |
-
sameLlmTarget(model.target, selectedTarget),
|
| 315 |
-
)
|
| 316 |
const nextModel = hasCurrent ? selectedModel?.model : llmModels[0]?.model
|
| 317 |
if (!nextModel) return
|
| 318 |
|
|
@@ -354,23 +312,15 @@ function LlmStatusPopover() {
|
|
| 354 |
? 'bg-rose-400 text-white ring-1 ring-rose-400/30'
|
| 355 |
: busy
|
| 356 |
? 'bg-amber-400 text-white ring-1 ring-amber-400/30'
|
| 357 |
-
: 'bg-muted text-muted-foreground ring-
|
| 358 |
}`}
|
| 359 |
>
|
| 360 |
<motion.span
|
| 361 |
className={`size-1.5 rounded-full ${
|
| 362 |
-
llmReady
|
| 363 |
-
? 'bg-white'
|
| 364 |
-
: busy
|
| 365 |
-
? 'bg-white'
|
| 366 |
-
: 'bg-muted-foreground/40'
|
| 367 |
}`}
|
| 368 |
animate={
|
| 369 |
-
llmReady
|
| 370 |
-
? { opacity: [1, 0.5, 1] }
|
| 371 |
-
: busy
|
| 372 |
-
? { opacity: [1, 0.4, 1] }
|
| 373 |
-
: { opacity: 1 }
|
| 374 |
}
|
| 375 |
transition={
|
| 376 |
llmReady || busy
|
|
@@ -385,14 +335,10 @@ function LlmStatusPopover() {
|
|
| 385 |
LLM
|
| 386 |
</button>
|
| 387 |
</PopoverTrigger>
|
| 388 |
-
<PopoverContent
|
| 389 |
-
align='end'
|
| 390 |
-
className='w-[280px] p-0'
|
| 391 |
-
data-testid='llm-popover'
|
| 392 |
-
>
|
| 393 |
{/* Model + load */}
|
| 394 |
<div className='flex flex-col gap-1 px-3 pt-3 pb-2.5'>
|
| 395 |
-
<span className='text-
|
| 396 |
{t('llm.model', { defaultValue: 'Model' })}
|
| 397 |
</span>
|
| 398 |
<Input
|
|
@@ -405,14 +351,8 @@ function LlmStatusPopover() {
|
|
| 405 |
className='h-7 text-xs'
|
| 406 |
/>
|
| 407 |
<div className='flex items-center gap-1.5'>
|
| 408 |
-
<Select
|
| 409 |
-
|
| 410 |
-
onValueChange={handleSetSelectedModel}
|
| 411 |
-
>
|
| 412 |
-
<SelectTrigger
|
| 413 |
-
data-testid='llm-model-select'
|
| 414 |
-
className='min-w-0 flex-1'
|
| 415 |
-
>
|
| 416 |
<SelectValue placeholder={t('llm.selectPlaceholder')} />
|
| 417 |
</SelectTrigger>
|
| 418 |
<SelectContent position='popper'>
|
|
@@ -425,7 +365,7 @@ function LlmStatusPopover() {
|
|
| 425 |
>
|
| 426 |
<span className='flex items-center gap-1.5'>
|
| 427 |
{provider ? (
|
| 428 |
-
<span className='bg-primary/10
|
| 429 |
{provider.name}
|
| 430 |
</span>
|
| 431 |
) : null}
|
|
@@ -436,7 +376,7 @@ function LlmStatusPopover() {
|
|
| 436 |
) : (
|
| 437 |
<div
|
| 438 |
data-testid='llm-model-empty'
|
| 439 |
-
className='
|
| 440 |
>
|
| 441 |
{t('llm.modelSearchNoResults', {
|
| 442 |
defaultValue: 'No models found',
|
|
@@ -455,12 +395,8 @@ function LlmStatusPopover() {
|
|
| 455 |
disabled={!selectedTarget || busy}
|
| 456 |
className='h-6 shrink-0 gap-1 px-2 text-[11px]'
|
| 457 |
>
|
| 458 |
-
{busy ?
|
| 459 |
-
|
| 460 |
-
) : null}
|
| 461 |
-
{selectedIsLoaded || llmUnloading
|
| 462 |
-
? t('llm.unload')
|
| 463 |
-
: t('llm.load')}
|
| 464 |
</Button>
|
| 465 |
</div>
|
| 466 |
</div>
|
|
@@ -471,7 +407,7 @@ function LlmStatusPopover() {
|
|
| 471 |
|
| 472 |
{/* Language + prompt */}
|
| 473 |
<div className='flex flex-col gap-1 px-3 pt-2.5 pb-3'>
|
| 474 |
-
<span className='text-
|
| 475 |
{t('llm.translationSettings', {
|
| 476 |
defaultValue: 'Translation',
|
| 477 |
})}
|
|
@@ -483,10 +419,7 @@ function LlmStatusPopover() {
|
|
| 483 |
value={llmSelectedLanguage ?? selectedModelLanguages[0]}
|
| 484 |
onValueChange={handleSetSelectedLanguage}
|
| 485 |
>
|
| 486 |
-
<SelectTrigger
|
| 487 |
-
data-testid='llm-language-select'
|
| 488 |
-
className='w-full'
|
| 489 |
-
>
|
| 490 |
<SelectValue placeholder={t('llm.languagePlaceholder')} />
|
| 491 |
</SelectTrigger>
|
| 492 |
<SelectContent position='popper'>
|
|
@@ -506,9 +439,7 @@ function LlmStatusPopover() {
|
|
| 506 |
<Textarea
|
| 507 |
data-testid='llm-system-prompt'
|
| 508 |
value={customSystemPrompt ?? ''}
|
| 509 |
-
onChange={(e) =>
|
| 510 |
-
setCustomSystemPrompt(e.target.value || undefined)
|
| 511 |
-
}
|
| 512 |
placeholder={t('llm.systemPromptPlaceholder', {
|
| 513 |
defaultValue: 'Custom system prompt (optional)',
|
| 514 |
})}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import {
|
| 4 |
ScanIcon,
|
| 5 |
ScanTextIcon,
|
|
|
|
| 8 |
LoaderCircleIcon,
|
| 9 |
LanguagesIcon,
|
| 10 |
} from 'lucide-react'
|
| 11 |
+
import { motion } from 'motion/react'
|
| 12 |
+
import { useEffect, useMemo, useState } from 'react'
|
| 13 |
+
import { useTranslation } from 'react-i18next'
|
| 14 |
+
|
| 15 |
import { Button } from '@/components/ui/button'
|
| 16 |
import { Input } from '@/components/ui/input'
|
| 17 |
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
| 18 |
import {
|
| 19 |
Select,
|
| 20 |
SelectContent,
|
|
|
|
| 22 |
SelectTrigger,
|
| 23 |
SelectValue,
|
| 24 |
} from '@/components/ui/select'
|
| 25 |
+
import { Separator } from '@/components/ui/separator'
|
| 26 |
+
import { Textarea } from '@/components/ui/textarea'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
import { useGetLlm, useGetLlmCatalog } from '@/lib/api/llm/llm'
|
| 28 |
+
import type { LlmCatalog, LlmCatalogModel, LlmProviderCatalog } from '@/lib/api/schemas'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets'
|
| 30 |
+
import { useProcessing } from '@/lib/machines'
|
| 31 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 32 |
+
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 33 |
|
| 34 |
type SelectableLlmModel = {
|
| 35 |
model: LlmCatalogModel
|
|
|
|
| 40 |
...(catalog?.localModels ?? []).map((model) => ({ model })),
|
| 41 |
...(catalog?.providers ?? [])
|
| 42 |
.filter((provider) => provider.status === 'ready')
|
| 43 |
+
.flatMap((provider) => provider.models.map((model) => ({ model, provider }))),
|
|
|
|
|
|
|
| 44 |
]
|
| 45 |
|
| 46 |
+
const filterCatalogModels = (models: SelectableLlmModel[], query: string): SelectableLlmModel[] => {
|
|
|
|
|
|
|
|
|
|
| 47 |
const normalized = query.trim().toLowerCase()
|
| 48 |
if (!normalized) return models
|
| 49 |
|
|
|
|
| 55 |
provider?.name,
|
| 56 |
provider?.id,
|
| 57 |
]
|
| 58 |
+
return candidates.some((candidate) => candidate?.toLowerCase().includes(normalized))
|
|
|
|
|
|
|
| 59 |
})
|
| 60 |
}
|
| 61 |
|
| 62 |
export function CanvasToolbar() {
|
| 63 |
return (
|
| 64 |
+
<div className='flex items-center gap-2 border-b border-border/60 bg-card px-3 py-2 text-xs text-foreground'>
|
| 65 |
<WorkflowButtons />
|
| 66 |
<div className='flex-1' />
|
| 67 |
<LlmStatusPopover />
|
|
|
|
| 73 |
const { send, isProcessing, state } = useProcessing()
|
| 74 |
const { data: llmState } = useGetLlm()
|
| 75 |
const llmReady = llmState?.status === 'ready'
|
| 76 |
+
const hasDocument = useEditorUiStore((state) => state.currentDocumentId !== null)
|
|
|
|
|
|
|
| 77 |
const { t } = useTranslation()
|
| 78 |
|
| 79 |
const isDetecting = state.matches('detecting')
|
|
|
|
| 93 |
<Button
|
| 94 |
variant='ghost'
|
| 95 |
size='xs'
|
| 96 |
+
onClick={() => send({ type: 'START_DETECT', documentId: requireDocumentId() })}
|
|
|
|
|
|
|
| 97 |
data-testid='toolbar-detect'
|
| 98 |
disabled={!hasDocument || isDetecting || isProcessing}
|
| 99 |
>
|
|
|
|
| 110 |
<Button
|
| 111 |
variant='ghost'
|
| 112 |
size='xs'
|
| 113 |
+
onClick={() => send({ type: 'START_RECOGNIZE', documentId: requireDocumentId() })}
|
|
|
|
|
|
|
| 114 |
data-testid='toolbar-ocr'
|
| 115 |
disabled={!hasDocument || isOcr || isProcessing}
|
| 116 |
>
|
|
|
|
| 156 |
<Button
|
| 157 |
variant='ghost'
|
| 158 |
size='xs'
|
| 159 |
+
onClick={() => send({ type: 'START_INPAINT', documentId: requireDocumentId() })}
|
|
|
|
|
|
|
| 160 |
data-testid='toolbar-inpaint'
|
| 161 |
disabled={!hasDocument || isInpainting || isProcessing}
|
| 162 |
>
|
|
|
|
| 203 |
const { data: llmCatalog } = useGetLlmCatalog()
|
| 204 |
const [popoverOpen, setPopoverOpen] = useState(false)
|
| 205 |
const [modelSearchQuery, setModelSearchQuery] = useState('')
|
| 206 |
+
const llmModels = useMemo(() => flattenCatalogModels(llmCatalog), [llmCatalog])
|
|
|
|
|
|
|
|
|
|
| 207 |
const selectedTarget = useEditorUiStore((state) => state.selectedTarget)
|
| 208 |
+
const customSystemPrompt = usePreferencesStore((state) => state.customSystemPrompt)
|
| 209 |
+
const setCustomSystemPrompt = usePreferencesStore((state) => state.setCustomSystemPrompt)
|
| 210 |
+
const llmSelectedLanguage = useEditorUiStore((state) => state.selectedLanguage)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
const { data: llmState } = useGetLlm()
|
| 212 |
const llmReady = llmState?.status === 'ready'
|
| 213 |
const { send, state } = useProcessing()
|
|
|
|
| 217 |
const { t } = useTranslation()
|
| 218 |
|
| 219 |
const selectedModel = useMemo(
|
| 220 |
+
() => llmModels.find(({ model }) => sameLlmTarget(model.target, selectedTarget)),
|
|
|
|
|
|
|
|
|
|
| 221 |
[llmModels, selectedTarget],
|
| 222 |
)
|
| 223 |
const filteredLlmModels = useMemo(
|
| 224 |
() => filterCatalogModels(llmModels, modelSearchQuery),
|
| 225 |
[llmModels, modelSearchQuery],
|
| 226 |
)
|
| 227 |
+
const selectedTargetKey = selectedTarget ? llmTargetKey(selectedTarget) : undefined
|
|
|
|
|
|
|
| 228 |
const selectedModelLanguages = selectedModel?.model.languages ?? []
|
| 229 |
+
const selectedIsLoaded = llmReady && sameLlmTarget(llmState?.target, selectedTarget)
|
|
|
|
| 230 |
|
| 231 |
const handleSetSelectedModel = (key: string) => {
|
| 232 |
+
const nextSelection = llmModels.find(({ model }) => llmTargetKey(model.target) === key)
|
|
|
|
|
|
|
| 233 |
if (!nextSelection) return
|
| 234 |
|
| 235 |
const nextLanguages = nextSelection.model.languages
|
|
|
|
| 270 |
useEffect(() => {
|
| 271 |
if (llmModels.length === 0) return
|
| 272 |
|
| 273 |
+
const hasCurrent = llmModels.some(({ model }) => sameLlmTarget(model.target, selectedTarget))
|
|
|
|
|
|
|
| 274 |
const nextModel = hasCurrent ? selectedModel?.model : llmModels[0]?.model
|
| 275 |
if (!nextModel) return
|
| 276 |
|
|
|
|
| 312 |
? 'bg-rose-400 text-white ring-1 ring-rose-400/30'
|
| 313 |
: busy
|
| 314 |
? 'bg-amber-400 text-white ring-1 ring-amber-400/30'
|
| 315 |
+
: 'bg-muted text-muted-foreground ring-1 ring-border/50'
|
| 316 |
}`}
|
| 317 |
>
|
| 318 |
<motion.span
|
| 319 |
className={`size-1.5 rounded-full ${
|
| 320 |
+
llmReady ? 'bg-white' : busy ? 'bg-white' : 'bg-muted-foreground/40'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}`}
|
| 322 |
animate={
|
| 323 |
+
llmReady ? { opacity: [1, 0.5, 1] } : busy ? { opacity: [1, 0.4, 1] } : { opacity: 1 }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
}
|
| 325 |
transition={
|
| 326 |
llmReady || busy
|
|
|
|
| 335 |
LLM
|
| 336 |
</button>
|
| 337 |
</PopoverTrigger>
|
| 338 |
+
<PopoverContent align='end' className='w-[280px] p-0' data-testid='llm-popover'>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
{/* Model + load */}
|
| 340 |
<div className='flex flex-col gap-1 px-3 pt-3 pb-2.5'>
|
| 341 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 342 |
{t('llm.model', { defaultValue: 'Model' })}
|
| 343 |
</span>
|
| 344 |
<Input
|
|
|
|
| 351 |
className='h-7 text-xs'
|
| 352 |
/>
|
| 353 |
<div className='flex items-center gap-1.5'>
|
| 354 |
+
<Select value={selectedTargetKey} onValueChange={handleSetSelectedModel}>
|
| 355 |
+
<SelectTrigger data-testid='llm-model-select' className='min-w-0 flex-1'>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
<SelectValue placeholder={t('llm.selectPlaceholder')} />
|
| 357 |
</SelectTrigger>
|
| 358 |
<SelectContent position='popper'>
|
|
|
|
| 365 |
>
|
| 366 |
<span className='flex items-center gap-1.5'>
|
| 367 |
{provider ? (
|
| 368 |
+
<span className='rounded bg-primary/10 px-1 py-0.5 text-[9px] leading-none font-semibold text-primary uppercase'>
|
| 369 |
{provider.name}
|
| 370 |
</span>
|
| 371 |
) : null}
|
|
|
|
| 376 |
) : (
|
| 377 |
<div
|
| 378 |
data-testid='llm-model-empty'
|
| 379 |
+
className='px-2 py-2 text-xs text-muted-foreground'
|
| 380 |
>
|
| 381 |
{t('llm.modelSearchNoResults', {
|
| 382 |
defaultValue: 'No models found',
|
|
|
|
| 395 |
disabled={!selectedTarget || busy}
|
| 396 |
className='h-6 shrink-0 gap-1 px-2 text-[11px]'
|
| 397 |
>
|
| 398 |
+
{busy ? <LoaderCircleIcon className='size-3 animate-spin' /> : null}
|
| 399 |
+
{selectedIsLoaded || llmUnloading ? t('llm.unload') : t('llm.load')}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
</Button>
|
| 401 |
</div>
|
| 402 |
</div>
|
|
|
|
| 407 |
|
| 408 |
{/* Language + prompt */}
|
| 409 |
<div className='flex flex-col gap-1 px-3 pt-2.5 pb-3'>
|
| 410 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 411 |
{t('llm.translationSettings', {
|
| 412 |
defaultValue: 'Translation',
|
| 413 |
})}
|
|
|
|
| 419 |
value={llmSelectedLanguage ?? selectedModelLanguages[0]}
|
| 420 |
onValueChange={handleSetSelectedLanguage}
|
| 421 |
>
|
| 422 |
+
<SelectTrigger data-testid='llm-language-select' className='w-full'>
|
|
|
|
|
|
|
|
|
|
| 423 |
<SelectValue placeholder={t('llm.languagePlaceholder')} />
|
| 424 |
</SelectTrigger>
|
| 425 |
<SelectContent position='popper'>
|
|
|
|
| 439 |
<Textarea
|
| 440 |
data-testid='llm-system-prompt'
|
| 441 |
value={customSystemPrompt ?? ''}
|
| 442 |
+
onChange={(e) => setCustomSystemPrompt(e.target.value || undefined)}
|
|
|
|
|
|
|
| 443 |
placeholder={t('llm.systemPromptPlaceholder', {
|
| 444 |
defaultValue: 'Custom system prompt (optional)',
|
| 445 |
})}
|
ui/components/canvas/StatusBar.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useTranslation } from 'react-i18next'
|
| 4 |
-
|
| 5 |
import { Slider } from '@/components/ui/slider'
|
|
|
|
| 6 |
|
| 7 |
export function StatusBar() {
|
| 8 |
const { scale, setScale, summary } = useCanvasZoom()
|
| 9 |
const { t } = useTranslation()
|
| 10 |
|
| 11 |
return (
|
| 12 |
-
<div className='
|
| 13 |
<div className='flex items-center gap-1.5'>
|
| 14 |
<span className='text-muted-foreground'>{t('statusBar.zoom')}</span>
|
| 15 |
<Slider
|
| 16 |
data-testid='zoom-slider'
|
| 17 |
-
className='[&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:
|
| 18 |
min={10}
|
| 19 |
max={100}
|
| 20 |
step={5}
|
|
@@ -25,7 +26,7 @@ export function StatusBar() {
|
|
| 25 |
{scale}%
|
| 26 |
</span>
|
| 27 |
</div>
|
| 28 |
-
<span className='
|
| 29 |
{t('statusBar.canvas')}: {summary}
|
| 30 |
</span>
|
| 31 |
</div>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useTranslation } from 'react-i18next'
|
| 4 |
+
|
| 5 |
import { Slider } from '@/components/ui/slider'
|
| 6 |
+
import { useCanvasZoom } from '@/hooks/useCanvasZoom'
|
| 7 |
|
| 8 |
export function StatusBar() {
|
| 9 |
const { scale, setScale, summary } = useCanvasZoom()
|
| 10 |
const { t } = useTranslation()
|
| 11 |
|
| 12 |
return (
|
| 13 |
+
<div className='flex shrink-0 items-center justify-end gap-3 border-t border-border bg-card px-2 py-1 text-xs text-foreground'>
|
| 14 |
<div className='flex items-center gap-1.5'>
|
| 15 |
<span className='text-muted-foreground'>{t('statusBar.zoom')}</span>
|
| 16 |
<Slider
|
| 17 |
data-testid='zoom-slider'
|
| 18 |
+
className='w-44 [&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:size-2.5 [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20'
|
| 19 |
min={10}
|
| 20 |
max={100}
|
| 21 |
step={5}
|
|
|
|
| 26 |
{scale}%
|
| 27 |
</span>
|
| 28 |
</div>
|
| 29 |
+
<span className='ml-auto text-[11px] text-muted-foreground'>
|
| 30 |
{t('statusBar.canvas')}: {summary}
|
| 31 |
</span>
|
| 32 |
</div>
|
ui/components/canvas/TextBlockLayer.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useRef } from 'react'
|
| 4 |
import { useDrag } from '@use-gesture/react'
|
|
|
|
| 5 |
import { useHotkeys } from 'react-hotkeys-hook'
|
|
|
|
|
|
|
|
|
|
| 6 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 7 |
import { TextBlock } from '@/types'
|
| 8 |
-
import { useTextBlocks } from '@/hooks/useTextBlocks'
|
| 9 |
-
import { useBlobImage } from '@/hooks/useBlobData'
|
| 10 |
|
| 11 |
type TextBlockLayerProps = {
|
| 12 |
selectedIndex?: number
|
|
@@ -52,11 +53,7 @@ export function TextBlockLayer({
|
|
| 52 |
>
|
| 53 |
{showSprites &&
|
| 54 |
textBlocks.map((block, index) => (
|
| 55 |
-
<BlockSprite
|
| 56 |
-
key={`sprite-${block.id ?? index}`}
|
| 57 |
-
block={block}
|
| 58 |
-
scale={scale}
|
| 59 |
-
/>
|
| 60 |
))}
|
| 61 |
{textBlocks.map((block, index) => (
|
| 62 |
<TextBlockItem
|
|
@@ -222,7 +219,7 @@ function TextBlockItem({
|
|
| 222 |
<div
|
| 223 |
className={`absolute inset-0 rounded ${
|
| 224 |
selected
|
| 225 |
-
? 'border-primary bg-primary/15
|
| 226 |
: 'border-2 border-rose-400/60 bg-rose-400/5'
|
| 227 |
}`}
|
| 228 |
/>
|
|
@@ -237,9 +234,7 @@ function TextBlockItem({
|
|
| 237 |
</div>
|
| 238 |
|
| 239 |
{/* Resize handles */}
|
| 240 |
-
{selected && interactive &&
|
| 241 |
-
<ResizeHandles onEdgePointerDown={handleEdgePointerDown} />
|
| 242 |
-
)}
|
| 243 |
</div>
|
| 244 |
)
|
| 245 |
}
|
|
@@ -265,11 +260,7 @@ function BlockSprite({ block, scale }: { block: TextBlock; scale: number }) {
|
|
| 265 |
)
|
| 266 |
}
|
| 267 |
|
| 268 |
-
function ResizeHandles({
|
| 269 |
-
onEdgePointerDown,
|
| 270 |
-
}: {
|
| 271 |
-
onEdgePointerDown: (edge: ResizeEdge) => void
|
| 272 |
-
}) {
|
| 273 |
const s = RESIZE_HANDLE_SIZE
|
| 274 |
const half = s / 2
|
| 275 |
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { useDrag } from '@use-gesture/react'
|
| 4 |
+
import { useRef } from 'react'
|
| 5 |
import { useHotkeys } from 'react-hotkeys-hook'
|
| 6 |
+
|
| 7 |
+
import { useBlobImage } from '@/hooks/useBlobData'
|
| 8 |
+
import { useTextBlocks } from '@/hooks/useTextBlocks'
|
| 9 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 10 |
import { TextBlock } from '@/types'
|
|
|
|
|
|
|
| 11 |
|
| 12 |
type TextBlockLayerProps = {
|
| 13 |
selectedIndex?: number
|
|
|
|
| 53 |
>
|
| 54 |
{showSprites &&
|
| 55 |
textBlocks.map((block, index) => (
|
| 56 |
+
<BlockSprite key={`sprite-${block.id ?? index}`} block={block} scale={scale} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
))}
|
| 58 |
{textBlocks.map((block, index) => (
|
| 59 |
<TextBlockItem
|
|
|
|
| 219 |
<div
|
| 220 |
className={`absolute inset-0 rounded ${
|
| 221 |
selected
|
| 222 |
+
? 'border-[3px] border-primary bg-primary/15'
|
| 223 |
: 'border-2 border-rose-400/60 bg-rose-400/5'
|
| 224 |
}`}
|
| 225 |
/>
|
|
|
|
| 234 |
</div>
|
| 235 |
|
| 236 |
{/* Resize handles */}
|
| 237 |
+
{selected && interactive && <ResizeHandles onEdgePointerDown={handleEdgePointerDown} />}
|
|
|
|
|
|
|
| 238 |
</div>
|
| 239 |
)
|
| 240 |
}
|
|
|
|
| 260 |
)
|
| 261 |
}
|
| 262 |
|
| 263 |
+
function ResizeHandles({ onEdgePointerDown }: { onEdgePointerDown: (edge: ResizeEdge) => void }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
const s = RESIZE_HANDLE_SIZE
|
| 265 |
const half = s / 2
|
| 266 |
|
ui/components/canvas/ToolRail.tsx
CHANGED
|
@@ -1,30 +1,17 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import type { ComponentType } from 'react'
|
| 4 |
import { useTranslation } from 'react-i18next'
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
} from 'lucide-react'
|
| 12 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 13 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 14 |
import { ToolMode } from '@/types'
|
| 15 |
-
import {
|
| 16 |
-
Tooltip,
|
| 17 |
-
TooltipContent,
|
| 18 |
-
TooltipTrigger,
|
| 19 |
-
} from '@/components/ui/tooltip'
|
| 20 |
-
import {
|
| 21 |
-
Popover,
|
| 22 |
-
PopoverContent,
|
| 23 |
-
PopoverTrigger,
|
| 24 |
-
} from '@/components/ui/popover'
|
| 25 |
-
import { Slider } from '@/components/ui/slider'
|
| 26 |
-
import { Button } from '@/components/ui/button'
|
| 27 |
-
import { ColorPicker } from '@/components/ui/color-picker'
|
| 28 |
|
| 29 |
type ModeDefinition = {
|
| 30 |
value: ToolMode
|
|
@@ -73,7 +60,7 @@ export function ToolRail() {
|
|
| 73 |
const { t } = useTranslation()
|
| 74 |
|
| 75 |
return (
|
| 76 |
-
<div className='
|
| 77 |
<div className='flex flex-1 flex-col items-center gap-1 py-2'>
|
| 78 |
{MODES.map((item) => {
|
| 79 |
const label = t(item.labelKey)
|
|
@@ -100,7 +87,7 @@ export function ToolRail() {
|
|
| 100 |
data-testid={item.testId}
|
| 101 |
data-active={item.value === mode}
|
| 102 |
onClick={() => setMode(item.value)}
|
| 103 |
-
className='text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary
|
| 104 |
aria-label={label}
|
| 105 |
>
|
| 106 |
<item.icon className='h-4 w-4' />
|
|
@@ -148,7 +135,7 @@ function BrushToolWithPopover({
|
|
| 148 |
data-testid={item.testId}
|
| 149 |
data-active={isActive}
|
| 150 |
onClick={onSelect}
|
| 151 |
-
className='text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary
|
| 152 |
aria-label={label}
|
| 153 |
>
|
| 154 |
<item.icon className='h-4 w-4' />
|
|
@@ -164,24 +151,22 @@ function BrushToolWithPopover({
|
|
| 164 |
<PopoverContent side='right' align='start' className='w-56'>
|
| 165 |
<div className='space-y-4 text-sm'>
|
| 166 |
<div className='space-y-2'>
|
| 167 |
-
<p className='text-
|
| 168 |
{t('toolbar.brushSize')}
|
| 169 |
</p>
|
| 170 |
<div className='flex items-center gap-2'>
|
| 171 |
<Slider
|
| 172 |
data-testid='brush-size-slider'
|
| 173 |
-
className='[&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:
|
| 174 |
min={8}
|
| 175 |
max={128}
|
| 176 |
step={4}
|
| 177 |
value={[brushSize]}
|
| 178 |
-
onValueChange={(vals) =>
|
| 179 |
-
setBrushConfig({ size: vals[0] ?? brushSize })
|
| 180 |
-
}
|
| 181 |
/>
|
| 182 |
<Tooltip>
|
| 183 |
<TooltipTrigger asChild>
|
| 184 |
-
<span className='
|
| 185 |
{brushSize}px
|
| 186 |
</span>
|
| 187 |
</TooltipTrigger>
|
|
@@ -193,7 +178,7 @@ function BrushToolWithPopover({
|
|
| 193 |
</div>
|
| 194 |
</div>
|
| 195 |
<div className='space-y-2'>
|
| 196 |
-
<p className='text-
|
| 197 |
{t('toolbar.brushColor')}
|
| 198 |
</p>
|
| 199 |
<div className='flex items-center gap-2'>
|
|
@@ -207,9 +192,7 @@ function BrushToolWithPopover({
|
|
| 207 |
inputTestId='brush-color-input'
|
| 208 |
pickButtonTestId='brush-color-pick'
|
| 209 |
/>
|
| 210 |
-
<span className='text-
|
| 211 |
-
{brushColor}
|
| 212 |
-
</span>
|
| 213 |
</div>
|
| 214 |
</div>
|
| 215 |
</div>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { MousePointer, VectorSquare, Brush, Bandage, Eraser } from 'lucide-react'
|
| 4 |
import type { ComponentType } from 'react'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
+
|
| 7 |
+
import { Button } from '@/components/ui/button'
|
| 8 |
+
import { ColorPicker } from '@/components/ui/color-picker'
|
| 9 |
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
| 10 |
+
import { Slider } from '@/components/ui/slider'
|
| 11 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
|
|
| 12 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 13 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 14 |
import { ToolMode } from '@/types'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
type ModeDefinition = {
|
| 17 |
value: ToolMode
|
|
|
|
| 60 |
const { t } = useTranslation()
|
| 61 |
|
| 62 |
return (
|
| 63 |
+
<div className='flex w-11 flex-col border-r border-border bg-card'>
|
| 64 |
<div className='flex flex-1 flex-col items-center gap-1 py-2'>
|
| 65 |
{MODES.map((item) => {
|
| 66 |
const label = t(item.labelKey)
|
|
|
|
| 87 |
data-testid={item.testId}
|
| 88 |
data-active={item.value === mode}
|
| 89 |
onClick={() => setMode(item.value)}
|
| 90 |
+
className='border border-transparent text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary'
|
| 91 |
aria-label={label}
|
| 92 |
>
|
| 93 |
<item.icon className='h-4 w-4' />
|
|
|
|
| 135 |
data-testid={item.testId}
|
| 136 |
data-active={isActive}
|
| 137 |
onClick={onSelect}
|
| 138 |
+
className='border border-transparent text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary'
|
| 139 |
aria-label={label}
|
| 140 |
>
|
| 141 |
<item.icon className='h-4 w-4' />
|
|
|
|
| 151 |
<PopoverContent side='right' align='start' className='w-56'>
|
| 152 |
<div className='space-y-4 text-sm'>
|
| 153 |
<div className='space-y-2'>
|
| 154 |
+
<p className='text-xs font-medium text-muted-foreground uppercase'>
|
| 155 |
{t('toolbar.brushSize')}
|
| 156 |
</p>
|
| 157 |
<div className='flex items-center gap-2'>
|
| 158 |
<Slider
|
| 159 |
data-testid='brush-size-slider'
|
| 160 |
+
className='flex-1 [&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:size-3 [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20'
|
| 161 |
min={8}
|
| 162 |
max={128}
|
| 163 |
step={4}
|
| 164 |
value={[brushSize]}
|
| 165 |
+
onValueChange={(vals) => setBrushConfig({ size: vals[0] ?? brushSize })}
|
|
|
|
|
|
|
| 166 |
/>
|
| 167 |
<Tooltip>
|
| 168 |
<TooltipTrigger asChild>
|
| 169 |
+
<span className='w-10 cursor-help text-right text-muted-foreground tabular-nums'>
|
| 170 |
{brushSize}px
|
| 171 |
</span>
|
| 172 |
</TooltipTrigger>
|
|
|
|
| 178 |
</div>
|
| 179 |
</div>
|
| 180 |
<div className='space-y-2'>
|
| 181 |
+
<p className='text-xs font-medium text-muted-foreground uppercase'>
|
| 182 |
{t('toolbar.brushColor')}
|
| 183 |
</p>
|
| 184 |
<div className='flex items-center gap-2'>
|
|
|
|
| 192 |
inputTestId='brush-color-input'
|
| 193 |
pickButtonTestId='brush-color-pick'
|
| 194 |
/>
|
| 195 |
+
<span className='text-xs text-muted-foreground'>{brushColor}</span>
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
</div>
|
| 198 |
</div>
|
ui/components/canvas/Workspace.tsx
CHANGED
|
@@ -1,58 +1,53 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useEffect, useRef, useMemo, useCallback } from 'react'
|
| 4 |
-
import type React from 'react'
|
| 5 |
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
| 6 |
import { useGesture } from '@use-gesture/react'
|
| 7 |
-
import {
|
| 8 |
-
|
| 9 |
-
ContextMenuContent,
|
| 10 |
-
ContextMenuItem,
|
| 11 |
-
ContextMenuTrigger,
|
| 12 |
-
} from '@/components/ui/context-menu'
|
| 13 |
import { useTranslation } from 'react-i18next'
|
| 14 |
-
|
| 15 |
-
import {
|
| 16 |
import {
|
| 17 |
setCanvasViewport,
|
| 18 |
setCanvasDocumentSize,
|
| 19 |
fitCanvasToViewport,
|
| 20 |
} from '@/components/canvas/canvasViewport'
|
| 21 |
-
import { ToolRail } from '@/components/canvas/ToolRail'
|
| 22 |
-
import { CanvasToolbar } from '@/components/canvas/CanvasToolbar'
|
| 23 |
import { TextBlockLayer } from '@/components/canvas/TextBlockLayer'
|
| 24 |
-
import {
|
| 25 |
-
import { usePointerToDocument } from '@/hooks/usePointerToDocument'
|
| 26 |
-
import { useBlockDrafting } from '@/hooks/useBlockDrafting'
|
| 27 |
-
import { useBlockContextMenu } from '@/hooks/useBlockContextMenu'
|
| 28 |
-
import { useTextBlocks, useDocumentLayer } from '@/hooks/useTextBlocks'
|
| 29 |
-
import { useMaskDrawing } from '@/hooks/useMaskDrawing'
|
| 30 |
-
import { useRenderBrushDrawing } from '@/hooks/useRenderBrushDrawing'
|
| 31 |
-
import { useBrushLayerDisplay } from '@/hooks/useBrushLayerDisplay'
|
| 32 |
-
import { useBrushCursor } from '@/hooks/useBrushCursor'
|
| 33 |
-
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
| 34 |
-
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 35 |
import {
|
| 36 |
resolvePinchMemoScaleRatio,
|
| 37 |
resolvePinchNextScaleRatio,
|
| 38 |
} from '@/components/canvas/zoomGestures'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
const BRUSH_CURSOR = 'none'
|
| 41 |
|
| 42 |
export function Workspace() {
|
| 43 |
useKeyboardShortcuts()
|
| 44 |
const scale = useEditorUiStore((state) => state.scale)
|
| 45 |
-
const showSegmentationMask = useEditorUiStore(
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
const showInpaintedImage = useEditorUiStore(
|
| 49 |
-
(state) => state.showInpaintedImage,
|
| 50 |
-
)
|
| 51 |
const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
|
| 52 |
const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
|
| 53 |
-
const showTextBlocksOverlay = useEditorUiStore(
|
| 54 |
-
(state) => state.showTextBlocksOverlay,
|
| 55 |
-
)
|
| 56 |
const mode = useEditorUiStore((state) => state.mode)
|
| 57 |
const autoFitEnabled = useEditorUiStore((state) => state.autoFitEnabled)
|
| 58 |
const {
|
|
@@ -64,16 +59,8 @@ export function Workspace() {
|
|
| 64 |
removeBlock,
|
| 65 |
} = useTextBlocks()
|
| 66 |
|
| 67 |
-
const imageData = useDocumentLayer(
|
| 68 |
-
|
| 69 |
-
'image',
|
| 70 |
-
currentDocument?.image,
|
| 71 |
-
)
|
| 72 |
-
const segmentData = useDocumentLayer(
|
| 73 |
-
currentDocument?.id,
|
| 74 |
-
'segment',
|
| 75 |
-
currentDocument?.segment,
|
| 76 |
-
)
|
| 77 |
const inpaintedData = useDocumentLayer(
|
| 78 |
currentDocument?.id,
|
| 79 |
'inpainted',
|
|
@@ -84,11 +71,7 @@ export function Workspace() {
|
|
| 84 |
'brushLayer',
|
| 85 |
currentDocument?.brushLayer,
|
| 86 |
)
|
| 87 |
-
const renderedData = useDocumentLayer(
|
| 88 |
-
currentDocument?.id,
|
| 89 |
-
'rendered',
|
| 90 |
-
currentDocument?.rendered,
|
| 91 |
-
)
|
| 92 |
|
| 93 |
useEffect(() => {
|
| 94 |
if (currentDocument) {
|
|
@@ -123,14 +106,11 @@ export function Workspace() {
|
|
| 123 |
|
| 124 |
const maskPointerEnabled = useMemo(
|
| 125 |
() =>
|
| 126 |
-
mode === 'repairBrush' ||
|
| 127 |
-
(mode === 'eraser' && (showSegmentationMask || !showBrushLayer)),
|
| 128 |
[mode, showSegmentationMask, showBrushLayer],
|
| 129 |
)
|
| 130 |
const brushPointerEnabled = useMemo(
|
| 131 |
-
() =>
|
| 132 |
-
mode === 'brush' ||
|
| 133 |
-
(mode === 'eraser' && !showSegmentationMask && showBrushLayer),
|
| 134 |
[mode, showSegmentationMask, showBrushLayer],
|
| 135 |
)
|
| 136 |
const maskDrawing = useMaskDrawing({
|
|
@@ -163,19 +143,15 @@ export function Workspace() {
|
|
| 163 |
fitCanvasToViewport()
|
| 164 |
}
|
| 165 |
}, [currentDocument?.id, autoFitEnabled])
|
| 166 |
-
const {
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
removeBlock: (index) => {
|
| 176 |
-
void removeBlock(index)
|
| 177 |
-
},
|
| 178 |
-
})
|
| 179 |
const { t } = useTranslation()
|
| 180 |
|
| 181 |
// Listen for Tauri resize events
|
|
@@ -241,10 +217,7 @@ export function Workspace() {
|
|
| 241 |
memo,
|
| 242 |
useEditorUiStore.getState().scale / 100,
|
| 243 |
)
|
| 244 |
-
const nextScaleRatio = resolvePinchNextScaleRatio(
|
| 245 |
-
memoScaleRatio,
|
| 246 |
-
movementScale,
|
| 247 |
-
)
|
| 248 |
applyScale(nextScaleRatio * 100)
|
| 249 |
return memoScaleRatio
|
| 250 |
},
|
|
@@ -272,9 +245,7 @@ export function Workspace() {
|
|
| 272 |
},
|
| 273 |
)
|
| 274 |
|
| 275 |
-
const handleCanvasPointerDownCapture = (
|
| 276 |
-
event: React.PointerEvent<HTMLDivElement>,
|
| 277 |
-
) => {
|
| 278 |
if (mode !== 'block' && event.target === event.currentTarget) {
|
| 279 |
clearSelection()
|
| 280 |
}
|
|
@@ -301,7 +272,7 @@ export function Workspace() {
|
|
| 301 |
)
|
| 302 |
|
| 303 |
return (
|
| 304 |
-
<div className='
|
| 305 |
<ToolRail />
|
| 306 |
<div className='relative flex min-h-0 min-w-0 flex-1 flex-col'>
|
| 307 |
<CanvasToolbar />
|
|
@@ -324,7 +295,7 @@ export function Workspace() {
|
|
| 324 |
<div
|
| 325 |
ref={canvasRef}
|
| 326 |
data-testid='workspace-canvas'
|
| 327 |
-
className='border-border bg-card
|
| 328 |
style={{
|
| 329 |
...canvasDimensions,
|
| 330 |
cursor: canvasCursor,
|
|
@@ -392,9 +363,7 @@ export function Workspace() {
|
|
| 392 |
width: '100%',
|
| 393 |
height: '100%',
|
| 394 |
opacity: brushDrawing.visible ? 1 : 0,
|
| 395 |
-
pointerEvents: brushPointerEnabled
|
| 396 |
-
? 'auto'
|
| 397 |
-
: 'none',
|
| 398 |
touchAction: 'none',
|
| 399 |
zIndex: 20,
|
| 400 |
transition: 'opacity 120ms ease',
|
|
@@ -421,7 +390,7 @@ export function Workspace() {
|
|
| 421 |
</div>
|
| 422 |
{draftBlock && (
|
| 423 |
<div
|
| 424 |
-
className='
|
| 425 |
style={{
|
| 426 |
left: draftBlock.x * scaleRatio,
|
| 427 |
top: draftBlock.y * scaleRatio,
|
|
@@ -443,7 +412,7 @@ export function Workspace() {
|
|
| 443 |
</ContextMenuContent>
|
| 444 |
</ContextMenu>
|
| 445 |
) : (
|
| 446 |
-
<div className='
|
| 447 |
{t('workspace.importPrompt')}
|
| 448 |
</div>
|
| 449 |
)}
|
|
@@ -452,13 +421,13 @@ export function Workspace() {
|
|
| 452 |
orientation='vertical'
|
| 453 |
className='flex w-2 touch-none p-px select-none'
|
| 454 |
>
|
| 455 |
-
<ScrollAreaPrimitive.Thumb className='bg-muted-foreground/40
|
| 456 |
</ScrollAreaPrimitive.Scrollbar>
|
| 457 |
<ScrollAreaPrimitive.Scrollbar
|
| 458 |
orientation='horizontal'
|
| 459 |
className='flex h-2 touch-none p-px select-none'
|
| 460 |
>
|
| 461 |
-
<ScrollAreaPrimitive.Thumb className='bg-muted-foreground/40
|
| 462 |
</ScrollAreaPrimitive.Scrollbar>
|
| 463 |
</ScrollAreaPrimitive.Root>
|
| 464 |
</div>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
| 4 |
import { useGesture } from '@use-gesture/react'
|
| 5 |
+
import { useEffect, useRef, useMemo, useCallback } from 'react'
|
| 6 |
+
import type React from 'react'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import { useTranslation } from 'react-i18next'
|
| 8 |
+
|
| 9 |
+
import { CanvasToolbar } from '@/components/canvas/CanvasToolbar'
|
| 10 |
import {
|
| 11 |
setCanvasViewport,
|
| 12 |
setCanvasDocumentSize,
|
| 13 |
fitCanvasToViewport,
|
| 14 |
} from '@/components/canvas/canvasViewport'
|
|
|
|
|
|
|
| 15 |
import { TextBlockLayer } from '@/components/canvas/TextBlockLayer'
|
| 16 |
+
import { ToolRail } from '@/components/canvas/ToolRail'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
import {
|
| 18 |
resolvePinchMemoScaleRatio,
|
| 19 |
resolvePinchNextScaleRatio,
|
| 20 |
} from '@/components/canvas/zoomGestures'
|
| 21 |
+
import { Image } from '@/components/Image'
|
| 22 |
+
import {
|
| 23 |
+
ContextMenu,
|
| 24 |
+
ContextMenuContent,
|
| 25 |
+
ContextMenuItem,
|
| 26 |
+
ContextMenuTrigger,
|
| 27 |
+
} from '@/components/ui/context-menu'
|
| 28 |
+
import { useBlockContextMenu } from '@/hooks/useBlockContextMenu'
|
| 29 |
+
import { useBlockDrafting } from '@/hooks/useBlockDrafting'
|
| 30 |
+
import { useBrushCursor } from '@/hooks/useBrushCursor'
|
| 31 |
+
import { useBrushLayerDisplay } from '@/hooks/useBrushLayerDisplay'
|
| 32 |
+
import { useCanvasZoom } from '@/hooks/useCanvasZoom'
|
| 33 |
+
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
| 34 |
+
import { useMaskDrawing } from '@/hooks/useMaskDrawing'
|
| 35 |
+
import { usePointerToDocument } from '@/hooks/usePointerToDocument'
|
| 36 |
+
import { useRenderBrushDrawing } from '@/hooks/useRenderBrushDrawing'
|
| 37 |
+
import { useTextBlocks, useDocumentLayer } from '@/hooks/useTextBlocks'
|
| 38 |
+
import { listen } from '@/lib/backend'
|
| 39 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 40 |
|
| 41 |
const BRUSH_CURSOR = 'none'
|
| 42 |
|
| 43 |
export function Workspace() {
|
| 44 |
useKeyboardShortcuts()
|
| 45 |
const scale = useEditorUiStore((state) => state.scale)
|
| 46 |
+
const showSegmentationMask = useEditorUiStore((state) => state.showSegmentationMask)
|
| 47 |
+
const showInpaintedImage = useEditorUiStore((state) => state.showInpaintedImage)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
|
| 49 |
const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
|
| 50 |
+
const showTextBlocksOverlay = useEditorUiStore((state) => state.showTextBlocksOverlay)
|
|
|
|
|
|
|
| 51 |
const mode = useEditorUiStore((state) => state.mode)
|
| 52 |
const autoFitEnabled = useEditorUiStore((state) => state.autoFitEnabled)
|
| 53 |
const {
|
|
|
|
| 59 |
removeBlock,
|
| 60 |
} = useTextBlocks()
|
| 61 |
|
| 62 |
+
const imageData = useDocumentLayer(currentDocument?.id, 'image', currentDocument?.image)
|
| 63 |
+
const segmentData = useDocumentLayer(currentDocument?.id, 'segment', currentDocument?.segment)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
const inpaintedData = useDocumentLayer(
|
| 65 |
currentDocument?.id,
|
| 66 |
'inpainted',
|
|
|
|
| 71 |
'brushLayer',
|
| 72 |
currentDocument?.brushLayer,
|
| 73 |
)
|
| 74 |
+
const renderedData = useDocumentLayer(currentDocument?.id, 'rendered', currentDocument?.rendered)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
useEffect(() => {
|
| 77 |
if (currentDocument) {
|
|
|
|
| 106 |
|
| 107 |
const maskPointerEnabled = useMemo(
|
| 108 |
() =>
|
| 109 |
+
mode === 'repairBrush' || (mode === 'eraser' && (showSegmentationMask || !showBrushLayer)),
|
|
|
|
| 110 |
[mode, showSegmentationMask, showBrushLayer],
|
| 111 |
)
|
| 112 |
const brushPointerEnabled = useMemo(
|
| 113 |
+
() => mode === 'brush' || (mode === 'eraser' && !showSegmentationMask && showBrushLayer),
|
|
|
|
|
|
|
| 114 |
[mode, showSegmentationMask, showBrushLayer],
|
| 115 |
)
|
| 116 |
const maskDrawing = useMaskDrawing({
|
|
|
|
| 143 |
fitCanvasToViewport()
|
| 144 |
}
|
| 145 |
}, [currentDocument?.id, autoFitEnabled])
|
| 146 |
+
const { contextMenuBlockIndex, handleContextMenu, handleDeleteBlock, clearContextMenu } =
|
| 147 |
+
useBlockContextMenu({
|
| 148 |
+
currentDocument,
|
| 149 |
+
pointerToDocument,
|
| 150 |
+
selectBlock: setSelectedBlockIndex,
|
| 151 |
+
removeBlock: (index) => {
|
| 152 |
+
void removeBlock(index)
|
| 153 |
+
},
|
| 154 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
const { t } = useTranslation()
|
| 156 |
|
| 157 |
// Listen for Tauri resize events
|
|
|
|
| 217 |
memo,
|
| 218 |
useEditorUiStore.getState().scale / 100,
|
| 219 |
)
|
| 220 |
+
const nextScaleRatio = resolvePinchNextScaleRatio(memoScaleRatio, movementScale)
|
|
|
|
|
|
|
|
|
|
| 221 |
applyScale(nextScaleRatio * 100)
|
| 222 |
return memoScaleRatio
|
| 223 |
},
|
|
|
|
| 245 |
},
|
| 246 |
)
|
| 247 |
|
| 248 |
+
const handleCanvasPointerDownCapture = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
|
|
|
|
|
| 249 |
if (mode !== 'block' && event.target === event.currentTarget) {
|
| 250 |
clearSelection()
|
| 251 |
}
|
|
|
|
| 272 |
)
|
| 273 |
|
| 274 |
return (
|
| 275 |
+
<div className='flex min-h-0 min-w-0 flex-1 bg-muted'>
|
| 276 |
<ToolRail />
|
| 277 |
<div className='relative flex min-h-0 min-w-0 flex-1 flex-col'>
|
| 278 |
<CanvasToolbar />
|
|
|
|
| 295 |
<div
|
| 296 |
ref={canvasRef}
|
| 297 |
data-testid='workspace-canvas'
|
| 298 |
+
className='relative rounded border border-border bg-card shadow-sm'
|
| 299 |
style={{
|
| 300 |
...canvasDimensions,
|
| 301 |
cursor: canvasCursor,
|
|
|
|
| 363 |
width: '100%',
|
| 364 |
height: '100%',
|
| 365 |
opacity: brushDrawing.visible ? 1 : 0,
|
| 366 |
+
pointerEvents: brushPointerEnabled ? 'auto' : 'none',
|
|
|
|
|
|
|
| 367 |
touchAction: 'none',
|
| 368 |
zIndex: 20,
|
| 369 |
transition: 'opacity 120ms ease',
|
|
|
|
| 390 |
</div>
|
| 391 |
{draftBlock && (
|
| 392 |
<div
|
| 393 |
+
className='pointer-events-none absolute rounded border-2 border-dashed border-primary bg-primary/10'
|
| 394 |
style={{
|
| 395 |
left: draftBlock.x * scaleRatio,
|
| 396 |
top: draftBlock.y * scaleRatio,
|
|
|
|
| 412 |
</ContextMenuContent>
|
| 413 |
</ContextMenu>
|
| 414 |
) : (
|
| 415 |
+
<div className='flex h-full w-full items-center justify-center text-sm text-muted-foreground'>
|
| 416 |
{t('workspace.importPrompt')}
|
| 417 |
</div>
|
| 418 |
)}
|
|
|
|
| 421 |
orientation='vertical'
|
| 422 |
className='flex w-2 touch-none p-px select-none'
|
| 423 |
>
|
| 424 |
+
<ScrollAreaPrimitive.Thumb className='flex-1 rounded bg-muted-foreground/40' />
|
| 425 |
</ScrollAreaPrimitive.Scrollbar>
|
| 426 |
<ScrollAreaPrimitive.Scrollbar
|
| 427 |
orientation='horizontal'
|
| 428 |
className='flex h-2 touch-none p-px select-none'
|
| 429 |
>
|
| 430 |
+
<ScrollAreaPrimitive.Thumb className='rounded bg-muted-foreground/40' />
|
| 431 |
</ScrollAreaPrimitive.Scrollbar>
|
| 432 |
</ScrollAreaPrimitive.Root>
|
| 433 |
</div>
|
ui/components/canvas/zoomGestures.ts
CHANGED
|
@@ -8,20 +8,14 @@ function clampScaleRatio(scaleRatio: number) {
|
|
| 8 |
return Math.max(MIN_SCALE_RATIO, Math.min(MAX_SCALE_RATIO, scaleRatio))
|
| 9 |
}
|
| 10 |
|
| 11 |
-
export function resolvePinchMemoScaleRatio(
|
| 12 |
-
memo: unknown,
|
| 13 |
-
currentScaleRatio: number,
|
| 14 |
-
) {
|
| 15 |
if (typeof memo === 'number' && Number.isFinite(memo)) {
|
| 16 |
return clampScaleRatio(memo)
|
| 17 |
}
|
| 18 |
return clampScaleRatio(currentScaleRatio)
|
| 19 |
}
|
| 20 |
|
| 21 |
-
export function resolvePinchNextScaleRatio(
|
| 22 |
-
memoScaleRatio: number,
|
| 23 |
-
movementScale: number,
|
| 24 |
-
) {
|
| 25 |
const baseScaleRatio = clampScaleRatio(memoScaleRatio)
|
| 26 |
if (!Number.isFinite(movementScale) || movementScale <= 0) {
|
| 27 |
return baseScaleRatio
|
|
|
|
| 8 |
return Math.max(MIN_SCALE_RATIO, Math.min(MAX_SCALE_RATIO, scaleRatio))
|
| 9 |
}
|
| 10 |
|
| 11 |
+
export function resolvePinchMemoScaleRatio(memo: unknown, currentScaleRatio: number) {
|
|
|
|
|
|
|
|
|
|
| 12 |
if (typeof memo === 'number' && Number.isFinite(memo)) {
|
| 13 |
return clampScaleRatio(memo)
|
| 14 |
}
|
| 15 |
return clampScaleRatio(currentScaleRatio)
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export function resolvePinchNextScaleRatio(memoScaleRatio: number, movementScale: number) {
|
|
|
|
|
|
|
|
|
|
| 19 |
const baseScaleRatio = clampScaleRatio(memoScaleRatio)
|
| 20 |
if (!Number.isFinite(movementScale) || movementScale <= 0) {
|
| 21 |
return baseScaleRatio
|
ui/components/panels/LayersPanel.tsx
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useTranslation } from 'react-i18next'
|
| 4 |
-
import { motion } from 'motion/react'
|
| 5 |
import {
|
| 6 |
EyeIcon,
|
| 7 |
EyeOffIcon,
|
|
@@ -11,10 +9,13 @@ import {
|
|
| 11 |
BandageIcon,
|
| 12 |
PaintbrushIcon,
|
| 13 |
} from 'lucide-react'
|
| 14 |
-
import {
|
|
|
|
|
|
|
| 15 |
import { Button } from '@/components/ui/button'
|
| 16 |
-
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 17 |
import { useCurrentDocument } from '@/hooks/useTextBlocks'
|
|
|
|
|
|
|
| 18 |
|
| 19 |
type Layer = {
|
| 20 |
id: string
|
|
@@ -28,30 +29,16 @@ type Layer = {
|
|
| 28 |
|
| 29 |
export function LayersPanel() {
|
| 30 |
const currentDocument = useCurrentDocument()
|
| 31 |
-
const showInpaintedImage = useEditorUiStore(
|
| 32 |
-
|
| 33 |
-
)
|
| 34 |
-
const
|
| 35 |
-
(state) => state.setShowInpaintedImage,
|
| 36 |
-
)
|
| 37 |
-
const showSegmentationMask = useEditorUiStore(
|
| 38 |
-
(state) => state.showSegmentationMask,
|
| 39 |
-
)
|
| 40 |
-
const setShowSegmentationMask = useEditorUiStore(
|
| 41 |
-
(state) => state.setShowSegmentationMask,
|
| 42 |
-
)
|
| 43 |
const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
|
| 44 |
const setShowBrushLayer = useEditorUiStore((state) => state.setShowBrushLayer)
|
| 45 |
-
const showTextBlocksOverlay = useEditorUiStore(
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
const setShowTextBlocksOverlay = useEditorUiStore(
|
| 49 |
-
(state) => state.setShowTextBlocksOverlay,
|
| 50 |
-
)
|
| 51 |
const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
|
| 52 |
-
const setShowRenderedImage = useEditorUiStore(
|
| 53 |
-
(state) => state.setShowRenderedImage,
|
| 54 |
-
)
|
| 55 |
|
| 56 |
const layers: Layer[] = [
|
| 57 |
{
|
|
@@ -69,8 +56,7 @@ export function LayersPanel() {
|
|
| 69 |
visible: showTextBlocksOverlay,
|
| 70 |
setVisible: setShowTextBlocksOverlay,
|
| 71 |
hasContent:
|
| 72 |
-
currentDocument?.textBlocks !== undefined &&
|
| 73 |
-
currentDocument.textBlocks.length > 0,
|
| 74 |
},
|
| 75 |
{
|
| 76 |
id: 'brush',
|
|
@@ -145,20 +131,14 @@ function LayerItem({ layer }: { layer: Layer }) {
|
|
| 145 |
}
|
| 146 |
}}
|
| 147 |
disabled={!canToggle}
|
| 148 |
-
className={cn(
|
| 149 |
-
'size-5',
|
| 150 |
-
canToggle ? 'cursor-pointer' : 'cursor-default',
|
| 151 |
-
)}
|
| 152 |
>
|
| 153 |
{layer.visible ? (
|
| 154 |
<EyeIcon
|
| 155 |
-
className={cn(
|
| 156 |
-
'size-3.5',
|
| 157 |
-
isActive ? 'text-foreground' : 'text-muted-foreground',
|
| 158 |
-
)}
|
| 159 |
/>
|
| 160 |
) : (
|
| 161 |
-
<EyeOffIcon className='text-muted-foreground/40
|
| 162 |
)}
|
| 163 |
</Button>
|
| 164 |
|
|
@@ -166,9 +146,7 @@ function LayerItem({ layer }: { layer: Layer }) {
|
|
| 166 |
<div
|
| 167 |
className={cn(
|
| 168 |
'flex size-5 shrink-0 items-center justify-center rounded',
|
| 169 |
-
!layer.hasContent && !isLocked
|
| 170 |
-
? 'text-muted-foreground/40'
|
| 171 |
-
: 'text-muted-foreground',
|
| 172 |
)}
|
| 173 |
>
|
| 174 |
{layer.icon === 'RAW' ? (
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import {
|
| 4 |
EyeIcon,
|
| 5 |
EyeOffIcon,
|
|
|
|
| 9 |
BandageIcon,
|
| 10 |
PaintbrushIcon,
|
| 11 |
} from 'lucide-react'
|
| 12 |
+
import { motion } from 'motion/react'
|
| 13 |
+
import { useTranslation } from 'react-i18next'
|
| 14 |
+
|
| 15 |
import { Button } from '@/components/ui/button'
|
|
|
|
| 16 |
import { useCurrentDocument } from '@/hooks/useTextBlocks'
|
| 17 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 18 |
+
import { cn } from '@/lib/utils'
|
| 19 |
|
| 20 |
type Layer = {
|
| 21 |
id: string
|
|
|
|
| 29 |
|
| 30 |
export function LayersPanel() {
|
| 31 |
const currentDocument = useCurrentDocument()
|
| 32 |
+
const showInpaintedImage = useEditorUiStore((state) => state.showInpaintedImage)
|
| 33 |
+
const setShowInpaintedImage = useEditorUiStore((state) => state.setShowInpaintedImage)
|
| 34 |
+
const showSegmentationMask = useEditorUiStore((state) => state.showSegmentationMask)
|
| 35 |
+
const setShowSegmentationMask = useEditorUiStore((state) => state.setShowSegmentationMask)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
|
| 37 |
const setShowBrushLayer = useEditorUiStore((state) => state.setShowBrushLayer)
|
| 38 |
+
const showTextBlocksOverlay = useEditorUiStore((state) => state.showTextBlocksOverlay)
|
| 39 |
+
const setShowTextBlocksOverlay = useEditorUiStore((state) => state.setShowTextBlocksOverlay)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
|
| 41 |
+
const setShowRenderedImage = useEditorUiStore((state) => state.setShowRenderedImage)
|
|
|
|
|
|
|
| 42 |
|
| 43 |
const layers: Layer[] = [
|
| 44 |
{
|
|
|
|
| 56 |
visible: showTextBlocksOverlay,
|
| 57 |
setVisible: setShowTextBlocksOverlay,
|
| 58 |
hasContent:
|
| 59 |
+
currentDocument?.textBlocks !== undefined && currentDocument.textBlocks.length > 0,
|
|
|
|
| 60 |
},
|
| 61 |
{
|
| 62 |
id: 'brush',
|
|
|
|
| 131 |
}
|
| 132 |
}}
|
| 133 |
disabled={!canToggle}
|
| 134 |
+
className={cn('size-5', canToggle ? 'cursor-pointer' : 'cursor-default')}
|
|
|
|
|
|
|
|
|
|
| 135 |
>
|
| 136 |
{layer.visible ? (
|
| 137 |
<EyeIcon
|
| 138 |
+
className={cn('size-3.5', isActive ? 'text-foreground' : 'text-muted-foreground')}
|
|
|
|
|
|
|
|
|
|
| 139 |
/>
|
| 140 |
) : (
|
| 141 |
+
<EyeOffIcon className='size-3.5 text-muted-foreground/40' />
|
| 142 |
)}
|
| 143 |
</Button>
|
| 144 |
|
|
|
|
| 146 |
<div
|
| 147 |
className={cn(
|
| 148 |
'flex size-5 shrink-0 items-center justify-center rounded',
|
| 149 |
+
!layer.hasContent && !isLocked ? 'text-muted-foreground/40' : 'text-muted-foreground',
|
|
|
|
|
|
|
| 150 |
)}
|
| 151 |
>
|
| 152 |
{layer.icon === 'RAW' ? (
|
ui/components/panels/RenderControlsPanel.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
import { useTranslation } from 'react-i18next'
|
| 5 |
import {
|
| 6 |
AlignCenterIcon,
|
| 7 |
AlignLeftIcon,
|
|
@@ -12,33 +11,22 @@ import {
|
|
| 12 |
PlusIcon,
|
| 13 |
SquareIcon,
|
| 14 |
} from 'lucide-react'
|
| 15 |
-
import {
|
| 16 |
-
import {
|
| 17 |
-
|
| 18 |
-
RenderStroke,
|
| 19 |
-
RgbaColor,
|
| 20 |
-
TextAlign,
|
| 21 |
-
TextStyle,
|
| 22 |
-
} from '@/types'
|
| 23 |
-
import type { FontFaceInfo } from '@/lib/api/schemas'
|
| 24 |
import { Button } from '@/components/ui/button'
|
| 25 |
import { ColorPicker } from '@/components/ui/color-picker'
|
| 26 |
-
import { Input } from '@/components/ui/input'
|
| 27 |
-
import {
|
| 28 |
-
Tooltip,
|
| 29 |
-
TooltipContent,
|
| 30 |
-
TooltipTrigger,
|
| 31 |
-
} from '@/components/ui/tooltip'
|
| 32 |
import { FontSelect } from '@/components/ui/font-select'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 34 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 35 |
-
import { useListFonts, useGetGoogleFontsCatalog } from '@/lib/api/system/system'
|
| 36 |
-
import {
|
| 37 |
-
getGetDocumentQueryKey,
|
| 38 |
-
updateDocumentStyle,
|
| 39 |
-
} from '@/lib/api/documents/documents'
|
| 40 |
-
import { useQueryClient } from '@tanstack/react-query'
|
| 41 |
import { cn } from '@/lib/utils'
|
|
|
|
| 42 |
|
| 43 |
const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
|
| 44 |
const DEFAULT_FONT_FACES: FontFaceInfo[] = [
|
|
@@ -62,16 +50,12 @@ const DEFAULT_STROKE_WIDTH = 1.6
|
|
| 62 |
const MIN_STROKE_WIDTH = 0.2
|
| 63 |
const MAX_STROKE_WIDTH = 24
|
| 64 |
const STROKE_WIDTH_STEP = 0.1
|
| 65 |
-
const LATIN_ONLY_PATTERN =
|
| 66 |
-
/^[\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]*$/u
|
| 67 |
|
| 68 |
-
const clampByte = (value: number) =>
|
| 69 |
-
Math.max(0, Math.min(255, Math.round(value)))
|
| 70 |
|
| 71 |
const clampStrokeWidth = (value: number) =>
|
| 72 |
-
Number(
|
| 73 |
-
Math.max(MIN_STROKE_WIDTH, Math.min(MAX_STROKE_WIDTH, value)).toFixed(1),
|
| 74 |
-
)
|
| 75 |
|
| 76 |
const colorToHex = (color: RgbaColor) =>
|
| 77 |
`#${color
|
|
@@ -129,8 +113,7 @@ const fallbackFontFace = (value?: string): FontFaceInfo | undefined => {
|
|
| 129 |
}
|
| 130 |
}
|
| 131 |
|
| 132 |
-
const hasExplicitFontFamilies = (style?: TextStyle) =>
|
| 133 |
-
(style?.fontFamilies?.length ?? 0) > 0
|
| 134 |
|
| 135 |
const normalizeEffect = (effect?: Partial<RenderEffect>): RenderEffect => ({
|
| 136 |
italic: effect?.italic ?? false,
|
|
@@ -213,28 +196,20 @@ export function RenderControlsPanel() {
|
|
| 213 |
const queryClient = useQueryClient()
|
| 214 |
const { t } = useTranslation()
|
| 215 |
const selectedBlock =
|
| 216 |
-
selectedBlockIndex !== undefined
|
| 217 |
-
|
| 218 |
-
: undefined
|
| 219 |
-
const selectedBlockHasExplicitFont = hasExplicitFontFamilies(
|
| 220 |
-
selectedBlock?.style,
|
| 221 |
-
)
|
| 222 |
const firstBlock = textBlocks[0]
|
| 223 |
const hasBlocks = textBlocks.length > 0
|
| 224 |
const fontCandidates = uniqueFontFaces(
|
| 225 |
[
|
| 226 |
...sortedFonts,
|
| 227 |
...(documentFont ? [fallbackFontFace(documentFont)] : []),
|
| 228 |
-
...(selectedBlock?.style?.fontFamilies
|
| 229 |
-
|
| 230 |
-
?.map(fallbackFontFace) ?? []),
|
| 231 |
-
...(firstBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ??
|
| 232 |
-
[]),
|
| 233 |
...DEFAULT_FONT_FACES,
|
| 234 |
].filter((value): value is FontFaceInfo => !!value),
|
| 235 |
)
|
| 236 |
-
const fallbackFontFaces =
|
| 237 |
-
fontCandidates.length > 0 ? fontCandidates : DEFAULT_FONT_FACES
|
| 238 |
const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
|
| 239 |
const fontOptions = fontCandidates
|
| 240 |
const currentFontCandidate =
|
|
@@ -243,18 +218,12 @@ export function RenderControlsPanel() {
|
|
| 243 |
firstBlock?.style?.fontFamilies?.[0] ??
|
| 244 |
(hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
|
| 245 |
const currentFontFace =
|
| 246 |
-
findFontFace(fontOptions, currentFontCandidate) ??
|
| 247 |
-
fallbackFontFace(currentFontCandidate)
|
| 248 |
const currentFont = currentFontFace?.postScriptName ?? ''
|
| 249 |
const currentFontFamilyName = currentFontFace?.familyName
|
| 250 |
-
const currentEffect = normalizeEffect(
|
| 251 |
-
|
| 252 |
-
)
|
| 253 |
-
const currentStroke = normalizeStroke(
|
| 254 |
-
selectedBlock?.style?.stroke ?? renderStroke,
|
| 255 |
-
)
|
| 256 |
-
const currentColor =
|
| 257 |
-
selectedBlock?.style?.color ?? (hasBlocks ? fallbackColor : DEFAULT_COLOR)
|
| 258 |
const currentColorHex = colorToHex(currentColor)
|
| 259 |
const currentStrokeColorHex = colorToHex(currentStroke.color)
|
| 260 |
const currentStrokeWidth = currentStroke.widthPx ?? DEFAULT_STROKE_WIDTH
|
|
@@ -269,9 +238,7 @@ export function RenderControlsPanel() {
|
|
| 269 |
const strokeColorLabel = t('render.strokeColorLabel')
|
| 270 |
const strokeWidthLabel = t('render.strokeWidthLabel')
|
| 271 |
const alignLabel = t('render.alignLabel')
|
| 272 |
-
const currentTextAlign = resolveEffectiveTextAlign(
|
| 273 |
-
selectedBlock ?? firstBlock,
|
| 274 |
-
)
|
| 275 |
const scopeLabel =
|
| 276 |
selectedBlockIndex !== undefined
|
| 277 |
? t('render.fontScopeBlockIndex', {
|
|
@@ -319,14 +286,9 @@ export function RenderControlsPanel() {
|
|
| 319 |
void updateTextBlocks(nextBlocks)
|
| 320 |
}
|
| 321 |
|
| 322 |
-
const mergeFontFamilies = (
|
| 323 |
-
nextFont: string,
|
| 324 |
-
current: string[] | undefined,
|
| 325 |
-
) => {
|
| 326 |
const base = (
|
| 327 |
-
current?.length
|
| 328 |
-
? current
|
| 329 |
-
: fallbackFontFaces.map((font) => font.postScriptName)
|
| 330 |
).map((family) => normalizeFontValue(fontOptions, family) ?? family)
|
| 331 |
return [nextFont, ...base.filter((family) => family !== nextFont)]
|
| 332 |
}
|
|
@@ -406,10 +368,10 @@ export function RenderControlsPanel() {
|
|
| 406 |
{/* Font + Color */}
|
| 407 |
<div className='flex flex-col gap-0.5'>
|
| 408 |
<div className='flex items-baseline justify-between'>
|
| 409 |
-
<span className='text-
|
| 410 |
{fontLabel}
|
| 411 |
</span>
|
| 412 |
-
<span className='text-
|
| 413 |
{t('render.fontColorLabel')}
|
| 414 |
</span>
|
| 415 |
</div>
|
|
@@ -422,20 +384,14 @@ export function RenderControlsPanel() {
|
|
| 422 |
disabled={fontOptions.length === 0}
|
| 423 |
placeholder={t('render.fontPlaceholder')}
|
| 424 |
triggerStyle={
|
| 425 |
-
currentFontFamilyName
|
| 426 |
-
? { fontFamily: currentFontFamilyName }
|
| 427 |
-
: undefined
|
| 428 |
}
|
| 429 |
onChange={(value) => {
|
| 430 |
-
const nextFamilies = mergeFontFamilies(
|
| 431 |
-
value,
|
| 432 |
-
selectedBlock?.style?.fontFamilies,
|
| 433 |
-
)
|
| 434 |
// Only persist a block override when the block already has one.
|
| 435 |
// Otherwise keep the block inheriting the document default.
|
| 436 |
if (selectedBlockHasExplicitFont) {
|
| 437 |
-
if (applyStyleToSelected({ fontFamilies: nextFamilies }))
|
| 438 |
-
return
|
| 439 |
}
|
| 440 |
updateDocumentDefaultFont(value)
|
| 441 |
}}
|
|
@@ -444,7 +400,7 @@ export function RenderControlsPanel() {
|
|
| 444 |
{selectedBlockHasExplicitFont ? (
|
| 445 |
<button
|
| 446 |
type='button'
|
| 447 |
-
className='text-muted-foreground hover:text-foreground
|
| 448 |
onClick={() => applyStyleToSelected({ fontFamilies: [] })}
|
| 449 |
title='Reset to document default'
|
| 450 |
>
|
|
@@ -471,17 +427,17 @@ export function RenderControlsPanel() {
|
|
| 471 |
|
| 472 |
{/* Size / Effect / Align */}
|
| 473 |
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto_auto] items-end gap-x-2'>
|
| 474 |
-
<span className='text-
|
| 475 |
{fontSizeLabel}
|
| 476 |
</span>
|
| 477 |
-
<span className='text-
|
| 478 |
{effectLabel}
|
| 479 |
</span>
|
| 480 |
-
<span className='text-
|
| 481 |
{alignLabel}
|
| 482 |
</span>
|
| 483 |
|
| 484 |
-
<div className='
|
| 485 |
<Button
|
| 486 |
type='button'
|
| 487 |
variant='ghost'
|
|
@@ -505,9 +461,7 @@ export function RenderControlsPanel() {
|
|
| 505 |
className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
|
| 506 |
data-testid='render-font-size'
|
| 507 |
disabled={selectedBlockIndex === undefined}
|
| 508 |
-
value={
|
| 509 |
-
currentFontSize !== undefined ? Math.round(currentFontSize) : ''
|
| 510 |
-
}
|
| 511 |
placeholder='auto'
|
| 512 |
onChange={(event) => {
|
| 513 |
const parsed = Number.parseInt(event.target.value, 10)
|
|
@@ -523,10 +477,7 @@ export function RenderControlsPanel() {
|
|
| 523 |
className='size-7 shrink-0 rounded-l-none border-l'
|
| 524 |
disabled={selectedBlockIndex === undefined}
|
| 525 |
onClick={() => {
|
| 526 |
-
const next = Math.min(
|
| 527 |
-
300,
|
| 528 |
-
Math.round((currentFontSize ?? 16) + 1),
|
| 529 |
-
)
|
| 530 |
applyStyleToSelected({ fontSize: next })
|
| 531 |
}}
|
| 532 |
>
|
|
@@ -549,7 +500,7 @@ export function RenderControlsPanel() {
|
|
| 549 |
className={cn(
|
| 550 |
'size-7 shrink-0',
|
| 551 |
active &&
|
| 552 |
-
'bg-primary text-primary-foreground
|
| 553 |
)}
|
| 554 |
onClick={() => {
|
| 555 |
const nextEffect = {
|
|
@@ -588,11 +539,10 @@ export function RenderControlsPanel() {
|
|
| 588 |
className={cn(
|
| 589 |
'size-7 shrink-0',
|
| 590 |
active &&
|
| 591 |
-
'bg-primary text-primary-foreground
|
| 592 |
)}
|
| 593 |
onClick={() => {
|
| 594 |
-
if (applyStyleToSelected({ textAlign: item.value }))
|
| 595 |
-
return
|
| 596 |
applyStyleToAll({ textAlign: item.value })
|
| 597 |
}}
|
| 598 |
>
|
|
@@ -610,7 +560,7 @@ export function RenderControlsPanel() {
|
|
| 610 |
|
| 611 |
{/* Border / Stroke */}
|
| 612 |
<div className='flex flex-col gap-0.5'>
|
| 613 |
-
<span className='text-
|
| 614 |
{strokeLabel}
|
| 615 |
</span>
|
| 616 |
<div className='flex min-w-0 items-center gap-1'>
|
|
@@ -624,7 +574,7 @@ export function RenderControlsPanel() {
|
|
| 624 |
className={cn(
|
| 625 |
'size-7 shrink-0',
|
| 626 |
currentStroke.enabled &&
|
| 627 |
-
'bg-primary text-primary-foreground
|
| 628 |
)}
|
| 629 |
onClick={() =>
|
| 630 |
applyStrokeSetting({
|
|
@@ -667,16 +617,14 @@ export function RenderControlsPanel() {
|
|
| 667 |
</TooltipContent>
|
| 668 |
</Tooltip>
|
| 669 |
|
| 670 |
-
<div className='
|
| 671 |
<Button
|
| 672 |
type='button'
|
| 673 |
variant='ghost'
|
| 674 |
size='icon-sm'
|
| 675 |
aria-label={`${strokeWidthLabel} -`}
|
| 676 |
className='size-7 shrink-0 rounded-r-none border-r'
|
| 677 |
-
onClick={() =>
|
| 678 |
-
updateStrokeWidth(currentStrokeWidth - STROKE_WIDTH_STEP)
|
| 679 |
-
}
|
| 680 |
>
|
| 681 |
<MinusIcon className='size-3' />
|
| 682 |
</Button>
|
|
@@ -690,9 +638,7 @@ export function RenderControlsPanel() {
|
|
| 690 |
className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
|
| 691 |
data-testid='render-stroke-width'
|
| 692 |
value={
|
| 693 |
-
Number.isFinite(currentStrokeWidth)
|
| 694 |
-
? currentStrokeWidth
|
| 695 |
-
: DEFAULT_STROKE_WIDTH
|
| 696 |
}
|
| 697 |
onChange={(event) => {
|
| 698 |
const parsed = Number.parseFloat(event.target.value)
|
|
@@ -707,9 +653,7 @@ export function RenderControlsPanel() {
|
|
| 707 |
size='icon-sm'
|
| 708 |
aria-label={`${strokeWidthLabel} +`}
|
| 709 |
className='size-7 shrink-0 rounded-l-none border-l'
|
| 710 |
-
onClick={() =>
|
| 711 |
-
updateStrokeWidth(currentStrokeWidth + STROKE_WIDTH_STEP)
|
| 712 |
-
}
|
| 713 |
>
|
| 714 |
<PlusIcon className='size-3' />
|
| 715 |
</Button>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useQueryClient } from '@tanstack/react-query'
|
|
|
|
| 4 |
import {
|
| 5 |
AlignCenterIcon,
|
| 6 |
AlignLeftIcon,
|
|
|
|
| 11 |
PlusIcon,
|
| 12 |
SquareIcon,
|
| 13 |
} from 'lucide-react'
|
| 14 |
+
import { type ComponentType, useMemo } from 'react'
|
| 15 |
+
import { useTranslation } from 'react-i18next'
|
| 16 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
import { Button } from '@/components/ui/button'
|
| 18 |
import { ColorPicker } from '@/components/ui/color-picker'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
import { FontSelect } from '@/components/ui/font-select'
|
| 20 |
+
import { Input } from '@/components/ui/input'
|
| 21 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
| 22 |
+
import { useTextBlocks } from '@/hooks/useTextBlocks'
|
| 23 |
+
import { getGetDocumentQueryKey, updateDocumentStyle } from '@/lib/api/documents/documents'
|
| 24 |
+
import type { FontFaceInfo } from '@/lib/api/schemas'
|
| 25 |
+
import { useListFonts, useGetGoogleFontsCatalog } from '@/lib/api/system/system'
|
| 26 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 27 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
import { cn } from '@/lib/utils'
|
| 29 |
+
import { RenderEffect, RenderStroke, RgbaColor, TextAlign, TextStyle } from '@/types'
|
| 30 |
|
| 31 |
const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
|
| 32 |
const DEFAULT_FONT_FACES: FontFaceInfo[] = [
|
|
|
|
| 50 |
const MIN_STROKE_WIDTH = 0.2
|
| 51 |
const MAX_STROKE_WIDTH = 24
|
| 52 |
const STROKE_WIDTH_STEP = 0.1
|
| 53 |
+
const LATIN_ONLY_PATTERN = /^[\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]*$/u
|
|
|
|
| 54 |
|
| 55 |
+
const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)))
|
|
|
|
| 56 |
|
| 57 |
const clampStrokeWidth = (value: number) =>
|
| 58 |
+
Number(Math.max(MIN_STROKE_WIDTH, Math.min(MAX_STROKE_WIDTH, value)).toFixed(1))
|
|
|
|
|
|
|
| 59 |
|
| 60 |
const colorToHex = (color: RgbaColor) =>
|
| 61 |
`#${color
|
|
|
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
| 116 |
+
const hasExplicitFontFamilies = (style?: TextStyle) => (style?.fontFamilies?.length ?? 0) > 0
|
|
|
|
| 117 |
|
| 118 |
const normalizeEffect = (effect?: Partial<RenderEffect>): RenderEffect => ({
|
| 119 |
italic: effect?.italic ?? false,
|
|
|
|
| 196 |
const queryClient = useQueryClient()
|
| 197 |
const { t } = useTranslation()
|
| 198 |
const selectedBlock =
|
| 199 |
+
selectedBlockIndex !== undefined ? textBlocks[selectedBlockIndex] : undefined
|
| 200 |
+
const selectedBlockHasExplicitFont = hasExplicitFontFamilies(selectedBlock?.style)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
const firstBlock = textBlocks[0]
|
| 202 |
const hasBlocks = textBlocks.length > 0
|
| 203 |
const fontCandidates = uniqueFontFaces(
|
| 204 |
[
|
| 205 |
...sortedFonts,
|
| 206 |
...(documentFont ? [fallbackFontFace(documentFont)] : []),
|
| 207 |
+
...(selectedBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ?? []),
|
| 208 |
+
...(firstBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ?? []),
|
|
|
|
|
|
|
|
|
|
| 209 |
...DEFAULT_FONT_FACES,
|
| 210 |
].filter((value): value is FontFaceInfo => !!value),
|
| 211 |
)
|
| 212 |
+
const fallbackFontFaces = fontCandidates.length > 0 ? fontCandidates : DEFAULT_FONT_FACES
|
|
|
|
| 213 |
const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
|
| 214 |
const fontOptions = fontCandidates
|
| 215 |
const currentFontCandidate =
|
|
|
|
| 218 |
firstBlock?.style?.fontFamilies?.[0] ??
|
| 219 |
(hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
|
| 220 |
const currentFontFace =
|
| 221 |
+
findFontFace(fontOptions, currentFontCandidate) ?? fallbackFontFace(currentFontCandidate)
|
|
|
|
| 222 |
const currentFont = currentFontFace?.postScriptName ?? ''
|
| 223 |
const currentFontFamilyName = currentFontFace?.familyName
|
| 224 |
+
const currentEffect = normalizeEffect(selectedBlock?.style?.effect ?? renderEffect)
|
| 225 |
+
const currentStroke = normalizeStroke(selectedBlock?.style?.stroke ?? renderStroke)
|
| 226 |
+
const currentColor = selectedBlock?.style?.color ?? (hasBlocks ? fallbackColor : DEFAULT_COLOR)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
const currentColorHex = colorToHex(currentColor)
|
| 228 |
const currentStrokeColorHex = colorToHex(currentStroke.color)
|
| 229 |
const currentStrokeWidth = currentStroke.widthPx ?? DEFAULT_STROKE_WIDTH
|
|
|
|
| 238 |
const strokeColorLabel = t('render.strokeColorLabel')
|
| 239 |
const strokeWidthLabel = t('render.strokeWidthLabel')
|
| 240 |
const alignLabel = t('render.alignLabel')
|
| 241 |
+
const currentTextAlign = resolveEffectiveTextAlign(selectedBlock ?? firstBlock)
|
|
|
|
|
|
|
| 242 |
const scopeLabel =
|
| 243 |
selectedBlockIndex !== undefined
|
| 244 |
? t('render.fontScopeBlockIndex', {
|
|
|
|
| 286 |
void updateTextBlocks(nextBlocks)
|
| 287 |
}
|
| 288 |
|
| 289 |
+
const mergeFontFamilies = (nextFont: string, current: string[] | undefined) => {
|
|
|
|
|
|
|
|
|
|
| 290 |
const base = (
|
| 291 |
+
current?.length ? current : fallbackFontFaces.map((font) => font.postScriptName)
|
|
|
|
|
|
|
| 292 |
).map((family) => normalizeFontValue(fontOptions, family) ?? family)
|
| 293 |
return [nextFont, ...base.filter((family) => family !== nextFont)]
|
| 294 |
}
|
|
|
|
| 368 |
{/* Font + Color */}
|
| 369 |
<div className='flex flex-col gap-0.5'>
|
| 370 |
<div className='flex items-baseline justify-between'>
|
| 371 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 372 |
{fontLabel}
|
| 373 |
</span>
|
| 374 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 375 |
{t('render.fontColorLabel')}
|
| 376 |
</span>
|
| 377 |
</div>
|
|
|
|
| 384 |
disabled={fontOptions.length === 0}
|
| 385 |
placeholder={t('render.fontPlaceholder')}
|
| 386 |
triggerStyle={
|
| 387 |
+
currentFontFamilyName ? { fontFamily: currentFontFamilyName } : undefined
|
|
|
|
|
|
|
| 388 |
}
|
| 389 |
onChange={(value) => {
|
| 390 |
+
const nextFamilies = mergeFontFamilies(value, selectedBlock?.style?.fontFamilies)
|
|
|
|
|
|
|
|
|
|
| 391 |
// Only persist a block override when the block already has one.
|
| 392 |
// Otherwise keep the block inheriting the document default.
|
| 393 |
if (selectedBlockHasExplicitFont) {
|
| 394 |
+
if (applyStyleToSelected({ fontFamilies: nextFamilies })) return
|
|
|
|
| 395 |
}
|
| 396 |
updateDocumentDefaultFont(value)
|
| 397 |
}}
|
|
|
|
| 400 |
{selectedBlockHasExplicitFont ? (
|
| 401 |
<button
|
| 402 |
type='button'
|
| 403 |
+
className='text-[9px] text-muted-foreground hover:text-foreground'
|
| 404 |
onClick={() => applyStyleToSelected({ fontFamilies: [] })}
|
| 405 |
title='Reset to document default'
|
| 406 |
>
|
|
|
|
| 427 |
|
| 428 |
{/* Size / Effect / Align */}
|
| 429 |
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto_auto] items-end gap-x-2'>
|
| 430 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 431 |
{fontSizeLabel}
|
| 432 |
</span>
|
| 433 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 434 |
{effectLabel}
|
| 435 |
</span>
|
| 436 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 437 |
{alignLabel}
|
| 438 |
</span>
|
| 439 |
|
| 440 |
+
<div className='flex min-w-0 items-center rounded-md border border-input bg-background shadow-xs'>
|
| 441 |
<Button
|
| 442 |
type='button'
|
| 443 |
variant='ghost'
|
|
|
|
| 461 |
className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
|
| 462 |
data-testid='render-font-size'
|
| 463 |
disabled={selectedBlockIndex === undefined}
|
| 464 |
+
value={currentFontSize !== undefined ? Math.round(currentFontSize) : ''}
|
|
|
|
|
|
|
| 465 |
placeholder='auto'
|
| 466 |
onChange={(event) => {
|
| 467 |
const parsed = Number.parseInt(event.target.value, 10)
|
|
|
|
| 477 |
className='size-7 shrink-0 rounded-l-none border-l'
|
| 478 |
disabled={selectedBlockIndex === undefined}
|
| 479 |
onClick={() => {
|
| 480 |
+
const next = Math.min(300, Math.round((currentFontSize ?? 16) + 1))
|
|
|
|
|
|
|
|
|
|
| 481 |
applyStyleToSelected({ fontSize: next })
|
| 482 |
}}
|
| 483 |
>
|
|
|
|
| 500 |
className={cn(
|
| 501 |
'size-7 shrink-0',
|
| 502 |
active &&
|
| 503 |
+
'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
|
| 504 |
)}
|
| 505 |
onClick={() => {
|
| 506 |
const nextEffect = {
|
|
|
|
| 539 |
className={cn(
|
| 540 |
'size-7 shrink-0',
|
| 541 |
active &&
|
| 542 |
+
'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
|
| 543 |
)}
|
| 544 |
onClick={() => {
|
| 545 |
+
if (applyStyleToSelected({ textAlign: item.value })) return
|
|
|
|
| 546 |
applyStyleToAll({ textAlign: item.value })
|
| 547 |
}}
|
| 548 |
>
|
|
|
|
| 560 |
|
| 561 |
{/* Border / Stroke */}
|
| 562 |
<div className='flex flex-col gap-0.5'>
|
| 563 |
+
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
|
| 564 |
{strokeLabel}
|
| 565 |
</span>
|
| 566 |
<div className='flex min-w-0 items-center gap-1'>
|
|
|
|
| 574 |
className={cn(
|
| 575 |
'size-7 shrink-0',
|
| 576 |
currentStroke.enabled &&
|
| 577 |
+
'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
|
| 578 |
)}
|
| 579 |
onClick={() =>
|
| 580 |
applyStrokeSetting({
|
|
|
|
| 617 |
</TooltipContent>
|
| 618 |
</Tooltip>
|
| 619 |
|
| 620 |
+
<div className='flex min-w-0 flex-1 items-center rounded-md border border-input bg-background shadow-xs'>
|
| 621 |
<Button
|
| 622 |
type='button'
|
| 623 |
variant='ghost'
|
| 624 |
size='icon-sm'
|
| 625 |
aria-label={`${strokeWidthLabel} -`}
|
| 626 |
className='size-7 shrink-0 rounded-r-none border-r'
|
| 627 |
+
onClick={() => updateStrokeWidth(currentStrokeWidth - STROKE_WIDTH_STEP)}
|
|
|
|
|
|
|
| 628 |
>
|
| 629 |
<MinusIcon className='size-3' />
|
| 630 |
</Button>
|
|
|
|
| 638 |
className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
|
| 639 |
data-testid='render-stroke-width'
|
| 640 |
value={
|
| 641 |
+
Number.isFinite(currentStrokeWidth) ? currentStrokeWidth : DEFAULT_STROKE_WIDTH
|
|
|
|
|
|
|
| 642 |
}
|
| 643 |
onChange={(event) => {
|
| 644 |
const parsed = Number.parseFloat(event.target.value)
|
|
|
|
| 653 |
size='icon-sm'
|
| 654 |
aria-label={`${strokeWidthLabel} +`}
|
| 655 |
className='size-7 shrink-0 rounded-l-none border-l'
|
| 656 |
+
onClick={() => updateStrokeWidth(currentStrokeWidth + STROKE_WIDTH_STEP)}
|
|
|
|
|
|
|
| 657 |
>
|
| 658 |
<PlusIcon className='size-3' />
|
| 659 |
</Button>
|
ui/components/panels/TextBlocksPanel.tsx
CHANGED
|
@@ -1,28 +1,25 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useTranslation } from 'react-i18next'
|
| 4 |
-
import { motion } from 'motion/react'
|
| 5 |
-
import { TextBlock } from '@/types'
|
| 6 |
import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
|
| 7 |
-
import {
|
| 8 |
-
import {
|
| 9 |
-
|
| 10 |
-
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 11 |
-
import { useProcessing } from '@/lib/machines'
|
| 12 |
import {
|
| 13 |
Accordion,
|
| 14 |
AccordionContent,
|
| 15 |
AccordionItem,
|
| 16 |
AccordionTrigger,
|
| 17 |
} from '@/components/ui/accordion'
|
| 18 |
-
import {
|
| 19 |
-
Tooltip,
|
| 20 |
-
TooltipContent,
|
| 21 |
-
TooltipTrigger,
|
| 22 |
-
} from '@/components/ui/tooltip'
|
| 23 |
import { Button } from '@/components/ui/button'
|
| 24 |
import { DraftTextarea } from '@/components/ui/draft-textarea'
|
| 25 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
export function TextBlocksPanel() {
|
| 28 |
const {
|
|
@@ -41,14 +38,13 @@ export function TextBlocksPanel() {
|
|
| 41 |
|
| 42 |
if (!document) {
|
| 43 |
return (
|
| 44 |
-
<div className='
|
| 45 |
{t('textBlocks.emptyPrompt')}
|
| 46 |
</div>
|
| 47 |
)
|
| 48 |
}
|
| 49 |
|
| 50 |
-
const accordionValue =
|
| 51 |
-
selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : ''
|
| 52 |
|
| 53 |
const handleGenerate = (blockIndex: number) => {
|
| 54 |
const documentId = useEditorUiStore.getState().currentDocumentId
|
|
@@ -78,11 +74,8 @@ export function TextBlocksPanel() {
|
|
| 78 |
}
|
| 79 |
|
| 80 |
return (
|
| 81 |
-
<div
|
| 82 |
-
className='flex
|
| 83 |
-
data-testid='panels-textblocks'
|
| 84 |
-
>
|
| 85 |
-
<div className='border-border text-muted-foreground flex items-center justify-between border-b px-2 py-1.5 text-xs font-semibold tracking-wide uppercase'>
|
| 86 |
<span data-testid='textblocks-count' data-count={textBlocks.length}>
|
| 87 |
{t('textBlocks.title', { count: textBlocks.length })}
|
| 88 |
</span>
|
|
@@ -94,7 +87,7 @@ export function TextBlocksPanel() {
|
|
| 94 |
>
|
| 95 |
<div className='p-2'>
|
| 96 |
{textBlocks.length === 0 ? (
|
| 97 |
-
<p className='
|
| 98 |
{t('textBlocks.none')}
|
| 99 |
</p>
|
| 100 |
) : (
|
|
@@ -172,9 +165,9 @@ function BlockCard({
|
|
| 172 |
<AccordionItem
|
| 173 |
value={index.toString()}
|
| 174 |
data-selected={selected}
|
| 175 |
-
className='bg-card/90 ring-border data-[selected=true]:ring-primary
|
| 176 |
>
|
| 177 |
-
<AccordionTrigger className='
|
| 178 |
<span
|
| 179 |
className={`shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
|
| 180 |
selected ? 'bg-primary' : 'bg-muted-foreground/60'
|
|
@@ -186,33 +179,27 @@ function BlockCard({
|
|
| 186 |
<div className='flex min-w-0 flex-1 items-center gap-1'>
|
| 187 |
<span
|
| 188 |
className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
|
| 189 |
-
hasOcr
|
| 190 |
-
? 'bg-rose-400/80 text-white'
|
| 191 |
-
: 'bg-muted text-muted-foreground/50'
|
| 192 |
}`}
|
| 193 |
>
|
| 194 |
{t('textBlocks.ocrBadge')}
|
| 195 |
</span>
|
| 196 |
<span
|
| 197 |
className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
|
| 198 |
-
hasTranslation
|
| 199 |
-
? 'bg-rose-400/80 text-white'
|
| 200 |
-
: 'bg-muted text-muted-foreground/50'
|
| 201 |
}`}
|
| 202 |
>
|
| 203 |
{t('textBlocks.translationBadge')}
|
| 204 |
</span>
|
| 205 |
{preview && (
|
| 206 |
-
<p className='
|
| 207 |
-
{preview}
|
| 208 |
-
</p>
|
| 209 |
)}
|
| 210 |
</div>
|
| 211 |
</AccordionTrigger>
|
| 212 |
<AccordionContent className='px-2 pt-1.5 pb-2 shadow-[inset_0_1px_0_0_var(--color-border)]'>
|
| 213 |
<div className='space-y-1.5'>
|
| 214 |
<div className='flex flex-col gap-0.5'>
|
| 215 |
-
<span className='text-
|
| 216 |
{t('textBlocks.ocrLabel')}
|
| 217 |
</span>
|
| 218 |
<DraftTextarea
|
|
@@ -226,7 +213,7 @@ function BlockCard({
|
|
| 226 |
</div>
|
| 227 |
<div className='flex flex-col gap-0.5'>
|
| 228 |
<div className='flex items-center justify-between'>
|
| 229 |
-
<span className='text-
|
| 230 |
{t('textBlocks.translationLabel')}
|
| 231 |
</span>
|
| 232 |
<div className='flex items-center gap-0.5'>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
|
| 4 |
+
import { motion } from 'motion/react'
|
| 5 |
+
import { useTranslation } from 'react-i18next'
|
| 6 |
+
|
|
|
|
|
|
|
| 7 |
import {
|
| 8 |
Accordion,
|
| 9 |
AccordionContent,
|
| 10 |
AccordionItem,
|
| 11 |
AccordionTrigger,
|
| 12 |
} from '@/components/ui/accordion'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import { Button } from '@/components/ui/button'
|
| 14 |
import { DraftTextarea } from '@/components/ui/draft-textarea'
|
| 15 |
import { ScrollArea } from '@/components/ui/scroll-area'
|
| 16 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
| 17 |
+
import { useTextBlocks } from '@/hooks/useTextBlocks'
|
| 18 |
+
import { useGetLlm } from '@/lib/api/llm/llm'
|
| 19 |
+
import { useProcessing } from '@/lib/machines'
|
| 20 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 21 |
+
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 22 |
+
import { TextBlock } from '@/types'
|
| 23 |
|
| 24 |
export function TextBlocksPanel() {
|
| 25 |
const {
|
|
|
|
| 38 |
|
| 39 |
if (!document) {
|
| 40 |
return (
|
| 41 |
+
<div className='flex flex-1 items-center justify-center text-xs text-muted-foreground'>
|
| 42 |
{t('textBlocks.emptyPrompt')}
|
| 43 |
</div>
|
| 44 |
)
|
| 45 |
}
|
| 46 |
|
| 47 |
+
const accordionValue = selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : ''
|
|
|
|
| 48 |
|
| 49 |
const handleGenerate = (blockIndex: number) => {
|
| 50 |
const documentId = useEditorUiStore.getState().currentDocumentId
|
|
|
|
| 74 |
}
|
| 75 |
|
| 76 |
return (
|
| 77 |
+
<div className='flex min-h-0 flex-1 flex-col' data-testid='panels-textblocks'>
|
| 78 |
+
<div className='flex items-center justify-between border-b border-border px-2 py-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase'>
|
|
|
|
|
|
|
|
|
|
| 79 |
<span data-testid='textblocks-count' data-count={textBlocks.length}>
|
| 80 |
{t('textBlocks.title', { count: textBlocks.length })}
|
| 81 |
</span>
|
|
|
|
| 87 |
>
|
| 88 |
<div className='p-2'>
|
| 89 |
{textBlocks.length === 0 ? (
|
| 90 |
+
<p className='rounded border border-dashed border-border p-2 text-xs text-muted-foreground'>
|
| 91 |
{t('textBlocks.none')}
|
| 92 |
</p>
|
| 93 |
) : (
|
|
|
|
| 165 |
<AccordionItem
|
| 166 |
value={index.toString()}
|
| 167 |
data-selected={selected}
|
| 168 |
+
className='overflow-hidden rounded bg-card/90 text-xs ring-1 ring-border data-[selected=true]:ring-primary'
|
| 169 |
>
|
| 170 |
+
<AccordionTrigger className='flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left transition outline-none hover:no-underline data-[state=open]:bg-accent [&>svg]:hidden'>
|
| 171 |
<span
|
| 172 |
className={`shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
|
| 173 |
selected ? 'bg-primary' : 'bg-muted-foreground/60'
|
|
|
|
| 179 |
<div className='flex min-w-0 flex-1 items-center gap-1'>
|
| 180 |
<span
|
| 181 |
className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
|
| 182 |
+
hasOcr ? 'bg-rose-400/80 text-white' : 'bg-muted text-muted-foreground/50'
|
|
|
|
|
|
|
| 183 |
}`}
|
| 184 |
>
|
| 185 |
{t('textBlocks.ocrBadge')}
|
| 186 |
</span>
|
| 187 |
<span
|
| 188 |
className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
|
| 189 |
+
hasTranslation ? 'bg-rose-400/80 text-white' : 'bg-muted text-muted-foreground/50'
|
|
|
|
|
|
|
| 190 |
}`}
|
| 191 |
>
|
| 192 |
{t('textBlocks.translationBadge')}
|
| 193 |
</span>
|
| 194 |
{preview && (
|
| 195 |
+
<p className='line-clamp-1 min-w-0 flex-1 text-xs text-muted-foreground'>{preview}</p>
|
|
|
|
|
|
|
| 196 |
)}
|
| 197 |
</div>
|
| 198 |
</AccordionTrigger>
|
| 199 |
<AccordionContent className='px-2 pt-1.5 pb-2 shadow-[inset_0_1px_0_0_var(--color-border)]'>
|
| 200 |
<div className='space-y-1.5'>
|
| 201 |
<div className='flex flex-col gap-0.5'>
|
| 202 |
+
<span className='text-[10px] text-muted-foreground uppercase'>
|
| 203 |
{t('textBlocks.ocrLabel')}
|
| 204 |
</span>
|
| 205 |
<DraftTextarea
|
|
|
|
| 213 |
</div>
|
| 214 |
<div className='flex flex-col gap-0.5'>
|
| 215 |
<div className='flex items-center justify-between'>
|
| 216 |
+
<span className='text-[10px] text-muted-foreground uppercase'>
|
| 217 |
{t('textBlocks.translationLabel')}
|
| 218 |
</span>
|
| 219 |
<div className='flex items-center gap-0.5'>
|
ui/components/ui/accordion.tsx
CHANGED
|
@@ -1,14 +1,12 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
| 5 |
import { ChevronDownIcon } from 'lucide-react'
|
|
|
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
-
function Accordion({
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
| 12 |
return <AccordionPrimitive.Root data-slot='accordion' {...props} />
|
| 13 |
}
|
| 14 |
|
|
@@ -35,13 +33,13 @@ function AccordionTrigger({
|
|
| 35 |
<AccordionPrimitive.Trigger
|
| 36 |
data-slot='accordion-trigger'
|
| 37 |
className={cn(
|
| 38 |
-
'
|
| 39 |
className,
|
| 40 |
)}
|
| 41 |
{...props}
|
| 42 |
>
|
| 43 |
{children}
|
| 44 |
-
<ChevronDownIcon className='
|
| 45 |
</AccordionPrimitive.Trigger>
|
| 46 |
</AccordionPrimitive.Header>
|
| 47 |
)
|
|
@@ -55,7 +53,7 @@ function AccordionContent({
|
|
| 55 |
return (
|
| 56 |
<AccordionPrimitive.Content
|
| 57 |
data-slot='accordion-content'
|
| 58 |
-
className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down
|
| 59 |
{...props}
|
| 60 |
>
|
| 61 |
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
| 4 |
import { ChevronDownIcon } from 'lucide-react'
|
| 5 |
+
import * as React from 'react'
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
+
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
|
|
|
|
|
| 10 |
return <AccordionPrimitive.Root data-slot='accordion' {...props} />
|
| 11 |
}
|
| 12 |
|
|
|
|
| 33 |
<AccordionPrimitive.Trigger
|
| 34 |
data-slot='accordion-trigger'
|
| 35 |
className={cn(
|
| 36 |
+
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
| 37 |
className,
|
| 38 |
)}
|
| 39 |
{...props}
|
| 40 |
>
|
| 41 |
{children}
|
| 42 |
+
<ChevronDownIcon className='pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200' />
|
| 43 |
</AccordionPrimitive.Trigger>
|
| 44 |
</AccordionPrimitive.Header>
|
| 45 |
)
|
|
|
|
| 53 |
return (
|
| 54 |
<AccordionPrimitive.Content
|
| 55 |
data-slot='accordion-content'
|
| 56 |
+
className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down'
|
| 57 |
{...props}
|
| 58 |
>
|
| 59 |
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
ui/components/ui/alert-dialog.tsx
CHANGED
|
@@ -1,31 +1,23 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
|
|
|
| 5 |
|
| 6 |
-
import { cn } from '@/lib/utils'
|
| 7 |
import { buttonVariants } from '@/components/ui/button'
|
|
|
|
| 8 |
|
| 9 |
-
function AlertDialog({
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
| 12 |
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
|
| 13 |
}
|
| 14 |
|
| 15 |
function AlertDialogTrigger({
|
| 16 |
...props
|
| 17 |
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
| 18 |
-
return
|
| 19 |
-
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
|
| 20 |
-
)
|
| 21 |
}
|
| 22 |
|
| 23 |
-
function AlertDialogPortal({
|
| 24 |
-
...props
|
| 25 |
-
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
| 26 |
-
return (
|
| 27 |
-
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
|
| 28 |
-
)
|
| 29 |
}
|
| 30 |
|
| 31 |
function AlertDialogOverlay({
|
|
@@ -36,7 +28,7 @@ function AlertDialogOverlay({
|
|
| 36 |
<AlertDialogPrimitive.Overlay
|
| 37 |
data-slot='alert-dialog-overlay'
|
| 38 |
className={cn(
|
| 39 |
-
'
|
| 40 |
className,
|
| 41 |
)}
|
| 42 |
{...props}
|
|
@@ -54,7 +46,7 @@ function AlertDialogContent({
|
|
| 54 |
<AlertDialogPrimitive.Content
|
| 55 |
data-slot='alert-dialog-content'
|
| 56 |
className={cn(
|
| 57 |
-
'bg-background data-[state=
|
| 58 |
className,
|
| 59 |
)}
|
| 60 |
{...props}
|
|
@@ -70,7 +62,7 @@ function AlertDialogTitle({
|
|
| 70 |
return (
|
| 71 |
<AlertDialogPrimitive.Title
|
| 72 |
data-slot='alert-dialog-title'
|
| 73 |
-
className={cn('text-
|
| 74 |
{...props}
|
| 75 |
/>
|
| 76 |
)
|
|
@@ -83,7 +75,7 @@ function AlertDialogDescription({
|
|
| 83 |
return (
|
| 84 |
<AlertDialogPrimitive.Description
|
| 85 |
data-slot='alert-dialog-description'
|
| 86 |
-
className={cn('text-
|
| 87 |
{...props}
|
| 88 |
/>
|
| 89 |
)
|
|
@@ -93,12 +85,7 @@ function AlertDialogAction({
|
|
| 93 |
className,
|
| 94 |
...props
|
| 95 |
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
| 96 |
-
return (
|
| 97 |
-
<AlertDialogPrimitive.Action
|
| 98 |
-
className={cn(buttonVariants(), className)}
|
| 99 |
-
{...props}
|
| 100 |
-
/>
|
| 101 |
-
)
|
| 102 |
}
|
| 103 |
|
| 104 |
function AlertDialogCancel({
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
|
|
|
| 6 |
import { buttonVariants } from '@/components/ui/button'
|
| 7 |
+
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
+
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
|
|
|
|
|
|
| 10 |
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
|
| 11 |
}
|
| 12 |
|
| 13 |
function AlertDialogTrigger({
|
| 14 |
...props
|
| 15 |
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
| 16 |
+
return <AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
+
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
| 20 |
+
return <AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
function AlertDialogOverlay({
|
|
|
|
| 28 |
<AlertDialogPrimitive.Overlay
|
| 29 |
data-slot='alert-dialog-overlay'
|
| 30 |
className={cn(
|
| 31 |
+
'fixed inset-0 z-50 bg-black/40 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
| 32 |
className,
|
| 33 |
)}
|
| 34 |
{...props}
|
|
|
|
| 46 |
<AlertDialogPrimitive.Content
|
| 47 |
data-slot='alert-dialog-content'
|
| 48 |
className={cn(
|
| 49 |
+
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 50 |
className,
|
| 51 |
)}
|
| 52 |
{...props}
|
|
|
|
| 62 |
return (
|
| 63 |
<AlertDialogPrimitive.Title
|
| 64 |
data-slot='alert-dialog-title'
|
| 65 |
+
className={cn('text-sm font-semibold text-foreground', className)}
|
| 66 |
{...props}
|
| 67 |
/>
|
| 68 |
)
|
|
|
|
| 75 |
return (
|
| 76 |
<AlertDialogPrimitive.Description
|
| 77 |
data-slot='alert-dialog-description'
|
| 78 |
+
className={cn('text-sm text-muted-foreground', className)}
|
| 79 |
{...props}
|
| 80 |
/>
|
| 81 |
)
|
|
|
|
| 85 |
className,
|
| 86 |
...props
|
| 87 |
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
| 88 |
+
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
function AlertDialogCancel({
|
ui/components/ui/button.tsx
CHANGED
|
@@ -1,29 +1,27 @@
|
|
| 1 |
-
import * as React from 'react'
|
| 2 |
import { Slot } from '@radix-ui/react-slot'
|
| 3 |
import { cva, type VariantProps } from 'class-variance-authority'
|
|
|
|
| 4 |
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
| 7 |
const buttonVariants = cva(
|
| 8 |
-
"inline-flex items-center justify-center gap-2
|
| 9 |
{
|
| 10 |
variants: {
|
| 11 |
variant: {
|
| 12 |
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
| 13 |
destructive:
|
| 14 |
-
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:
|
| 15 |
outline:
|
| 16 |
-
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:
|
| 17 |
-
secondary:
|
| 18 |
-
|
| 19 |
-
ghost:
|
| 20 |
-
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
| 21 |
link: 'text-primary underline-offset-4 hover:underline',
|
| 22 |
},
|
| 23 |
size: {
|
| 24 |
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
| 25 |
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
| 26 |
-
sm: 'h-8
|
| 27 |
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
| 28 |
icon: 'size-9',
|
| 29 |
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
|
|
|
|
|
|
| 1 |
import { Slot } from '@radix-ui/react-slot'
|
| 2 |
import { cva, type VariantProps } from 'class-variance-authority'
|
| 3 |
+
import * as React from 'react'
|
| 4 |
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
| 7 |
const buttonVariants = cva(
|
| 8 |
+
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 9 |
{
|
| 10 |
variants: {
|
| 11 |
variant: {
|
| 12 |
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
| 13 |
destructive:
|
| 14 |
+
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
| 15 |
outline:
|
| 16 |
+
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
| 17 |
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
| 18 |
+
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
|
|
|
|
|
| 19 |
link: 'text-primary underline-offset-4 hover:underline',
|
| 20 |
},
|
| 21 |
size: {
|
| 22 |
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
| 23 |
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
| 24 |
+
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
| 25 |
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
| 26 |
icon: 'size-9',
|
| 27 |
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
ui/components/ui/color-picker.tsx
CHANGED
|
@@ -2,12 +2,9 @@
|
|
| 2 |
|
| 3 |
import { useRef, useState, useCallback, useEffect } from 'react'
|
| 4 |
import { HexColorInput, HexColorPicker } from 'react-colorful'
|
|
|
|
| 5 |
import { Button } from '@/components/ui/button'
|
| 6 |
-
import {
|
| 7 |
-
Popover,
|
| 8 |
-
PopoverContent,
|
| 9 |
-
PopoverTrigger,
|
| 10 |
-
} from '@/components/ui/popover'
|
| 11 |
import { cn } from '@/lib/utils'
|
| 12 |
|
| 13 |
type ColorPickerProps = {
|
|
@@ -60,8 +57,7 @@ export function ColorPicker({
|
|
| 60 |
}, [localColor, onChange])
|
| 61 |
|
| 62 |
const canUseEyeDropper =
|
| 63 |
-
typeof window !== 'undefined' &&
|
| 64 |
-
typeof (window as EyeDropperWindow).EyeDropper === 'function'
|
| 65 |
|
| 66 |
const handlePickFromScreen = async () => {
|
| 67 |
const EyeDropperCtor = (window as EyeDropperWindow).EyeDropper
|
|
@@ -87,7 +83,7 @@ export function ColorPicker({
|
|
| 87 |
data-testid={triggerTestId}
|
| 88 |
disabled={disabled}
|
| 89 |
className={cn(
|
| 90 |
-
'
|
| 91 |
className,
|
| 92 |
)}
|
| 93 |
>
|
|
@@ -120,7 +116,7 @@ export function ColorPicker({
|
|
| 120 |
spellCheck={false}
|
| 121 |
disabled={disabled}
|
| 122 |
aria-label='Hex color code'
|
| 123 |
-
className='
|
| 124 |
onChange={(color) => {
|
| 125 |
const normalized = normalizeHex(color)
|
| 126 |
setLocalColor(normalized)
|
|
|
|
| 2 |
|
| 3 |
import { useRef, useState, useCallback, useEffect } from 'react'
|
| 4 |
import { HexColorInput, HexColorPicker } from 'react-colorful'
|
| 5 |
+
|
| 6 |
import { Button } from '@/components/ui/button'
|
| 7 |
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import { cn } from '@/lib/utils'
|
| 9 |
|
| 10 |
type ColorPickerProps = {
|
|
|
|
| 57 |
}, [localColor, onChange])
|
| 58 |
|
| 59 |
const canUseEyeDropper =
|
| 60 |
+
typeof window !== 'undefined' && typeof (window as EyeDropperWindow).EyeDropper === 'function'
|
|
|
|
| 61 |
|
| 62 |
const handlePickFromScreen = async () => {
|
| 63 |
const EyeDropperCtor = (window as EyeDropperWindow).EyeDropper
|
|
|
|
| 83 |
data-testid={triggerTestId}
|
| 84 |
disabled={disabled}
|
| 85 |
className={cn(
|
| 86 |
+
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-input transition hover:border-border disabled:cursor-not-allowed disabled:opacity-50',
|
| 87 |
className,
|
| 88 |
)}
|
| 89 |
>
|
|
|
|
| 116 |
spellCheck={false}
|
| 117 |
disabled={disabled}
|
| 118 |
aria-label='Hex color code'
|
| 119 |
+
className='h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 font-mono text-xs uppercase shadow-xs transition outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
|
| 120 |
onChange={(color) => {
|
| 121 |
const normalized = normalizeHex(color)
|
| 122 |
setLocalColor(normalized)
|
ui/components/ui/context-menu.tsx
CHANGED
|
@@ -1,56 +1,37 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
| 5 |
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
|
|
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
-
function ContextMenu({
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
| 12 |
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />
|
| 13 |
}
|
| 14 |
|
| 15 |
function ContextMenuTrigger({
|
| 16 |
...props
|
| 17 |
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
| 18 |
-
return
|
| 19 |
-
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
|
| 20 |
-
)
|
| 21 |
}
|
| 22 |
|
| 23 |
-
function ContextMenuGroup({
|
| 24 |
-
...props
|
| 25 |
-
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
| 26 |
-
return (
|
| 27 |
-
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
|
| 28 |
-
)
|
| 29 |
}
|
| 30 |
|
| 31 |
-
function ContextMenuPortal({
|
| 32 |
-
...props
|
| 33 |
-
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
| 34 |
-
return (
|
| 35 |
-
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
|
| 36 |
-
)
|
| 37 |
}
|
| 38 |
|
| 39 |
-
function ContextMenuSub({
|
| 40 |
-
...props
|
| 41 |
-
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
| 42 |
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />
|
| 43 |
}
|
| 44 |
|
| 45 |
function ContextMenuRadioGroup({
|
| 46 |
...props
|
| 47 |
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
| 48 |
-
return
|
| 49 |
-
<ContextMenuPrimitive.RadioGroup
|
| 50 |
-
data-slot='context-menu-radio-group'
|
| 51 |
-
{...props}
|
| 52 |
-
/>
|
| 53 |
-
)
|
| 54 |
}
|
| 55 |
|
| 56 |
function ContextMenuSubTrigger({
|
|
@@ -66,7 +47,7 @@ function ContextMenuSubTrigger({
|
|
| 66 |
data-slot='context-menu-sub-trigger'
|
| 67 |
data-inset={inset}
|
| 68 |
className={cn(
|
| 69 |
-
"
|
| 70 |
className,
|
| 71 |
)}
|
| 72 |
{...props}
|
|
@@ -85,7 +66,7 @@ function ContextMenuSubContent({
|
|
| 85 |
<ContextMenuPrimitive.SubContent
|
| 86 |
data-slot='context-menu-sub-content'
|
| 87 |
className={cn(
|
| 88 |
-
'
|
| 89 |
className,
|
| 90 |
)}
|
| 91 |
{...props}
|
|
@@ -102,7 +83,7 @@ function ContextMenuContent({
|
|
| 102 |
<ContextMenuPrimitive.Content
|
| 103 |
data-slot='context-menu-content'
|
| 104 |
className={cn(
|
| 105 |
-
'
|
| 106 |
className,
|
| 107 |
)}
|
| 108 |
{...props}
|
|
@@ -126,7 +107,7 @@ function ContextMenuItem({
|
|
| 126 |
data-inset={inset}
|
| 127 |
data-variant={variant}
|
| 128 |
className={cn(
|
| 129 |
-
"focus:bg-accent focus:text-accent-foreground data-[
|
| 130 |
className,
|
| 131 |
)}
|
| 132 |
{...props}
|
|
@@ -144,7 +125,7 @@ function ContextMenuCheckboxItem({
|
|
| 144 |
<ContextMenuPrimitive.CheckboxItem
|
| 145 |
data-slot='context-menu-checkbox-item'
|
| 146 |
className={cn(
|
| 147 |
-
"
|
| 148 |
className,
|
| 149 |
)}
|
| 150 |
checked={checked}
|
|
@@ -169,7 +150,7 @@ function ContextMenuRadioItem({
|
|
| 169 |
<ContextMenuPrimitive.RadioItem
|
| 170 |
data-slot='context-menu-radio-item'
|
| 171 |
className={cn(
|
| 172 |
-
"
|
| 173 |
className,
|
| 174 |
)}
|
| 175 |
{...props}
|
|
@@ -195,10 +176,7 @@ function ContextMenuLabel({
|
|
| 195 |
<ContextMenuPrimitive.Label
|
| 196 |
data-slot='context-menu-label'
|
| 197 |
data-inset={inset}
|
| 198 |
-
className={cn(
|
| 199 |
-
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
| 200 |
-
className,
|
| 201 |
-
)}
|
| 202 |
{...props}
|
| 203 |
/>
|
| 204 |
)
|
|
@@ -211,23 +189,17 @@ function ContextMenuSeparator({
|
|
| 211 |
return (
|
| 212 |
<ContextMenuPrimitive.Separator
|
| 213 |
data-slot='context-menu-separator'
|
| 214 |
-
className={cn('
|
| 215 |
{...props}
|
| 216 |
/>
|
| 217 |
)
|
| 218 |
}
|
| 219 |
|
| 220 |
-
function ContextMenuShortcut({
|
| 221 |
-
className,
|
| 222 |
-
...props
|
| 223 |
-
}: React.ComponentProps<'span'>) {
|
| 224 |
return (
|
| 225 |
<span
|
| 226 |
data-slot='context-menu-shortcut'
|
| 227 |
-
className={cn(
|
| 228 |
-
'text-muted-foreground ml-auto text-xs tracking-widest',
|
| 229 |
-
className,
|
| 230 |
-
)}
|
| 231 |
{...props}
|
| 232 |
/>
|
| 233 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
| 4 |
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
| 5 |
+
import * as React from 'react'
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
+
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
|
|
|
|
|
|
| 10 |
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />
|
| 11 |
}
|
| 12 |
|
| 13 |
function ContextMenuTrigger({
|
| 14 |
...props
|
| 15 |
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
| 16 |
+
return <ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
+
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
| 20 |
+
return <ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
+
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
| 24 |
+
return <ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
+
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
|
|
|
|
|
|
| 28 |
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />
|
| 29 |
}
|
| 30 |
|
| 31 |
function ContextMenuRadioGroup({
|
| 32 |
...props
|
| 33 |
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
| 34 |
+
return <ContextMenuPrimitive.RadioGroup data-slot='context-menu-radio-group' {...props} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
function ContextMenuSubTrigger({
|
|
|
|
| 47 |
data-slot='context-menu-sub-trigger'
|
| 48 |
data-inset={inset}
|
| 49 |
className={cn(
|
| 50 |
+
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
| 51 |
className,
|
| 52 |
)}
|
| 53 |
{...props}
|
|
|
|
| 66 |
<ContextMenuPrimitive.SubContent
|
| 67 |
data-slot='context-menu-sub-content'
|
| 68 |
className={cn(
|
| 69 |
+
'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 70 |
className,
|
| 71 |
)}
|
| 72 |
{...props}
|
|
|
|
| 83 |
<ContextMenuPrimitive.Content
|
| 84 |
data-slot='context-menu-content'
|
| 85 |
className={cn(
|
| 86 |
+
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 87 |
className,
|
| 88 |
)}
|
| 89 |
{...props}
|
|
|
|
| 107 |
data-inset={inset}
|
| 108 |
data-variant={variant}
|
| 109 |
className={cn(
|
| 110 |
+
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
|
| 111 |
className,
|
| 112 |
)}
|
| 113 |
{...props}
|
|
|
|
| 125 |
<ContextMenuPrimitive.CheckboxItem
|
| 126 |
data-slot='context-menu-checkbox-item'
|
| 127 |
className={cn(
|
| 128 |
+
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 129 |
className,
|
| 130 |
)}
|
| 131 |
checked={checked}
|
|
|
|
| 150 |
<ContextMenuPrimitive.RadioItem
|
| 151 |
data-slot='context-menu-radio-item'
|
| 152 |
className={cn(
|
| 153 |
+
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 154 |
className,
|
| 155 |
)}
|
| 156 |
{...props}
|
|
|
|
| 176 |
<ContextMenuPrimitive.Label
|
| 177 |
data-slot='context-menu-label'
|
| 178 |
data-inset={inset}
|
| 179 |
+
className={cn('px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8', className)}
|
|
|
|
|
|
|
|
|
|
| 180 |
{...props}
|
| 181 |
/>
|
| 182 |
)
|
|
|
|
| 189 |
return (
|
| 190 |
<ContextMenuPrimitive.Separator
|
| 191 |
data-slot='context-menu-separator'
|
| 192 |
+
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
| 193 |
{...props}
|
| 194 |
/>
|
| 195 |
)
|
| 196 |
}
|
| 197 |
|
| 198 |
+
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
|
|
|
|
|
|
|
|
|
| 199 |
return (
|
| 200 |
<span
|
| 201 |
data-slot='context-menu-shortcut'
|
| 202 |
+
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
|
|
|
|
|
|
|
|
|
| 203 |
{...props}
|
| 204 |
/>
|
| 205 |
)
|
ui/components/ui/dialog.tsx
CHANGED
|
@@ -1,24 +1,19 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
|
|
|
|
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
| 7 |
-
function Dialog({
|
| 8 |
-
...props
|
| 9 |
-
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
| 10 |
return <DialogPrimitive.Root data-slot='dialog' {...props} />
|
| 11 |
}
|
| 12 |
|
| 13 |
-
function DialogTrigger({
|
| 14 |
-
...props
|
| 15 |
-
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
| 16 |
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
|
| 17 |
}
|
| 18 |
|
| 19 |
-
function DialogPortal({
|
| 20 |
-
...props
|
| 21 |
-
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
| 22 |
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
|
| 23 |
}
|
| 24 |
|
|
@@ -30,7 +25,7 @@ function DialogOverlay({
|
|
| 30 |
<DialogPrimitive.Overlay
|
| 31 |
data-slot='dialog-overlay'
|
| 32 |
className={cn(
|
| 33 |
-
'
|
| 34 |
className,
|
| 35 |
)}
|
| 36 |
{...props}
|
|
@@ -49,7 +44,7 @@ function DialogContent({
|
|
| 49 |
<DialogPrimitive.Content
|
| 50 |
data-slot='dialog-content'
|
| 51 |
className={cn(
|
| 52 |
-
'bg-background data-[state=
|
| 53 |
className,
|
| 54 |
)}
|
| 55 |
{...props}
|
|
@@ -60,14 +55,11 @@ function DialogContent({
|
|
| 60 |
)
|
| 61 |
}
|
| 62 |
|
| 63 |
-
function DialogTitle({
|
| 64 |
-
className,
|
| 65 |
-
...props
|
| 66 |
-
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 67 |
return (
|
| 68 |
<DialogPrimitive.Title
|
| 69 |
data-slot='dialog-title'
|
| 70 |
-
className={cn('text-
|
| 71 |
{...props}
|
| 72 |
/>
|
| 73 |
)
|
|
@@ -80,7 +72,7 @@ function DialogDescription({
|
|
| 80 |
return (
|
| 81 |
<DialogPrimitive.Description
|
| 82 |
data-slot='dialog-description'
|
| 83 |
-
className={cn('text-
|
| 84 |
{...props}
|
| 85 |
/>
|
| 86 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
+
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
| 8 |
+
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
|
|
|
|
|
| 9 |
return <DialogPrimitive.Root data-slot='dialog' {...props} />
|
| 10 |
}
|
| 11 |
|
| 12 |
+
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
|
|
|
|
|
| 13 |
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
|
| 14 |
}
|
| 15 |
|
| 16 |
+
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
|
|
|
|
|
| 17 |
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
|
| 18 |
}
|
| 19 |
|
|
|
|
| 25 |
<DialogPrimitive.Overlay
|
| 26 |
data-slot='dialog-overlay'
|
| 27 |
className={cn(
|
| 28 |
+
'fixed inset-0 z-50 bg-black/40 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
| 29 |
className,
|
| 30 |
)}
|
| 31 |
{...props}
|
|
|
|
| 44 |
<DialogPrimitive.Content
|
| 45 |
data-slot='dialog-content'
|
| 46 |
className={cn(
|
| 47 |
+
'fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 48 |
className,
|
| 49 |
)}
|
| 50 |
{...props}
|
|
|
|
| 55 |
)
|
| 56 |
}
|
| 57 |
|
| 58 |
+
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
|
|
|
|
|
|
|
|
| 59 |
return (
|
| 60 |
<DialogPrimitive.Title
|
| 61 |
data-slot='dialog-title'
|
| 62 |
+
className={cn('text-sm font-semibold text-foreground', className)}
|
| 63 |
{...props}
|
| 64 |
/>
|
| 65 |
)
|
|
|
|
| 72 |
return (
|
| 73 |
<DialogPrimitive.Description
|
| 74 |
data-slot='dialog-description'
|
| 75 |
+
className={cn('text-xs text-muted-foreground', className)}
|
| 76 |
{...props}
|
| 77 |
/>
|
| 78 |
)
|
ui/components/ui/draft-textarea.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import * as React from 'react'
|
| 4 |
import { useEffect, useRef, useState } from 'react'
|
|
|
|
| 5 |
import { Textarea } from '@/components/ui/textarea'
|
| 6 |
|
| 7 |
export type DraftTextareaProps = Omit<
|
|
|
|
| 2 |
|
| 3 |
import * as React from 'react'
|
| 4 |
import { useEffect, useRef, useState } from 'react'
|
| 5 |
+
|
| 6 |
import { Textarea } from '@/components/ui/textarea'
|
| 7 |
|
| 8 |
export type DraftTextareaProps = Omit<
|
ui/components/ui/font-select.tsx
CHANGED
|
@@ -1,16 +1,13 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
| 4 |
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 5 |
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
|
| 6 |
-
import {
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
PopoverTrigger,
|
| 10 |
-
} from '@/components/ui/popover'
|
| 11 |
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
| 12 |
-
import { cn } from '@/lib/utils'
|
| 13 |
import { fetchGoogleFont } from '@/lib/api/system/system'
|
|
|
|
| 14 |
|
| 15 |
const ITEM_HEIGHT = 28
|
| 16 |
const MAX_VISIBLE = 10
|
|
@@ -37,14 +34,8 @@ type FontSelectProps = {
|
|
| 37 |
'data-testid'?: string
|
| 38 |
}
|
| 39 |
|
| 40 |
-
function useGoogleFontPreview(
|
| 41 |
-
|
| 42 |
-
source: string,
|
| 43 |
-
isVisible: boolean,
|
| 44 |
-
) {
|
| 45 |
-
const [state, setState] = useState<FontLoadState>(
|
| 46 |
-
source === 'system' ? 'ready' : 'idle',
|
| 47 |
-
)
|
| 48 |
const stateRef = useRef(state)
|
| 49 |
stateRef.current = state
|
| 50 |
|
|
@@ -92,17 +83,13 @@ function FontRow({
|
|
| 92 |
isVisible: boolean
|
| 93 |
onClick: () => void
|
| 94 |
}) {
|
| 95 |
-
const loadState = useGoogleFontPreview(
|
| 96 |
-
font.familyName,
|
| 97 |
-
font.source,
|
| 98 |
-
isVisible,
|
| 99 |
-
)
|
| 100 |
|
| 101 |
return (
|
| 102 |
<button
|
| 103 |
type='button'
|
| 104 |
className={cn(
|
| 105 |
-
'
|
| 106 |
selected && 'bg-accent',
|
| 107 |
)}
|
| 108 |
style={{
|
|
@@ -116,7 +103,7 @@ function FontRow({
|
|
| 116 |
</span>
|
| 117 |
<span className='truncate'>{font.familyName}</span>
|
| 118 |
{font.source === 'google' && (
|
| 119 |
-
<span className='
|
| 120 |
{loadState === 'loading' ? '...' : 'G'}
|
| 121 |
</span>
|
| 122 |
)}
|
|
@@ -144,9 +131,7 @@ export function FontSelect({
|
|
| 144 |
const filtered = useMemo(() => {
|
| 145 |
let result = options
|
| 146 |
if (categoryFilter) {
|
| 147 |
-
result = result.filter(
|
| 148 |
-
(f) => f.source === 'system' || f.category === categoryFilter,
|
| 149 |
-
)
|
| 150 |
}
|
| 151 |
if (search) {
|
| 152 |
const lower = search.toLowerCase()
|
|
@@ -190,7 +175,7 @@ export function FontSelect({
|
|
| 190 |
disabled={disabled}
|
| 191 |
data-testid={props['data-testid']}
|
| 192 |
className={cn(
|
| 193 |
-
"
|
| 194 |
triggerClassName,
|
| 195 |
)}
|
| 196 |
style={triggerStyle}
|
|
@@ -211,49 +196,33 @@ export function FontSelect({
|
|
| 211 |
value={search}
|
| 212 |
onChange={(e) => setSearch(e.target.value)}
|
| 213 |
placeholder='Search fonts…'
|
| 214 |
-
className='
|
| 215 |
/>
|
| 216 |
<ScrollArea className='border-b'>
|
| 217 |
<div className='flex gap-0.5 px-1.5 py-1'>
|
| 218 |
-
{['all', 'hand', 'display', 'sans', 'serif', 'mono'].map(
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
'
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
? 'bg-primary text-primary-foreground'
|
| 238 |
-
: 'bg-muted text-muted-foreground hover:bg-accent',
|
| 239 |
-
)}
|
| 240 |
-
onClick={() =>
|
| 241 |
-
setCategoryFilter(cat === 'all' ? null : full)
|
| 242 |
-
}
|
| 243 |
-
>
|
| 244 |
-
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
| 245 |
-
</button>
|
| 246 |
-
)
|
| 247 |
-
},
|
| 248 |
-
)}
|
| 249 |
</div>
|
| 250 |
<ScrollBar orientation='horizontal' />
|
| 251 |
</ScrollArea>
|
| 252 |
-
<ScrollArea
|
| 253 |
-
className='relative'
|
| 254 |
-
style={{ height: listHeight }}
|
| 255 |
-
viewportRef={viewportRef}
|
| 256 |
-
>
|
| 257 |
<div
|
| 258 |
style={{
|
| 259 |
height: virtualizer.getTotalSize(),
|
|
@@ -262,8 +231,7 @@ export function FontSelect({
|
|
| 262 |
>
|
| 263 |
{virtualizer.getVirtualItems().map((vi) => {
|
| 264 |
const font = filtered[vi.index]
|
| 265 |
-
const selected =
|
| 266 |
-
font.postScriptName === value || font.familyName === value
|
| 267 |
return (
|
| 268 |
<FontRow
|
| 269 |
key={vi.key}
|
|
@@ -282,9 +250,7 @@ export function FontSelect({
|
|
| 282 |
</div>
|
| 283 |
</ScrollArea>
|
| 284 |
{filtered.length === 0 && (
|
| 285 |
-
<div className='
|
| 286 |
-
No fonts found
|
| 287 |
-
</div>
|
| 288 |
)}
|
| 289 |
</PopoverContent>
|
| 290 |
</Popover>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 4 |
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
|
| 5 |
+
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
| 6 |
+
|
| 7 |
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
|
|
|
|
|
| 8 |
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
|
|
|
| 9 |
import { fetchGoogleFont } from '@/lib/api/system/system'
|
| 10 |
+
import { cn } from '@/lib/utils'
|
| 11 |
|
| 12 |
const ITEM_HEIGHT = 28
|
| 13 |
const MAX_VISIBLE = 10
|
|
|
|
| 34 |
'data-testid'?: string
|
| 35 |
}
|
| 36 |
|
| 37 |
+
function useGoogleFontPreview(family: string, source: string, isVisible: boolean) {
|
| 38 |
+
const [state, setState] = useState<FontLoadState>(source === 'system' ? 'ready' : 'idle')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
const stateRef = useRef(state)
|
| 40 |
stateRef.current = state
|
| 41 |
|
|
|
|
| 83 |
isVisible: boolean
|
| 84 |
onClick: () => void
|
| 85 |
}) {
|
| 86 |
+
const loadState = useGoogleFontPreview(font.familyName, font.source, isVisible)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
return (
|
| 89 |
<button
|
| 90 |
type='button'
|
| 91 |
className={cn(
|
| 92 |
+
'absolute left-0 flex w-full cursor-default items-center gap-1.5 rounded-sm px-2 text-xs select-none hover:bg-accent hover:text-accent-foreground',
|
| 93 |
selected && 'bg-accent',
|
| 94 |
)}
|
| 95 |
style={{
|
|
|
|
| 103 |
</span>
|
| 104 |
<span className='truncate'>{font.familyName}</span>
|
| 105 |
{font.source === 'google' && (
|
| 106 |
+
<span className='ml-auto shrink-0 text-[9px] text-muted-foreground'>
|
| 107 |
{loadState === 'loading' ? '...' : 'G'}
|
| 108 |
</span>
|
| 109 |
)}
|
|
|
|
| 131 |
const filtered = useMemo(() => {
|
| 132 |
let result = options
|
| 133 |
if (categoryFilter) {
|
| 134 |
+
result = result.filter((f) => f.source === 'system' || f.category === categoryFilter)
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
if (search) {
|
| 137 |
const lower = search.toLowerCase()
|
|
|
|
| 175 |
disabled={disabled}
|
| 176 |
data-testid={props['data-testid']}
|
| 177 |
className={cn(
|
| 178 |
+
"flex h-7 w-full items-center justify-between gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
| 179 |
triggerClassName,
|
| 180 |
)}
|
| 181 |
style={triggerStyle}
|
|
|
|
| 196 |
value={search}
|
| 197 |
onChange={(e) => setSearch(e.target.value)}
|
| 198 |
placeholder='Search fonts…'
|
| 199 |
+
className='w-full border-b bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground'
|
| 200 |
/>
|
| 201 |
<ScrollArea className='border-b'>
|
| 202 |
<div className='flex gap-0.5 px-1.5 py-1'>
|
| 203 |
+
{['all', 'hand', 'display', 'sans', 'serif', 'mono'].map((cat, i) => {
|
| 204 |
+
const full = ['all', 'handwriting', 'display', 'sans-serif', 'serif', 'monospace'][i]
|
| 205 |
+
const active = cat === 'all' ? !categoryFilter : categoryFilter === full
|
| 206 |
+
return (
|
| 207 |
+
<button
|
| 208 |
+
key={cat}
|
| 209 |
+
type='button'
|
| 210 |
+
className={cn(
|
| 211 |
+
'shrink-0 rounded-full px-1.5 py-px text-[9px]',
|
| 212 |
+
active
|
| 213 |
+
? 'bg-primary text-primary-foreground'
|
| 214 |
+
: 'bg-muted text-muted-foreground hover:bg-accent',
|
| 215 |
+
)}
|
| 216 |
+
onClick={() => setCategoryFilter(cat === 'all' ? null : full)}
|
| 217 |
+
>
|
| 218 |
+
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
| 219 |
+
</button>
|
| 220 |
+
)
|
| 221 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
</div>
|
| 223 |
<ScrollBar orientation='horizontal' />
|
| 224 |
</ScrollArea>
|
| 225 |
+
<ScrollArea className='relative' style={{ height: listHeight }} viewportRef={viewportRef}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
<div
|
| 227 |
style={{
|
| 228 |
height: virtualizer.getTotalSize(),
|
|
|
|
| 231 |
>
|
| 232 |
{virtualizer.getVirtualItems().map((vi) => {
|
| 233 |
const font = filtered[vi.index]
|
| 234 |
+
const selected = font.postScriptName === value || font.familyName === value
|
|
|
|
| 235 |
return (
|
| 236 |
<FontRow
|
| 237 |
key={vi.key}
|
|
|
|
| 250 |
</div>
|
| 251 |
</ScrollArea>
|
| 252 |
{filtered.length === 0 && (
|
| 253 |
+
<div className='px-2 py-4 text-center text-xs text-muted-foreground'>No fonts found</div>
|
|
|
|
|
|
|
| 254 |
)}
|
| 255 |
</PopoverContent>
|
| 256 |
</Popover>
|
ui/components/ui/input.tsx
CHANGED
|
@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|
| 8 |
type={type}
|
| 9 |
data-slot='input'
|
| 10 |
className={cn(
|
| 11 |
-
'
|
| 12 |
-
'focus-visible:border-ring focus-visible:ring-
|
| 13 |
-
'aria-invalid:
|
| 14 |
className,
|
| 15 |
)}
|
| 16 |
{...props}
|
|
|
|
| 8 |
type={type}
|
| 9 |
data-slot='input'
|
| 10 |
className={cn(
|
| 11 |
+
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
| 12 |
+
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
| 13 |
+
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
| 14 |
className,
|
| 15 |
)}
|
| 16 |
{...props}
|
ui/components/ui/label.tsx
CHANGED
|
@@ -1,14 +1,11 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
|
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
| 8 |
-
function Label({
|
| 9 |
-
className,
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
| 12 |
return (
|
| 13 |
<LabelPrimitive.Root
|
| 14 |
data-slot='label'
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as LabelPrimitive from '@radix-ui/react-label'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
| 8 |
+
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
|
|
|
|
|
|
|
|
| 9 |
return (
|
| 10 |
<LabelPrimitive.Root
|
| 11 |
data-slot='label'
|
ui/components/ui/menubar.tsx
CHANGED
|
@@ -1,20 +1,17 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as MenubarPrimitive from '@radix-ui/react-menubar'
|
| 5 |
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
|
|
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
-
function Menubar({
|
| 10 |
-
className,
|
| 11 |
-
...props
|
| 12 |
-
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
| 13 |
return (
|
| 14 |
<MenubarPrimitive.Root
|
| 15 |
data-slot='menubar'
|
| 16 |
className={cn(
|
| 17 |
-
'
|
| 18 |
className,
|
| 19 |
)}
|
| 20 |
{...props}
|
|
@@ -22,30 +19,20 @@ function Menubar({
|
|
| 22 |
)
|
| 23 |
}
|
| 24 |
|
| 25 |
-
function MenubarMenu({
|
| 26 |
-
...props
|
| 27 |
-
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
| 28 |
return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />
|
| 29 |
}
|
| 30 |
|
| 31 |
-
function MenubarGroup({
|
| 32 |
-
...props
|
| 33 |
-
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
| 34 |
return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />
|
| 35 |
}
|
| 36 |
|
| 37 |
-
function MenubarPortal({
|
| 38 |
-
...props
|
| 39 |
-
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
| 40 |
return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />
|
| 41 |
}
|
| 42 |
|
| 43 |
-
function MenubarRadioGroup({
|
| 44 |
-
...props
|
| 45 |
-
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
| 46 |
-
return (
|
| 47 |
-
<MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
|
| 48 |
-
)
|
| 49 |
}
|
| 50 |
|
| 51 |
function MenubarTrigger({
|
|
@@ -56,7 +43,7 @@ function MenubarTrigger({
|
|
| 56 |
<MenubarPrimitive.Trigger
|
| 57 |
data-slot='menubar-trigger'
|
| 58 |
className={cn(
|
| 59 |
-
'
|
| 60 |
className,
|
| 61 |
)}
|
| 62 |
{...props}
|
|
@@ -79,7 +66,7 @@ function MenubarContent({
|
|
| 79 |
alignOffset={alignOffset}
|
| 80 |
sideOffset={sideOffset}
|
| 81 |
className={cn(
|
| 82 |
-
'
|
| 83 |
className,
|
| 84 |
)}
|
| 85 |
{...props}
|
|
@@ -103,7 +90,7 @@ function MenubarItem({
|
|
| 103 |
data-inset={inset}
|
| 104 |
data-variant={variant}
|
| 105 |
className={cn(
|
| 106 |
-
"focus:bg-accent focus:text-accent-foreground data-[
|
| 107 |
className,
|
| 108 |
)}
|
| 109 |
{...props}
|
|
@@ -121,7 +108,7 @@ function MenubarCheckboxItem({
|
|
| 121 |
<MenubarPrimitive.CheckboxItem
|
| 122 |
data-slot='menubar-checkbox-item'
|
| 123 |
className={cn(
|
| 124 |
-
"
|
| 125 |
className,
|
| 126 |
)}
|
| 127 |
checked={checked}
|
|
@@ -146,7 +133,7 @@ function MenubarRadioItem({
|
|
| 146 |
<MenubarPrimitive.RadioItem
|
| 147 |
data-slot='menubar-radio-item'
|
| 148 |
className={cn(
|
| 149 |
-
"
|
| 150 |
className,
|
| 151 |
)}
|
| 152 |
{...props}
|
|
@@ -172,10 +159,7 @@ function MenubarLabel({
|
|
| 172 |
<MenubarPrimitive.Label
|
| 173 |
data-slot='menubar-label'
|
| 174 |
data-inset={inset}
|
| 175 |
-
className={cn(
|
| 176 |
-
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
| 177 |
-
className,
|
| 178 |
-
)}
|
| 179 |
{...props}
|
| 180 |
/>
|
| 181 |
)
|
|
@@ -188,31 +172,23 @@ function MenubarSeparator({
|
|
| 188 |
return (
|
| 189 |
<MenubarPrimitive.Separator
|
| 190 |
data-slot='menubar-separator'
|
| 191 |
-
className={cn('
|
| 192 |
{...props}
|
| 193 |
/>
|
| 194 |
)
|
| 195 |
}
|
| 196 |
|
| 197 |
-
function MenubarShortcut({
|
| 198 |
-
className,
|
| 199 |
-
...props
|
| 200 |
-
}: React.ComponentProps<'span'>) {
|
| 201 |
return (
|
| 202 |
<span
|
| 203 |
data-slot='menubar-shortcut'
|
| 204 |
-
className={cn(
|
| 205 |
-
'text-muted-foreground ml-auto text-xs tracking-widest',
|
| 206 |
-
className,
|
| 207 |
-
)}
|
| 208 |
{...props}
|
| 209 |
/>
|
| 210 |
)
|
| 211 |
}
|
| 212 |
|
| 213 |
-
function MenubarSub({
|
| 214 |
-
...props
|
| 215 |
-
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
| 216 |
return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />
|
| 217 |
}
|
| 218 |
|
|
@@ -229,7 +205,7 @@ function MenubarSubTrigger({
|
|
| 229 |
data-slot='menubar-sub-trigger'
|
| 230 |
data-inset={inset}
|
| 231 |
className={cn(
|
| 232 |
-
'
|
| 233 |
className,
|
| 234 |
)}
|
| 235 |
{...props}
|
|
@@ -248,7 +224,7 @@ function MenubarSubContent({
|
|
| 248 |
<MenubarPrimitive.SubContent
|
| 249 |
data-slot='menubar-sub-content'
|
| 250 |
className={cn(
|
| 251 |
-
'
|
| 252 |
className,
|
| 253 |
)}
|
| 254 |
{...props}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as MenubarPrimitive from '@radix-ui/react-menubar'
|
| 4 |
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
| 5 |
+
import * as React from 'react'
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
+
function Menubar({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
|
|
|
|
|
|
|
|
|
| 10 |
return (
|
| 11 |
<MenubarPrimitive.Root
|
| 12 |
data-slot='menubar'
|
| 13 |
className={cn(
|
| 14 |
+
'flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs',
|
| 15 |
className,
|
| 16 |
)}
|
| 17 |
{...props}
|
|
|
|
| 19 |
)
|
| 20 |
}
|
| 21 |
|
| 22 |
+
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
|
|
|
|
|
|
| 23 |
return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />
|
| 24 |
}
|
| 25 |
|
| 26 |
+
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
|
|
|
|
|
|
| 27 |
return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />
|
| 28 |
}
|
| 29 |
|
| 30 |
+
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
|
|
|
|
|
|
| 31 |
return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />
|
| 32 |
}
|
| 33 |
|
| 34 |
+
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
| 35 |
+
return <MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
function MenubarTrigger({
|
|
|
|
| 43 |
<MenubarPrimitive.Trigger
|
| 44 |
data-slot='menubar-trigger'
|
| 45 |
className={cn(
|
| 46 |
+
'flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
| 47 |
className,
|
| 48 |
)}
|
| 49 |
{...props}
|
|
|
|
| 66 |
alignOffset={alignOffset}
|
| 67 |
sideOffset={sideOffset}
|
| 68 |
className={cn(
|
| 69 |
+
'z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 70 |
className,
|
| 71 |
)}
|
| 72 |
{...props}
|
|
|
|
| 90 |
data-inset={inset}
|
| 91 |
data-variant={variant}
|
| 92 |
className={cn(
|
| 93 |
+
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
|
| 94 |
className,
|
| 95 |
)}
|
| 96 |
{...props}
|
|
|
|
| 108 |
<MenubarPrimitive.CheckboxItem
|
| 109 |
data-slot='menubar-checkbox-item'
|
| 110 |
className={cn(
|
| 111 |
+
"relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 112 |
className,
|
| 113 |
)}
|
| 114 |
checked={checked}
|
|
|
|
| 133 |
<MenubarPrimitive.RadioItem
|
| 134 |
data-slot='menubar-radio-item'
|
| 135 |
className={cn(
|
| 136 |
+
"relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 137 |
className,
|
| 138 |
)}
|
| 139 |
{...props}
|
|
|
|
| 159 |
<MenubarPrimitive.Label
|
| 160 |
data-slot='menubar-label'
|
| 161 |
data-inset={inset}
|
| 162 |
+
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
|
|
|
|
|
|
|
|
|
| 163 |
{...props}
|
| 164 |
/>
|
| 165 |
)
|
|
|
|
| 172 |
return (
|
| 173 |
<MenubarPrimitive.Separator
|
| 174 |
data-slot='menubar-separator'
|
| 175 |
+
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
| 176 |
{...props}
|
| 177 |
/>
|
| 178 |
)
|
| 179 |
}
|
| 180 |
|
| 181 |
+
function MenubarShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
|
|
|
|
|
|
|
|
|
| 182 |
return (
|
| 183 |
<span
|
| 184 |
data-slot='menubar-shortcut'
|
| 185 |
+
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
|
|
|
|
|
|
|
|
|
| 186 |
{...props}
|
| 187 |
/>
|
| 188 |
)
|
| 189 |
}
|
| 190 |
|
| 191 |
+
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
|
|
|
|
|
|
| 192 |
return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />
|
| 193 |
}
|
| 194 |
|
|
|
|
| 205 |
data-slot='menubar-sub-trigger'
|
| 206 |
data-inset={inset}
|
| 207 |
className={cn(
|
| 208 |
+
'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
| 209 |
className,
|
| 210 |
)}
|
| 211 |
{...props}
|
|
|
|
| 224 |
<MenubarPrimitive.SubContent
|
| 225 |
data-slot='menubar-sub-content'
|
| 226 |
className={cn(
|
| 227 |
+
'z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 228 |
className,
|
| 229 |
)}
|
| 230 |
{...props}
|
ui/components/ui/popover.tsx
CHANGED
|
@@ -1,25 +1,19 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
|
|
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
| 8 |
-
function Popover({
|
| 9 |
-
...props
|
| 10 |
-
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
| 11 |
return <PopoverPrimitive.Root data-slot='popover' {...props} />
|
| 12 |
}
|
| 13 |
|
| 14 |
-
function PopoverTrigger({
|
| 15 |
-
...props
|
| 16 |
-
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
| 17 |
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
|
| 18 |
}
|
| 19 |
|
| 20 |
-
function PopoverAnchor({
|
| 21 |
-
...props
|
| 22 |
-
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
| 23 |
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />
|
| 24 |
}
|
| 25 |
|
|
@@ -36,7 +30,7 @@ function PopoverContent({
|
|
| 36 |
align={align}
|
| 37 |
sideOffset={sideOffset}
|
| 38 |
className={cn(
|
| 39 |
-
'
|
| 40 |
className,
|
| 41 |
)}
|
| 42 |
{...props}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
| 8 |
+
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
|
|
|
|
|
| 9 |
return <PopoverPrimitive.Root data-slot='popover' {...props} />
|
| 10 |
}
|
| 11 |
|
| 12 |
+
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
|
|
|
|
|
| 13 |
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
|
| 14 |
}
|
| 15 |
|
| 16 |
+
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
|
|
|
|
|
| 17 |
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />
|
| 18 |
}
|
| 19 |
|
|
|
|
| 30 |
align={align}
|
| 31 |
sideOffset={sideOffset}
|
| 32 |
className={cn(
|
| 33 |
+
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 34 |
className,
|
| 35 |
)}
|
| 36 |
{...props}
|
ui/components/ui/progress.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
|
|
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
@@ -13,15 +13,12 @@ function Progress({
|
|
| 13 |
return (
|
| 14 |
<ProgressPrimitive.Root
|
| 15 |
data-slot='progress'
|
| 16 |
-
className={cn(
|
| 17 |
-
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
| 18 |
-
className,
|
| 19 |
-
)}
|
| 20 |
{...props}
|
| 21 |
>
|
| 22 |
<ProgressPrimitive.Indicator
|
| 23 |
data-slot='progress-indicator'
|
| 24 |
-
className='
|
| 25 |
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
| 26 |
/>
|
| 27 |
</ProgressPrimitive.Root>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
|
|
| 13 |
return (
|
| 14 |
<ProgressPrimitive.Root
|
| 15 |
data-slot='progress'
|
| 16 |
+
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
|
|
|
|
|
|
|
|
|
| 17 |
{...props}
|
| 18 |
>
|
| 19 |
<ProgressPrimitive.Indicator
|
| 20 |
data-slot='progress-indicator'
|
| 21 |
+
className='h-full w-full flex-1 rounded-full bg-primary transition-transform duration-300 ease-in-out'
|
| 22 |
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
| 23 |
/>
|
| 24 |
</ProgressPrimitive.Root>
|
ui/components/ui/scroll-area.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
|
|
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
@@ -25,7 +25,7 @@ function ScrollArea({
|
|
| 25 |
ref={viewportRef}
|
| 26 |
data-slot='scroll-area-viewport'
|
| 27 |
className={cn(
|
| 28 |
-
'
|
| 29 |
viewportClassName,
|
| 30 |
)}
|
| 31 |
>
|
|
@@ -48,17 +48,15 @@ function ScrollBar({
|
|
| 48 |
orientation={orientation}
|
| 49 |
className={cn(
|
| 50 |
'flex touch-none p-px transition-colors select-none',
|
| 51 |
-
orientation === 'vertical' &&
|
| 52 |
-
|
| 53 |
-
orientation === 'horizontal' &&
|
| 54 |
-
'h-2.5 flex-col border-t border-t-transparent',
|
| 55 |
className,
|
| 56 |
)}
|
| 57 |
{...props}
|
| 58 |
>
|
| 59 |
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 60 |
data-slot='scroll-area-thumb'
|
| 61 |
-
className='
|
| 62 |
/>
|
| 63 |
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 64 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
|
|
| 25 |
ref={viewportRef}
|
| 26 |
data-slot='scroll-area-viewport'
|
| 27 |
className={cn(
|
| 28 |
+
'size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1',
|
| 29 |
viewportClassName,
|
| 30 |
)}
|
| 31 |
>
|
|
|
|
| 48 |
orientation={orientation}
|
| 49 |
className={cn(
|
| 50 |
'flex touch-none p-px transition-colors select-none',
|
| 51 |
+
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
| 52 |
+
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
|
|
|
|
|
|
| 53 |
className,
|
| 54 |
)}
|
| 55 |
{...props}
|
| 56 |
>
|
| 57 |
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 58 |
data-slot='scroll-area-thumb'
|
| 59 |
+
className='relative flex-1 rounded-full bg-border'
|
| 60 |
/>
|
| 61 |
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 62 |
)
|
ui/components/ui/select.tsx
CHANGED
|
@@ -1,26 +1,20 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as SelectPrimitive from '@radix-ui/react-select'
|
| 5 |
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
|
|
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
-
function Select({
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
| 12 |
return <SelectPrimitive.Root data-slot='select' {...props} />
|
| 13 |
}
|
| 14 |
|
| 15 |
-
function SelectGroup({
|
| 16 |
-
...props
|
| 17 |
-
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
| 18 |
return <SelectPrimitive.Group data-slot='select-group' {...props} />
|
| 19 |
}
|
| 20 |
|
| 21 |
-
function SelectValue({
|
| 22 |
-
...props
|
| 23 |
-
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
| 24 |
return <SelectPrimitive.Value data-slot='select-value' {...props} />
|
| 25 |
}
|
| 26 |
|
|
@@ -37,7 +31,7 @@ function SelectTrigger({
|
|
| 37 |
data-slot='select-trigger'
|
| 38 |
data-size={size}
|
| 39 |
className={cn(
|
| 40 |
-
"
|
| 41 |
className,
|
| 42 |
)}
|
| 43 |
{...props}
|
|
@@ -62,7 +56,7 @@ function SelectContent({
|
|
| 62 |
<SelectPrimitive.Content
|
| 63 |
data-slot='select-content'
|
| 64 |
className={cn(
|
| 65 |
-
'
|
| 66 |
position === 'popper' &&
|
| 67 |
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
| 68 |
className,
|
|
@@ -87,14 +81,11 @@ function SelectContent({
|
|
| 87 |
)
|
| 88 |
}
|
| 89 |
|
| 90 |
-
function SelectLabel({
|
| 91 |
-
className,
|
| 92 |
-
...props
|
| 93 |
-
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
| 94 |
return (
|
| 95 |
<SelectPrimitive.Label
|
| 96 |
data-slot='select-label'
|
| 97 |
-
className={cn('
|
| 98 |
{...props}
|
| 99 |
/>
|
| 100 |
)
|
|
@@ -109,7 +100,7 @@ function SelectItem({
|
|
| 109 |
<SelectPrimitive.Item
|
| 110 |
data-slot='select-item'
|
| 111 |
className={cn(
|
| 112 |
-
"
|
| 113 |
className,
|
| 114 |
)}
|
| 115 |
{...props}
|
|
@@ -134,7 +125,7 @@ function SelectSeparator({
|
|
| 134 |
return (
|
| 135 |
<SelectPrimitive.Separator
|
| 136 |
data-slot='select-separator'
|
| 137 |
-
className={cn('
|
| 138 |
{...props}
|
| 139 |
/>
|
| 140 |
)
|
|
@@ -147,10 +138,7 @@ function SelectScrollUpButton({
|
|
| 147 |
return (
|
| 148 |
<SelectPrimitive.ScrollUpButton
|
| 149 |
data-slot='select-scroll-up-button'
|
| 150 |
-
className={cn(
|
| 151 |
-
'flex cursor-default items-center justify-center py-1',
|
| 152 |
-
className,
|
| 153 |
-
)}
|
| 154 |
{...props}
|
| 155 |
>
|
| 156 |
<ChevronUpIcon className='size-4' />
|
|
@@ -165,10 +153,7 @@ function SelectScrollDownButton({
|
|
| 165 |
return (
|
| 166 |
<SelectPrimitive.ScrollDownButton
|
| 167 |
data-slot='select-scroll-down-button'
|
| 168 |
-
className={cn(
|
| 169 |
-
'flex cursor-default items-center justify-center py-1',
|
| 170 |
-
className,
|
| 171 |
-
)}
|
| 172 |
{...props}
|
| 173 |
>
|
| 174 |
<ChevronDownIcon className='size-4' />
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as SelectPrimitive from '@radix-ui/react-select'
|
| 4 |
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
| 5 |
+
import * as React from 'react'
|
| 6 |
|
| 7 |
import { cn } from '@/lib/utils'
|
| 8 |
|
| 9 |
+
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
|
|
|
|
|
| 10 |
return <SelectPrimitive.Root data-slot='select' {...props} />
|
| 11 |
}
|
| 12 |
|
| 13 |
+
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
|
|
|
|
|
| 14 |
return <SelectPrimitive.Group data-slot='select-group' {...props} />
|
| 15 |
}
|
| 16 |
|
| 17 |
+
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
|
|
|
|
|
| 18 |
return <SelectPrimitive.Value data-slot='select-value' {...props} />
|
| 19 |
}
|
| 20 |
|
|
|
|
| 31 |
data-slot='select-trigger'
|
| 32 |
data-size={size}
|
| 33 |
className={cn(
|
| 34 |
+
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
| 35 |
className,
|
| 36 |
)}
|
| 37 |
{...props}
|
|
|
|
| 56 |
<SelectPrimitive.Content
|
| 57 |
data-slot='select-content'
|
| 58 |
className={cn(
|
| 59 |
+
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
| 60 |
position === 'popper' &&
|
| 61 |
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
| 62 |
className,
|
|
|
|
| 81 |
)
|
| 82 |
}
|
| 83 |
|
| 84 |
+
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
|
|
|
|
|
|
|
|
| 85 |
return (
|
| 86 |
<SelectPrimitive.Label
|
| 87 |
data-slot='select-label'
|
| 88 |
+
className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
|
| 89 |
{...props}
|
| 90 |
/>
|
| 91 |
)
|
|
|
|
| 100 |
<SelectPrimitive.Item
|
| 101 |
data-slot='select-item'
|
| 102 |
className={cn(
|
| 103 |
+
"relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-6 pl-1.5 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-1.5",
|
| 104 |
className,
|
| 105 |
)}
|
| 106 |
{...props}
|
|
|
|
| 125 |
return (
|
| 126 |
<SelectPrimitive.Separator
|
| 127 |
data-slot='select-separator'
|
| 128 |
+
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
| 129 |
{...props}
|
| 130 |
/>
|
| 131 |
)
|
|
|
|
| 138 |
return (
|
| 139 |
<SelectPrimitive.ScrollUpButton
|
| 140 |
data-slot='select-scroll-up-button'
|
| 141 |
+
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
|
|
|
|
|
|
|
|
|
| 142 |
{...props}
|
| 143 |
>
|
| 144 |
<ChevronUpIcon className='size-4' />
|
|
|
|
| 153 |
return (
|
| 154 |
<SelectPrimitive.ScrollDownButton
|
| 155 |
data-slot='select-scroll-down-button'
|
| 156 |
+
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
|
|
|
|
|
|
|
|
|
| 157 |
{...props}
|
| 158 |
>
|
| 159 |
<ChevronDownIcon className='size-4' />
|
ui/components/ui/separator.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
|
|
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
@@ -17,7 +17,7 @@ function Separator({
|
|
| 17 |
decorative={decorative}
|
| 18 |
orientation={orientation}
|
| 19 |
className={cn(
|
| 20 |
-
'
|
| 21 |
className,
|
| 22 |
)}
|
| 23 |
{...props}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
|
|
| 17 |
decorative={decorative}
|
| 18 |
orientation={orientation}
|
| 19 |
className={cn(
|
| 20 |
+
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
| 21 |
className,
|
| 22 |
)}
|
| 23 |
{...props}
|
ui/components/ui/slider.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import * as React from 'react'
|
| 4 |
import * as SliderPrimitive from '@radix-ui/react-slider'
|
|
|
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
@@ -14,12 +14,7 @@ function Slider({
|
|
| 14 |
...props
|
| 15 |
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
| 16 |
const _values = React.useMemo(
|
| 17 |
-
() =>
|
| 18 |
-
Array.isArray(value)
|
| 19 |
-
? value
|
| 20 |
-
: Array.isArray(defaultValue)
|
| 21 |
-
? defaultValue
|
| 22 |
-
: [min, max],
|
| 23 |
[value, defaultValue, min, max],
|
| 24 |
)
|
| 25 |
|
|
@@ -39,13 +34,13 @@ function Slider({
|
|
| 39 |
<SliderPrimitive.Track
|
| 40 |
data-slot='slider-track'
|
| 41 |
className={cn(
|
| 42 |
-
'
|
| 43 |
)}
|
| 44 |
>
|
| 45 |
<SliderPrimitive.Range
|
| 46 |
data-slot='slider-range'
|
| 47 |
className={cn(
|
| 48 |
-
'bg-primary
|
| 49 |
)}
|
| 50 |
/>
|
| 51 |
</SliderPrimitive.Track>
|
|
@@ -53,7 +48,7 @@ function Slider({
|
|
| 53 |
<SliderPrimitive.Thumb
|
| 54 |
data-slot='slider-thumb'
|
| 55 |
key={index}
|
| 56 |
-
className='
|
| 57 |
/>
|
| 58 |
))}
|
| 59 |
</SliderPrimitive.Root>
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import * as SliderPrimitive from '@radix-ui/react-slider'
|
| 4 |
+
import * as React from 'react'
|
| 5 |
|
| 6 |
import { cn } from '@/lib/utils'
|
| 7 |
|
|
|
|
| 14 |
...props
|
| 15 |
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
| 16 |
const _values = React.useMemo(
|
| 17 |
+
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
[value, defaultValue, min, max],
|
| 19 |
)
|
| 20 |
|
|
|
|
| 34 |
<SliderPrimitive.Track
|
| 35 |
data-slot='slider-track'
|
| 36 |
className={cn(
|
| 37 |
+
'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
|
| 38 |
)}
|
| 39 |
>
|
| 40 |
<SliderPrimitive.Range
|
| 41 |
data-slot='slider-range'
|
| 42 |
className={cn(
|
| 43 |
+
'absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
| 44 |
)}
|
| 45 |
/>
|
| 46 |
</SliderPrimitive.Track>
|
|
|
|
| 48 |
<SliderPrimitive.Thumb
|
| 49 |
data-slot='slider-thumb'
|
| 50 |
key={index}
|
| 51 |
+
className='block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50'
|
| 52 |
/>
|
| 53 |
))}
|
| 54 |
</SliderPrimitive.Root>
|