diff --git a/eslint.config.js b/eslint.config.js index b19330b..0af3d8b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,9 @@ export default defineConfig([ reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, ], + rules: { + 'react-refresh/only-export-components': 'off', + }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, diff --git a/package.json b/package.json index d3b37bf..504d81d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.16", "class-variance-authority": "^0.7.1", @@ -17,6 +18,7 @@ "lucide-react": "^0.552.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router": "^7.9.5", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.16" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d199d..71d739b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.2.2)(react@19.2.0) @@ -29,6 +32,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.2.0(react@19.2.0) + react-router: + specifier: ^7.9.5 + version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -399,6 +405,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -408,6 +417,111 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -417,6 +531,51 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rolldown/pluginutils@1.0.0-beta.43': resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} @@ -734,6 +893,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -787,6 +950,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -810,6 +977,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + electron-to-chromium@1.5.244: resolution: {integrity: sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==} @@ -936,6 +1106,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1206,6 +1380,46 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router@7.9.5: + resolution: {integrity: sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -1238,6 +1452,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1282,6 +1499,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -1313,6 +1533,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + vite@7.1.12: resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1649,12 +1889,108 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)': dependencies: react: 19.2.0 optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -1662,6 +1998,40 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + '@rolldown/pluginutils@1.0.0-beta.43': {} '@rollup/rollup-android-arm-eabi@4.52.5': @@ -1959,6 +2329,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + balanced-match@1.0.2: {} baseline-browser-mapping@2.8.22: {} @@ -2009,6 +2383,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.0.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2025,6 +2401,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + electron-to-chromium@1.5.244: {} enhanced-resolve@5.18.3: @@ -2188,6 +2566,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2394,6 +2774,41 @@ snapshots: react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.2)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + + react-remove-scroll@2.7.1(@types/react@19.2.2)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.2)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.2)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + + react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + cookie: 1.0.2 + react: 19.2.0 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.0(react@19.2.0) + + react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + react@19.2.0: {} resolve-from@4.0.0: {} @@ -2438,6 +2853,8 @@ snapshots: semver@7.7.3: {} + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2471,6 +2888,8 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -2502,6 +2921,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + + use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.25.11 diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 3d7ded3..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' - -function App() { - const [count, setCount] = useState(0) - - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) -} - -export default App diff --git a/src/components/biz/export-language-modal.tsx b/src/components/biz/export-language-modal.tsx new file mode 100644 index 0000000..15fcb48 --- /dev/null +++ b/src/components/biz/export-language-modal.tsx @@ -0,0 +1,92 @@ +import { memo, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { unflattenValues } from "@/lib/i18n-structure"; + +type Props = { + open: boolean; + onOpenChange: (next: boolean) => void; + languages: string[]; + valuesByLang: Record>; +}; + +function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) { + const [selected, setSelected] = useState(""); + + useEffect(() => { + if (open) { + setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? "")); + } + }, [open, languages]); + + const jsonText = useMemo(() => { + if (!selected) return ""; + const flat = valuesByLang[selected] ?? {}; + const nested = unflattenValues(flat); + try { + return JSON.stringify(nested, null, 2); + } catch { + return "{}"; + } + }, [selected, valuesByLang]); + + function handleDownload() { + if (!selected) return; + const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${selected}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + return ( + + + + 导出语言 JSON + +
+
+ {languages.length === 0 ? ( +
暂无可导出的语言
+ ) : ( + languages.map((lang) => ( + + )) + )} +
+
+ +
+
+ + + + +
+
+ ); +} + +export const ExportLanguageModal = memo(ExportLanguageModalImpl); + + diff --git a/src/components/biz/import-language-modal.tsx b/src/components/biz/import-language-modal.tsx new file mode 100644 index 0000000..b456b78 --- /dev/null +++ b/src/components/biz/import-language-modal.tsx @@ -0,0 +1,143 @@ +import { memo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { buildStructureFromObject, flattenValues } from "@/lib/i18n-structure"; +import { upsertLanguageTranslations, upsertStructure } from "@/lib/db"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type Props = { + open: boolean; + onOpenChange: (next: boolean) => void; + projectId: string; + hasStructure: boolean; + onImported: () => void | Promise; +}; + +function ImportLanguageModalImpl({ + open, + onOpenChange, + projectId, + hasStructure, + onImported, +}: Props) { + const [lang, setLang] = useState(""); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(null); + const fileRef = useRef(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if ( + !fileRef.current || + !fileRef.current.files || + fileRef.current.files.length === 0 + ) { + setError("请选择 JSON 文件"); + return; + } + const language = lang.trim(); + if (!language) { + setError("请输入语言代号,如 en 或 zh-CN"); + return; + } + setImporting(true); + setError(null); + try { + const file = fileRef.current.files[0]!; + const text = await file.text(); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new Error("JSON 解析失败,请检查文件内容"); + } + if (!hasStructure) { + const root = buildStructureFromObject(json); + await upsertStructure({ projectId, root }); + } + const values = flattenValues(json); + await upsertLanguageTranslations(projectId, language, values); + setLang(""); + if (fileRef.current) fileRef.current.value = ""; + onOpenChange(false); + await onImported(); + } catch (e) { + setError((e as Error)?.message ?? "导入失败"); + } finally { + setImporting(false); + } + } + + return ( + { + if (!importing) { + onOpenChange(v); + if (!v) setError(null); + } + }} + > + + + + {hasStructure ? "导入语言 JSON" : "导入翻译 JSON 并构建结构"} + + +
+
+ + +
+
+ + setLang(e.target.value)} + aria-label="语言代号" + /> +
+ {error && ( +
+ {error} +
+ )} + + + + +
+
+
+ ); +} + +export const ImportLanguageModal = memo(ImportLanguageModalImpl); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..6cb123b --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 868dec6..2fb8618 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -2,20 +2,25 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ); -} +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); + +Input.displayName = "Input"; export { Input }; diff --git a/src/hooks/biz/use-translation-inline-edit.ts b/src/hooks/biz/use-translation-inline-edit.ts new file mode 100644 index 0000000..651f66c --- /dev/null +++ b/src/hooks/biz/use-translation-inline-edit.ts @@ -0,0 +1,95 @@ +import { useRef, useState } from "react"; +import { upsertLanguageTranslations } from "@/lib/db"; + +export type ValuesByLang = Record>; + +function makeKey(path: string, lang: string) { + return `${path}||${lang}`; +} + +export function useTranslationInlineEdit(opts: { + projectId?: string; + valuesByLang: ValuesByLang; + setValuesByLang: React.Dispatch>; + onError?: (message: string) => void; +}) { + const { projectId, valuesByLang, setValuesByLang, onError } = opts; + + const [editingKey, setEditingKey] = useState(null); + const [editingValue, setEditingValue] = useState(""); + const [savingKey, setSavingKey] = useState(null); + const inputRef = useRef(null); + + function startEdit(path: string, lang: string) { + const key = makeKey(path, lang); + const current = valuesByLang[lang]?.[path] ?? ""; + setEditingKey(key); + setEditingValue(current); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function cancelEdit() { + setEditingKey(null); + setEditingValue(""); + } + + function isEditingCell(path: string, lang: string) { + return editingKey === makeKey(path, lang); + } + + function isSavingCell(path: string, lang: string) { + return savingKey === makeKey(path, lang); + } + + function getDisplayValue(path: string, lang: string) { + return valuesByLang[lang]?.[path] ?? ""; + } + + async function saveEdit() { + if (!projectId || !editingKey) return; + if (savingKey === editingKey) return; + const [path, lang] = editingKey.split("||"); + const prev = valuesByLang[lang] ?? {}; + const next = { ...prev, [path]: editingValue }; + setSavingKey(editingKey); + try { + await upsertLanguageTranslations(projectId, lang, next); + setValuesByLang((old) => ({ ...old, [lang]: next })); + cancelEdit(); + } catch (e) { + onError?.((e as Error)?.message ?? "保存失败"); + } finally { + setSavingKey(null); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + void saveEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } + } + + return { + // state + editingKey, + editingValue, + savingKey, + inputRef, + // selectors + isEditingCell, + isSavingCell, + getDisplayValue, + // actions + startEdit, + cancelEdit, + setEditingValue, + saveEdit, + handleKeyDown, + } as const; +} + + diff --git a/src/index.css b/src/index.css index a71185f..f36ea66 100644 --- a/src/index.css +++ b/src/index.css @@ -4,18 +4,6 @@ @custom-variant dark (&:is(.dark *)); :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.129 0.042 264.695); @@ -50,60 +38,6 @@ --sidebar-ring: oklch(0.704 0.04 256.788); } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} - @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..253eb96 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,252 @@ +export type Project = { + id: string; + name: string; + createdAt: number; // ms timestamp + updatedAt: number; // ms timestamp +}; + +export type StructureNode = { + key: string; + type: "group" | "entry"; + children?: StructureNode[]; +}; + +export type ProjectStructure = { + projectId: string; + root: StructureNode; +}; + +export type ProjectLanguageTranslations = { + id: string; // `${projectId}:${language}` + projectId: string; + language: string; // e.g. en, zh-CN, ja + values: Record; // path -> value +}; + +const DB_NAME = "i18n-translate-it"; +const DB_VERSION = 2; +const STORE_PROJECTS = "projects"; +const STORE_STRUCTURES = "structures"; // keyPath: projectId +const STORE_TRANSLATIONS = "translations"; // keyPath: id, index by projectId + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_PROJECTS)) { + db.createObjectStore(STORE_PROJECTS, { keyPath: "id" }); + } + if (!db.objectStoreNames.contains(STORE_STRUCTURES)) { + db.createObjectStore(STORE_STRUCTURES, { keyPath: "projectId" }); + } + if (!db.objectStoreNames.contains(STORE_TRANSLATIONS)) { + const store = db.createObjectStore(STORE_TRANSLATIONS, { keyPath: "id" }); + store.createIndex("byProject", "projectId", { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + request.onblocked = () => { + reject(new Error("IndexedDB blocked: please close other tabs.")); + }; + }); +} + +function promisifyRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function generateId(length = 12): string { + const alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const bytes = new Uint8Array(length); + if (globalThis.crypto && globalThis.crypto.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < length; i += 1) + bytes[i] = Math.floor(Math.random() * 256); + } + let id = ""; + for (let i = 0; i < length; i += 1) + id += alphabet[bytes[i] % alphabet.length]; + return id; +} + +export async function listProjects(): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_PROJECTS, "readonly"); + const store = tx.objectStore(STORE_PROJECTS); + const getAllReq = store.getAll(); + const result = await promisifyRequest(getAllReq); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + // sort by createdAt desc by default when listing + return (result ?? []).sort((a, b) => b.createdAt - a.createdAt); + } finally { + db.close(); + } +} + +export async function getProject(id: string): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_PROJECTS, "readonly"); + const store = tx.objectStore(STORE_PROJECTS); + const req = store.get(id); + const result = await promisifyRequest( + req as IDBRequest + ); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + return result; + } finally { + db.close(); + } +} + +export async function createProject(name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) throw new Error("项目名称不能为空"); + const project: Project = { + id: generateId(12), + name: trimmed, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const db = await openDb(); + try { + const tx = db.transaction(STORE_PROJECTS, "readwrite"); + const store = tx.objectStore(STORE_PROJECTS); + const putReq = store.add(project); + await promisifyRequest(putReq); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + return project; + } finally { + db.close(); + } +} + +export async function deleteProject(id: string): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_PROJECTS, "readwrite"); + const store = tx.objectStore(STORE_PROJECTS); + const delReq = store.delete(id); + await promisifyRequest(delReq); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +// ----- Structures ----- +export async function getStructure(projectId: string): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_STRUCTURES, "readonly"); + const store = tx.objectStore(STORE_STRUCTURES); + const req = store.get(projectId); + const result = await promisifyRequest(req as IDBRequest); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + return result; + } finally { + db.close(); + } +} + +export async function upsertStructure(structure: ProjectStructure): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_STRUCTURES, "readwrite"); + const store = tx.objectStore(STORE_STRUCTURES); + const putReq = store.put(structure); + await promisifyRequest(putReq); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +// ----- Translations ----- +export async function upsertLanguageTranslations(projectId: string, language: string, values: Record): Promise { + const id = `${projectId}:${language}`; + const record: ProjectLanguageTranslations = { id, projectId, language, values }; + const db = await openDb(); + try { + const tx = db.transaction(STORE_TRANSLATIONS, "readwrite"); + const store = tx.objectStore(STORE_TRANSLATIONS); + const putReq = store.put(record); + await promisifyRequest(putReq); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +export async function getLanguageTranslations(projectId: string, language: string): Promise { + const id = `${projectId}:${language}`; + const db = await openDb(); + try { + const tx = db.transaction(STORE_TRANSLATIONS, "readonly"); + const store = tx.objectStore(STORE_TRANSLATIONS); + const req = store.get(id); + const result = await promisifyRequest(req as IDBRequest); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + return result; + } finally { + db.close(); + } +} + +export async function listLanguages(projectId: string): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_TRANSLATIONS, "readonly"); + const store = tx.objectStore(STORE_TRANSLATIONS); + const index = store.index("byProject"); + const allReq = index.getAll(IDBKeyRange.only(projectId)); + const records = await promisifyRequest(allReq as IDBRequest); + const langs = new Set(); + for (const r of records) langs.add(r.language); + return Array.from(langs); + } finally { + db.close(); + } +} diff --git a/src/lib/i18n-structure.ts b/src/lib/i18n-structure.ts new file mode 100644 index 0000000..5bbb118 --- /dev/null +++ b/src/lib/i18n-structure.ts @@ -0,0 +1,86 @@ +import { type StructureNode } from "@/lib/db"; + +export type FlatEntry = { path: string; name: string }; + +export function buildStructureFromObject(obj: unknown): StructureNode { + const root: StructureNode = { key: "$root", type: "group", children: [] }; + function walk(currentKey: string, value: unknown): StructureNode { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const children: StructureNode[] = []; + for (const [k, v] of Object.entries(value as Record)) { + children.push(walk(k, v)); + } + return { key: currentKey, type: "group", children }; + } + return { key: currentKey, type: "entry" }; + } + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + for (const [k, v] of Object.entries(obj as Record)) { + root.children!.push(walk(k, v)); + } + } else { + root.children!.push({ key: "value", type: "entry" }); + } + return root; +} + +export function flattenEntries(root: StructureNode): FlatEntry[] { + const out: FlatEntry[] = []; + function dfs(node: StructureNode, parentPath: string[]) { + if (node.type === "entry") { + const pathArr = [...parentPath, node.key].filter(Boolean); + out.push({ path: pathArr.join("."), name: node.key }); + return; + } + const next = node.key === "$root" ? parentPath : [...parentPath, node.key]; + for (const child of node.children ?? []) dfs(child, next); + } + dfs(root, []); + return out; +} + +export function flattenValues(obj: unknown): Record { + const map: Record = {}; + function dfs(value: unknown, parentPath: string[]) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [k, v] of Object.entries(value as Record)) { + dfs(v, [...parentPath, k]); + } + return; + } + const path = parentPath.join("."); + map[path] = typeof value === "string" ? value : String(value); + } + if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) { + for (const [k, v] of Object.entries(obj as Record)) dfs(v, [k]); + } + return map; +} + +export function unflattenValues(values: Record): Record { + const root: Record = {}; + for (const [path, val] of Object.entries(values)) { + if (!path) continue; + const parts = path.split("."); + let cur: Record = root; + for (let i = 0; i < parts.length; i += 1) { + const key = parts[i]!; + const isLeaf = i === parts.length - 1; + if (isLeaf) { + cur[key] = val; + } else { + const next = cur[key]; + if (next && typeof next === "object" && !Array.isArray(next)) { + cur = next as Record; + } else { + const obj: Record = {}; + cur[key] = obj; + cur = obj; + } + } + } + } + return root; +} + + diff --git a/src/main.tsx b/src/main.tsx index bef5202..8587fa0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,24 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { createBrowserRouter } from "react-router"; +import { RouterProvider } from "react-router/dom"; +import "./index.css"; +import Home from "./pages/home.tsx"; +import Editor from "./pages/editor.tsx"; -createRoot(document.getElementById('root')!).render( +const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/editor/:id", + element: , + }, +]); + +createRoot(document.getElementById("root")!).render( - - , -) + , + +); diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx new file mode 100644 index 0000000..a8e63e2 --- /dev/null +++ b/src/pages/editor.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useParams } from "react-router"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + getProject, + getStructure, + listLanguages, + type Project, + type ProjectStructure, + getLanguageTranslations, +} from "@/lib/db"; +import { ArrowLeft } from "lucide-react"; +import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit"; +import { flattenEntries, type FlatEntry } from "@/lib/i18n-structure"; +import { ImportLanguageModal } from "@/components/biz/import-language-modal"; +import { ExportLanguageModal } from "@/components/biz/export-language-modal"; + +export default function Editor() { + const { id: projectId } = useParams(); + const [project, setProject] = useState(null); + const [structure, setStructure] = useState(null); + const [languages, setLanguages] = useState([]); + const [loading, setLoading] = useState(true); + const [pageError, setPageError] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + + async function refresh() { + if (!projectId) return; + setLoading(true); + setPageError(null); + try { + const p = await getProject(projectId); + if (!p) throw new Error("项目不存在"); + setProject(p); + const s = await getStructure(projectId); + if (s) setStructure(s); + const langs = await listLanguages(projectId); + setLanguages(langs); + } catch (e) { + setPageError((e as Error)?.message ?? "加载失败"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const entries: FlatEntry[] = useMemo(() => { + if (!structure) return []; + return flattenEntries(structure.root); + }, [structure]); + + const [valuesByLang, setValuesByLang] = useState>>({}); + const inline = useTranslationInlineEdit({ + projectId, + valuesByLang, + setValuesByLang, + onError: (m) => setPageError(m), + }); + + useEffect(() => { + if (!projectId || languages.length === 0) return; + let cancelled = false; + (async () => { + const all: Record> = {}; + for (const lang of languages) { + const rec = await getLanguageTranslations(projectId, lang); + all[lang] = rec?.values ?? {}; + } + if (!cancelled) setValuesByLang(all); + })(); + return () => { + cancelled = true; + }; + }, [projectId, languages]); + + return ( +
+
+
+
+ +
+
+ {project?.name ?? "编辑器"} +
+
+ {!structure ? ( + + ) : ( + + )} + {languages.length > 0 && ( + + )} +
+
+
+ +
+ {pageError && ( +
{pageError}
+ )} + + {loading ? ( +
加载中...
+ ) : !structure ? ( +
+
+ 该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。 +
+
+ +
+
+ ) : ( +
+
+ + + + + {languages.map((lang) => ( + + ))} + + + + {entries.map((entry) => ( + + + {languages.map((lang) => { + const isEditing = inline.isEditingCell(entry.path, lang); + const isSaving = inline.isSavingCell(entry.path, lang); + const displayValue = inline.getDisplayValue(entry.path, lang); + return ( + + ); + })} + + ))} + +
翻译条目名称{lang}
{entry.path} + {isEditing ? ( + inline.setEditingValue(e.target.value)} + onBlur={inline.saveEdit} + onKeyDown={inline.handleKeyDown} + disabled={isSaving} + className="h-8" + /> + ) : ( + + )} +
+
+
+ )} +
+ + + +
+ ); +} + + diff --git a/src/pages/home.tsx b/src/pages/home.tsx new file mode 100644 index 0000000..10e7f71 --- /dev/null +++ b/src/pages/home.tsx @@ -0,0 +1,125 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { createProject, deleteProject, listProjects, type Project } from "@/lib/db"; + +function formatTime(ts: number) { + try { + return new Date(ts).toLocaleString(); + } catch { + return String(ts); + } +} + +function App() { + const [projectName, setProjectName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + const [projects, setProjects] = useState([]); + const [error, setError] = useState(null); + + async function refresh() { + setLoading(true); + try { + const data = await listProjects(); + setProjects(data); + } catch (e) { + setError((e as Error)?.message ?? "加载失败"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void refresh(); + }, []); + + const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]); + + async function onCreate(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + await createProject(projectName.trim()); + setProjectName(""); + await refresh(); + } catch (e) { + setError((e as Error)?.message ?? "创建失败"); + } finally { + setSubmitting(false); + } + } + + async function onDelete(id: string) { + // 简单确认,避免误删 + if (!confirm("确认删除该项目?此操作不可恢复")) return; + try { + await deleteProject(id); + await refresh(); + } catch (e) { + setError((e as Error)?.message ?? "删除失败"); + } + } + + return ( +
+

翻译项目

+ +
+ setProjectName(e.target.value)} + aria-label="项目名称" + /> + +
+ + {error && ( +
+ {error} +
+ )} + +
+

项目列表

+ {loading ? ( +
加载中...
+ ) : projects.length === 0 ? ( +
暂无项目,创建一个开始吧。
+ ) : ( +
    + {projects.map((p) => ( +
  • +
    +
    {p.name}
    +
    + ID: {p.id} · 创建时间: {formatTime(p.createdAt)} +
    +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ ); +} + +export default App;