diff --git a/package.json b/package.json index 504d81d..c9d0d23 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.16", "class-variance-authority": "^0.7.1", @@ -19,6 +20,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.5", + "react-virtuoso": "^4.7.11", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.16" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71d739b..7d11f05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@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-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@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) @@ -35,6 +38,9 @@ importers: react-router: specifier: ^7.9.5 version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-virtuoso: + specifier: ^4.7.11 + version: 4.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -361,6 +367,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -408,6 +429,32 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + 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-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + 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-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -439,6 +486,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -452,6 +508,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + 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: @@ -483,6 +552,32 @@ packages: '@types/react': optional: true + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + 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-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + 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-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -522,6 +617,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + 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: @@ -576,6 +684,27 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + 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-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/pluginutils@1.0.0-beta.43': resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} @@ -1420,6 +1549,12 @@ packages: '@types/react': optional: true + react-virtuoso@4.14.1: + resolution: {integrity: sha512-NRUF1ak8lY+Tvc6WN9cce59gU+lilzVtOozP+pm9J7iHshLGGjsiAB4rB2qlBPHjFbcXOQpT+7womNHGDUql8w==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -1847,6 +1982,23 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@floating-ui/utils@0.2.10': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -1891,6 +2043,27 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@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-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) + 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-collection@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-context': 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-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-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)': dependencies: react: 19.2.0 @@ -1925,6 +2098,12 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-direction@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-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 @@ -1938,6 +2117,21 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-dropdown-menu@2.1.16(@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-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@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-use-controllable-state': 1.2.2(@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 @@ -1962,6 +2156,50 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-menu@2.1.16(@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-collection': 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-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-direction': 1.1.1(@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-popper': 1.2.8(@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-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-roving-focus': 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-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@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-popper@1.2.8(@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: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-arrow': 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-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-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-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/rect': 1.1.1 + 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-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) @@ -1991,6 +2229,23 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-roving-focus@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-collection': 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-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-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@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-controllable-state': 1.2.2(@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) @@ -2032,6 +2287,22 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-size@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/rect@1.1.1': {} + '@rolldown/pluginutils@1.0.0-beta.43': {} '@rollup/rollup-android-arm-eabi@4.52.5': @@ -2809,6 +3080,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + react-virtuoso@4.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react@19.2.0: {} resolve-from@4.0.0: {} diff --git a/src/components/biz/entry-name-modal.tsx b/src/components/biz/entry-name-modal.tsx new file mode 100644 index 0000000..7496de1 --- /dev/null +++ b/src/components/biz/entry-name-modal.tsx @@ -0,0 +1,82 @@ +import { memo, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + title: string; + placeholder?: string; + defaultValue?: string; + confirmText?: string; + onConfirm: (name: string) => void | Promise; + validate?: (name: string) => string | null; +}; + +function EntryNameModalImpl({ open, onOpenChange, title, placeholder, defaultValue, confirmText = "确认", onConfirm, validate }: Props) { + const [name, setName] = useState(""); + const [err, setErr] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setName(defaultValue ?? ""); + setErr(null); + setSaving(false); + } + }, [open, defaultValue]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return setErr("名称不能为空"); + if (trimmed.includes(".")) return setErr("名称不能包含 '.'"); + if (validate) { + const msg = validate(trimmed); + if (msg) return setErr(msg); + } + setSaving(true); + try { + await onConfirm(trimmed); + onOpenChange(false); + } catch (e) { + setErr((e as Error)?.message ?? "操作失败"); + } finally { + setSaving(false); + } + } + + return ( + { if (!saving) onOpenChange(v); }}> + + + {title} + +
+ setName(e.target.value)} + aria-label="名称" + /> + {err &&
{err}
} + + + + +
+
+
+ ); +} + +export const EntryNameModal = memo(EntryNameModalImpl); + + diff --git a/src/components/biz/export-language-modal.tsx b/src/components/biz/export-language-modal.tsx index 15fcb48..efdf200 100644 --- a/src/components/biz/export-language-modal.tsx +++ b/src/components/biz/export-language-modal.tsx @@ -14,31 +14,84 @@ type Props = { onOpenChange: (next: boolean) => void; languages: string[]; valuesByLang: Record>; + orderedPaths?: string[]; // 优先按照结构顺序导出 }; -function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) { +function ExportLanguageModalImpl({ + open, + onOpenChange, + languages, + valuesByLang, + orderedPaths, +}: Props) { const [selected, setSelected] = useState(""); useEffect(() => { if (open) { - setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? "")); + 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); + + // 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致 + if (orderedPaths && orderedPaths.length > 0) { + const root: Record = {}; + const added = new Set(); + const setByPath = (path: string, val: string) => { + 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; + } + } + } + }; + + for (const p of orderedPaths) { + const v = Object.prototype.hasOwnProperty.call(flat, p) ? flat[p] : ""; + setByPath(p, v); + added.add(p); + } + // 追加结构外的剩余键,避免丢数据 + for (const p of Object.keys(flat)) { + if (!added.has(p)) setByPath(p, flat[p]!); + } + try { + return JSON.stringify(root, null, 2); + } catch { + return "{}"; + } + } + + // 回退:无结构顺序时使用普通反扁平 try { - return JSON.stringify(nested, null, 2); + return JSON.stringify(unflattenValues(flat), null, 2); } catch { return "{}"; } - }, [selected, valuesByLang]); + }, [selected, valuesByLang, orderedPaths]); function handleDownload() { if (!selected) return; - const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" }); + const blob = new Blob([jsonText || "{}"], { + type: "application/json;charset=utf-8", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -58,14 +111,18 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
{languages.length === 0 ? ( -
暂无可导出的语言
+
+ 暂无可导出的语言 +
) : ( languages.map((lang) => ( @@ -73,14 +130,20 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang } )}
- +
- - + + @@ -88,5 +151,3 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang } } export const ExportLanguageModal = memo(ExportLanguageModalImpl); - - diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..eaed9ba --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 253eb96..ed4caf0 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -250,3 +250,76 @@ export async function listLanguages(projectId: string): Promise { db.close(); } } + +export async function getAllLanguageTranslations(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); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + return records; + } finally { + db.close(); + } +} + +export async function deleteEntryFromAllLanguages(projectId: string, path: string): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_TRANSLATIONS, "readwrite"); + 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); + for (const rec of records) { + if (rec.values && Object.prototype.hasOwnProperty.call(rec.values, path)) { + const { [path]: _, ...rest } = rec.values; + rec.values = rest; + await promisifyRequest(store.put(rec)); + } + } + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +export async function renameEntryInAllLanguages(projectId: string, oldPath: string, newPath: string): Promise { + const db = await openDb(); + try { + const tx = db.transaction(STORE_TRANSLATIONS, "readwrite"); + 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); + for (const rec of records) { + const values = rec.values ?? {}; + if (Object.prototype.hasOwnProperty.call(values, oldPath)) { + const val = values[oldPath]; + const updated = { ...values }; + delete updated[oldPath]; + updated[newPath] = val; + rec.values = updated; + await promisifyRequest(store.put(rec)); + } + } + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} diff --git a/src/lib/i18n-structure.ts b/src/lib/i18n-structure.ts index 5bbb118..c2532fd 100644 --- a/src/lib/i18n-structure.ts +++ b/src/lib/i18n-structure.ts @@ -83,4 +83,74 @@ export function unflattenValues(values: Record): Record c.key === seg && c.type === "group"); + if (!next) return null; + parent = next; + } + const leafKey = segments[segments.length - 1]; + if (leafKey == null) return null; + const index = (parent.children ?? []).findIndex((c) => c.key === leafKey); + if (index < 0) return null; + return { parent, index }; +} + +export function insertEntrySibling( + root: StructureNode, + targetPath: string, + position: "above" | "below", + newKey: string +): StructureNode { + const cloned = cloneNode(root); + const info = findParentAndIndex(cloned, targetPath); + if (!info) return cloned; + const { parent, index } = info; + if (!parent.children) parent.children = []; + const exists = parent.children.some((c) => c.key === newKey); + if (exists) throw new Error("同级已存在同名条目"); + const insertIndex = position === "above" ? index : index + 1; + parent.children.splice(insertIndex, 0, { key: newKey, type: "entry" }); + return cloned; +} + +export function removeEntryAtPath(root: StructureNode, path: string): StructureNode { + const cloned = cloneNode(root); + const info = findParentAndIndex(cloned, path); + if (!info) return cloned; + const { parent, index } = info; + parent.children?.splice(index, 1); + return cloned; +} + +export function renameEntryAtPath(root: StructureNode, path: string, newKey: string): { root: StructureNode; newPath: string } { + const cloned = cloneNode(root); + const info = findParentAndIndex(cloned, path); + if (!info) return { root: cloned, newPath: path }; + const { parent, index } = info; + const siblings = parent.children ?? []; + if (siblings.some((c, i) => i !== index && c.key === newKey)) { + throw new Error("同级已存在同名条目"); + } + const old = siblings[index]!; + siblings[index] = old.type === "group" ? { ...old, key: newKey } : { key: newKey, type: "entry" }; + const segments = getSegments(path); + segments[segments.length - 1] = newKey; + return { root: cloned, newPath: segments.join(".") }; +} + diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index a8e63e2..cf775c9 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type React from "react"; import { Link, useParams } from "react-router"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,12 +10,24 @@ import { type Project, type ProjectStructure, getLanguageTranslations, + upsertStructure, + deleteEntryFromAllLanguages, + renameEntryInAllLanguages, } 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 { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure"; import { ImportLanguageModal } from "@/components/biz/import-language-modal"; import { ExportLanguageModal } from "@/components/biz/export-language-modal"; +import { EntryNameModal } from "@/components/biz/entry-name-modal"; +import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; export default function Editor() { const { id: projectId } = useParams(); @@ -25,6 +38,50 @@ export default function Editor() { const [pageError, setPageError] = useState(null); const [importOpen, setImportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); + const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null); + const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null); + const virtuosoRef = useRef(null); + const scrollerRootRef = useRef(null); + const [query, setQuery] = useState(""); + const [caseSensitive, setCaseSensitive] = useState(false); + const [fullMatch, setFullMatch] = useState(false); + + function highlightRow(index: number) { + const tryFindAndAnimate = (attempt = 0) => { + const root = scrollerRootRef.current as HTMLElement | null; + if (!root) return; + const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null; + if (row) { + row.animate( + [ + { backgroundColor: "rgba(250, 204, 21, 0.6)" }, + { backgroundColor: "transparent" }, + { backgroundColor: "rgba(250, 204, 21, 0.6)" }, + { backgroundColor: "transparent" }, + ], + { duration: 1200, easing: "ease-in-out" } + ); + return; + } + if (attempt < 10) { + setTimeout(() => tryFindAndAnimate(attempt + 1), 50); + } + }; + // 等一帧,确保滚动定位后的 DOM 稳定 + requestAnimationFrame(() => tryFindAndAnimate(0)); + } + + function scrollToQuery() { + if (!query) return; + const idx = entries.findIndex((e) => { + const hay = caseSensitive ? e.path : e.path.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + return fullMatch ? hay === needle : hay.includes(needle); + }); + if (idx >= 0) { + virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) }); + } + } async function refresh() { if (!projectId) return; @@ -63,6 +120,107 @@ export default function Editor() { onError: (m) => setPageError(m), }); + const colWidth = useMemo(() => `${100 / (languages.length + 2)}%`, [languages.length]); + + const virtuosoComponents = useMemo(() => ({ + Table: (props: React.TableHTMLAttributes) => , + TableHead: (props: React.HTMLAttributes) => , + TableRow: (props: React.HTMLAttributes) => , + TableBody: (props: React.HTMLAttributes) => , + TableFoot: (props: React.HTMLAttributes) => , + }), []); + + const headerContent = useCallback(() => ( + + + {languages.map((lang) => ( + + ))} + + + ), [languages, colWidth]); + + const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { + return ( + <> + + {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 ( + + ); + })} + + + ); + }, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]); + useEffect(() => { if (!projectId || languages.length === 0) return; let cancelled = false; @@ -124,56 +282,35 @@ export default function Editor() { ) : (
-
-
翻译条目名称{lang}操作
{entry.path} + {isEditing ? ( + inline.setEditingValue(e.target.value)} + onBlur={inline.saveEdit} + onKeyDown={inline.handleKeyDown} + disabled={isSaving} + className="h-8" + /> + ) : ( + + )} + + + + + + + setAddModal({ open: true, path: entry.path, position: "below" })}> + 在下面新增 + + setAddModal({ open: true, path: entry.path, position: "above" })}> + 在上面新增 + + + { + if (!projectId || !structure) return; + if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return; + try { + const nextRoot = removeEntryAtPath(structure.root, entry.path); + await upsertStructure({ projectId, root: nextRoot }); + await deleteEntryFromAllLanguages(projectId, entry.path); + setStructure({ projectId, root: nextRoot }); + setValuesByLang((old) => { + const copy: typeof old = {}; + for (const [langKey, vals] of Object.entries(old)) { + const rest = { ...vals } as Record; + delete rest[entry.path]; + copy[langKey] = rest; + } + return copy; + }); + } catch (e) { + setPageError((e as Error)?.message ?? "删除失败"); + } + }} + > + 删除 + + setRenameModal({ open: true, path: entry.path })}> + 重命名 + + + +
- - - - {languages.map((lang) => ( - - ))} - - - - {entries.map((entry) => ( - - - {languages.map((lang) => { - const isEditing = inline.isEditingCell(entry.path, lang); - const isSaving = inline.isSavingCell(entry.path, lang); - const displayValue = inline.getDisplayValue(entry.path, lang); - return ( - - ); - })} - - ))} - -
翻译条目名称{lang}
{entry.path} - {isEditing ? ( - inline.setEditingValue(e.target.value)} - onBlur={inline.saveEdit} - onKeyDown={inline.handleKeyDown} - disabled={isSaving} - className="h-8" - /> - ) : ( - - )} -
+
+ setQuery(e.target.value)} /> + + + +
+
+ entry.path} + scrollerRef={(el) => { scrollerRootRef.current = el; }} + />
)} @@ -191,6 +328,56 @@ export default function Editor() { onOpenChange={setExportOpen} languages={languages} valuesByLang={valuesByLang} + orderedPaths={entries.map((e) => e.path)} + /> + + setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} + title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"} + placeholder="请输入条目名称(不含点)" + onConfirm={async (name) => { + if (!projectId || !structure || !addModal) return; + const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name); + await upsertStructure({ projectId, root: nextRoot }); + setStructure({ projectId, root: nextRoot }); + }} + validate={(name) => { + // 同级重名校验在结构函数中也会做,这里做基础提示即可 + if (name.includes('.')) return "名称不能包含 '.'"; + return null; + }} + /> + + setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))} + title="重命名条目" + placeholder="请输入新名称(不含点)" + defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''} + onConfirm={async (newName) => { + if (!projectId || !structure || !renameModal) return; + const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName); + await upsertStructure({ projectId, root: nextRoot }); + await renameEntryInAllLanguages(projectId, renameModal.path, newPath); + setStructure({ projectId, root: nextRoot }); + setValuesByLang((old) => { + const copy: typeof old = {}; + for (const [langKey, vals] of Object.entries(old)) { + if (Object.prototype.hasOwnProperty.call(vals, renameModal.path)) { + const { [renameModal.path]: val, ...rest } = vals; + copy[langKey] = { ...rest, [newPath]: val }; + } else { + copy[langKey] = vals; + } + } + return copy; + }); + }} + validate={(name) => { + if (name.includes('.')) return "名称不能包含 '.'"; + return null; + }} /> );