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 + 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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+
+
+
+ {pageError && (
+ {pageError}
+ )}
+
+ {loading ? (
+ 加载中...
+ ) : !structure ? (
+
+
+ 该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
+
+
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 (
+
+
翻译项目
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
项目列表
+ {loading ? (
+
加载中...
+ ) : projects.length === 0 ? (
+
暂无项目,创建一个开始吧。
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export default App;