Feat: 新增首页和编辑器页面,支持导出导出

This commit is contained in:
奇趣保罗 2025-11-03 16:47:16 +08:00
parent d2ba984a75
commit f2acb91f95
16 changed files with 1614 additions and 166 deletions

View File

@ -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,

View File

@ -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"
},

View File

@ -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

View File

@ -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;
}

View File

@ -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 (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@ -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<string, Record<string, string>>;
};
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
const [selected, setSelected] = useState<string>("");
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> JSON</DialogTitle>
</DialogHeader>
<div className="space-y-3 min-w-0">
<div className="flex flex-wrap gap-2">
{languages.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => setSelected(lang)}
className={`px-3 h-8 rounded border text-sm ${selected === lang ? "bg-accent" : ""}`}
>
{lang}
</button>
))
)}
</div>
<div className="border rounded-md overflow-hidden">
<textarea className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre" readOnly>
{jsonText}
</textarea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleDownload} disabled={!selected || !jsonText}> JSON</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const ExportLanguageModal = memo(ExportLanguageModalImpl);

View File

@ -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<void>;
};
function ImportLanguageModalImpl({
open,
onOpenChange,
projectId,
hasStructure,
onImported,
}: Props) {
const [lang, setLang] = useState("");
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement | null>(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 (
<Dialog
open={open}
onOpenChange={(v) => {
if (!importing) {
onOpenChange(v);
if (!v) setError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{hasStructure ? "导入语言 JSON" : "导入翻译 JSON 并构建结构"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="mt-2 space-y-3">
<div>
<label className="text-sm text-muted-foreground">
JSON
</label>
<Input
ref={fileRef}
type="file"
accept="application/json,.json"
className="mt-1 block w-full text-sm"
/>
</div>
<div>
<label className="text-sm text-muted-foreground">
enjazh-CN
</label>
<Input
placeholder="en"
value={lang}
onChange={(e) => setLang(e.target.value)}
aria-label="语言代号"
/>
</div>
{error && (
<div className="text-sm text-red-600" role="alert">
{error}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
setError(null);
}}
disabled={importing}
>
</Button>
<Button type="submit" disabled={importing}>
{importing ? "导入中..." : "确认导入"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export const ImportLanguageModal = memo(ImportLanguageModalImpl);

View File

@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -2,9 +2,11 @@ import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
ref={ref}
type={type}
data-slot="input"
className={cn(
@ -17,5 +19,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,95 @@
import { useRef, useState } from "react";
import { upsertLanguageTranslations } from "@/lib/db";
export type ValuesByLang = Record<string, Record<string, string>>;
function makeKey(path: string, lang: string) {
return `${path}||${lang}`;
}
export function useTranslationInlineEdit(opts: {
projectId?: string;
valuesByLang: ValuesByLang;
setValuesByLang: React.Dispatch<React.SetStateAction<ValuesByLang>>;
onError?: (message: string) => void;
}) {
const { projectId, valuesByLang, setValuesByLang, onError } = opts;
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState<string>("");
const [savingKey, setSavingKey] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) {
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;
}

View File

@ -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);

252
src/lib/db.ts Normal file
View File

@ -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<string, string>; // 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<IDBDatabase> {
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<T>(request: IDBRequest<T>): Promise<T> {
return new Promise<T>((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<Project[]> {
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<Project[]>(getAllReq);
await new Promise<void>((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<Project | undefined> {
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<Project | undefined>(
req as IDBRequest<Project | undefined>
);
await new Promise<void>((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<Project> {
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<void>((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<void> {
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<void>((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<ProjectStructure | undefined> {
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<ProjectStructure | undefined>(req as IDBRequest<ProjectStructure | undefined>);
await new Promise<void>((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<void> {
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<void>((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<string, string>): Promise<void> {
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<void>((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<ProjectLanguageTranslations | undefined> {
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<ProjectLanguageTranslations | undefined>(req as IDBRequest<ProjectLanguageTranslations | undefined>);
await new Promise<void>((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<string[]> {
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<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
const langs = new Set<string>();
for (const r of records) langs.add(r.language);
return Array.from(langs);
} finally {
db.close();
}
}

86
src/lib/i18n-structure.ts Normal file
View File

@ -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<string, unknown>)) {
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<string, unknown>)) {
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<string, string> {
const map: Record<string, string> = {};
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<string, unknown>)) {
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<string, unknown>)) dfs(v, [k]);
}
return map;
}
export function unflattenValues(values: Record<string, string>): Record<string, unknown> {
const root: Record<string, unknown> = {};
for (const [path, val] of Object.entries(values)) {
if (!path) continue;
const parts = path.split(".");
let cur: Record<string, unknown> = 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<string, unknown>;
} else {
const obj: Record<string, unknown> = {};
cur[key] = obj;
cur = obj;
}
}
}
}
return root;
}

View File

@ -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: <Home />,
},
{
path: "/editor/:id",
element: <Editor />,
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
<RouterProvider router={router} />,
</StrictMode>
);

199
src/pages/editor.tsx Normal file
View File

@ -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<Project | null>(null);
const [structure, setStructure] = useState<ProjectStructure | null>(null);
const [languages, setLanguages] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [pageError, setPageError] = useState<string | null>(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<Record<string, Record<string, string>>>({});
const inline = useTranslationInlineEdit({
projectId,
valuesByLang,
setValuesByLang,
onError: (m) => setPageError(m),
});
useEffect(() => {
if (!projectId || languages.length === 0) return;
let cancelled = false;
(async () => {
const all: Record<string, Record<string, string>> = {};
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 (
<div className="h-screen flex flex-col">
<header className="h-14 border-b px-2 md:px-4">
<div className="grid grid-cols-3 h-full items-center">
<div className="flex items-center gap-2">
<Button asChild size="icon" variant="ghost" aria-label="返回">
<Link to="/">
<ArrowLeft className="size-5" />
</Link>
</Button>
</div>
<div className="text-center font-medium truncate">
{project?.name ?? "编辑器"}
</div>
<div className="flex items-center justify-end gap-2">
{!structure ? (
<Button onClick={() => { setImportOpen(true); }}></Button>
) : (
<Button variant="outline" onClick={() => { setImportOpen(true); }}> JSON</Button>
)}
{languages.length > 0 && (
<Button variant="outline" onClick={() => setExportOpen(true)}> JSON</Button>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-auto p-4 md:p-6">
{pageError && (
<div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div>
)}
{loading ? (
<div className="text-sm text-muted-foreground">...</div>
) : !structure ? (
<div className="rounded-md border border-dashed p-6">
<div className="text-sm text-muted-foreground">
JSON
</div>
<div className="mt-3">
<Button onClick={() => { setImportOpen(true); }}></Button>
</div>
</div>
) : (
<div>
<div className="overflow-auto border rounded-md">
<table className="min-w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 w-[320px]"></th>
{languages.map((lang) => (
<th key={lang} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.path} className="border-t">
<td className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
{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 (
<td
key={`${entry.path}:${lang}`}
className="px-3 py-2 text-foreground/90 align-top"
>
{isEditing ? (
<Input
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="h-8"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-muted-foreground"></span>}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
<ImportLanguageModal
open={importOpen}
onOpenChange={setImportOpen}
projectId={projectId ?? ""}
hasStructure={!!structure}
onImported={refresh}
/>
<ExportLanguageModal
open={exportOpen}
onOpenChange={setExportOpen}
languages={languages}
valuesByLang={valuesByLang}
/>
</div>
);
}

125
src/pages/home.tsx Normal file
View File

@ -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<Project[]>([]);
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto max-w-3xl px-4 py-8">
<h1 className="text-2xl font-semibold"></h1>
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
<Input
placeholder="输入项目名称"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
aria-label="项目名称"
/>
<Button type="submit" disabled={!canSubmit}>
{submitting ? "创建中..." : "新建项目"}
</Button>
</form>
{error && (
<div className="mt-4 text-sm text-red-600" role="alert">
{error}
</div>
)}
<div className="mt-8">
<h2 className="mb-3 text-lg font-medium"></h2>
{loading ? (
<div className="text-sm text-muted-foreground">...</div>
) : projects.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<ul className="space-y-3">
{projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="min-w-0">
<div className="truncate font-medium">{p.name}</div>
<div className="mt-1 text-xs text-muted-foreground truncate">
ID: {p.id} · : {formatTime(p.createdAt)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button asChild size="sm" variant="outline">
<Link to={`/editor/${p.id}`}></Link>
</Button>
<Button size="sm" variant="destructive" onClick={() => onDelete(p.id)}>
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default App;