Phase 2: Code-Splitting + Virtual Scrolling (Tasks 2.1-2.7)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# LeoCRM Deployment Guide
|
||||
|
||||
## Coolify Deployment
|
||||
|
||||
### Application Info
|
||||
- **Coolify App UUID:** `stvabl4vaqru7jclx4ittzr3`
|
||||
- **App ID:** 12
|
||||
- **Domain:** `https://crm.media-on.de`
|
||||
- **Git Repo:** `https://forgejo.media-on.de/Leopoldadmin/leocrm.git`
|
||||
- **Branch:** `main`
|
||||
- **Build Pack:** Dockerfile (Multi-Stage: Frontend + Backend)
|
||||
- **Health Check:** `GET /api/v1/health` → 200
|
||||
|
||||
### Deploy Process
|
||||
1. **Code pushen:** `git push origin main`
|
||||
2. **Coolify Deploy triggern:**
|
||||
- Option A: Coolify Dashboard → `https://server.media-on.de` → App → Deploy
|
||||
- Option B: API: `POST https://server.media-on.de/api/v1/deploy` mit `{"uuid": "stvabl4vaqru7jclx4ittzr3"}`
|
||||
3. **Build dauert ~2-5 Min** (Multi-Stage: npm install + vite build + pip install + runtime)
|
||||
4. **Container wird automatisch ausgetauscht** wenn Build erfolgreich
|
||||
|
||||
### Manueller Build (Fallback)
|
||||
Wenn Coolify den Build nicht ausführt:
|
||||
```bash
|
||||
ssh root@46.225.91.159
|
||||
cd /tmp && git clone https://Leopoldadmin:<token>@forgejo.media-on.de/Leopoldadmin/leocrm.git leocrm-build
|
||||
cd leocrm-build
|
||||
docker build -t stvabl4vaqru7jclx4ittzr3:<commit-hash> .
|
||||
cd /data/coolify/applications/stvabl4vaqru7jclx4ittzr3
|
||||
# docker-compose.yaml Image-Tag aktualisieren
|
||||
# .env SOURCE_COMMIT aktualisieren
|
||||
docker compose up -d
|
||||
docker stop <old-container> && docker rm <old-container>
|
||||
```
|
||||
|
||||
### Bekannte Probleme
|
||||
- **Build schlägt fehl bei JSX/TS-Fehlern:** Vite-Build bricht ab → Coolify zeigt FAIL nach ~30s
|
||||
- **docker-compose.yaml wird VOR dem Build aktualisiert:** Wenn Build fehlschlägt, bleibt nicht-existentes Image in der Config
|
||||
- **Coolify Queue kann hängen:** Manchmal wird Deploy gequeued aber nicht verarbeitet — dann manuell bauen
|
||||
|
||||
### Pre-Deploy Checklist
|
||||
- [ ] `npx tsc --noEmit` — keine neuen TSC-Fehler (pre-existing Dms.tsx errors sind OK)
|
||||
- [ ] `npx vite build` — Frontend-Build erfolgreich
|
||||
- [ ] `git push origin main` — Code auf Forgejo
|
||||
- [ ] Coolify Deploy triggern
|
||||
- [ ] Health-Check: `curl https://crm.media-on.de/api/v1/health`
|
||||
|
||||
### Server Info
|
||||
- **Host:** `46.225.91.159` (coolify-01)
|
||||
- **SSH Key:** `/a0/usr/workdir/.ssh/coolify-01-root`
|
||||
- **Coolify API Token:** `2|UnMMp2WYbXFJCrOuZ1yqSK96hMooPTAfAJPIXiQc1e47154e`
|
||||
- **Coolify Dashboard:** `https://server.media-on.de`
|
||||
- **PostgreSQL:** `crm-postgres` container (pgvector/pgvector:pg16)
|
||||
- **Redis:** `crm-redis` container
|
||||
Generated
+345
@@ -11,6 +11,7 @@
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@tanstack/react-query": "^5.56.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"@tiptap/extension-color": "^3.28.0",
|
||||
"@tiptap/extension-image": "^3.28.0",
|
||||
"@tiptap/extension-link": "^3.28.0",
|
||||
@@ -52,6 +53,7 @@
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1391,6 +1393,22 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz",
|
||||
"integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
@@ -1403,6 +1421,15 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz",
|
||||
"integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -2618,6 +2645,84 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2800,6 +2905,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -3178,6 +3292,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -3499,6 +3622,29 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/import-from-esm": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz",
|
||||
"integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"import-meta-resolve": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20"
|
||||
}
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
@@ -3571,6 +3717,21 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3636,6 +3797,18 @@
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -4887,6 +5060,23 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
|
||||
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"define-lazy-prop": "^2.0.0",
|
||||
"is-docker": "^2.1.1",
|
||||
"is-wsl": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
@@ -5595,6 +5785,15 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -5670,6 +5869,48 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-plugin-visualizer": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz",
|
||||
"integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"open": "^8.4.0",
|
||||
"picomatch": "^4.0.2",
|
||||
"source-map": "^0.7.4",
|
||||
"yargs": "^17.5.1"
|
||||
},
|
||||
"bin": {
|
||||
"rollup-plugin-visualizer": "dist/bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rolldown": "1.x",
|
||||
"rollup": "2.x || 3.x || 4.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/rollup-plugin-visualizer/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
@@ -5778,6 +6019,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6165,6 +6415,15 @@
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -6448,6 +6707,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-bundle-visualizer": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-bundle-visualizer/-/vite-bundle-visualizer-1.2.1.tgz",
|
||||
"integrity": "sha512-cwz/Pg6+95YbgIDp+RPwEToc4TKxfsFWSG/tsl2DSZd9YZicUag1tQXjJ5xcL7ydvEoaC2FOZeaXOU60t9BRXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"import-from-esm": "^1.3.3",
|
||||
"rollup-plugin-visualizer": "^5.11.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"vite-bundle-visualizer": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
||||
@@ -6765,12 +7042,80 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
||||
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@tanstack/react-query": "^5.56.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"@tiptap/extension-color": "^3.28.0",
|
||||
"@tiptap/extension-image": "^3.28.0",
|
||||
"@tiptap/extension-link": "^3.28.0",
|
||||
@@ -56,6 +57,7 @@
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
@@ -75,6 +76,18 @@ export function ContactList({
|
||||
onSortChange,
|
||||
}: ContactListProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-skip virtualization for small datasets
|
||||
const shouldVirtualize = contacts.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? contacts.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => viewMode === 'cards' ? 120 : 56,
|
||||
overscan: 8,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -105,43 +118,67 @@ export function ContactList({
|
||||
|
||||
// ── List View ──
|
||||
if (viewMode === 'list') {
|
||||
const renderContactItem = (contact: UnifiedContact) => (
|
||||
<li key={contact.id}>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
|
||||
selectedContactId === contact.id
|
||||
? 'bg-primary-50'
|
||||
: 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 truncate">
|
||||
{getEmail(contact)} · {getCity(contact)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-list-view">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{contacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
|
||||
selectedContactId === contact.id
|
||||
? 'bg-primary-50'
|
||||
: 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{shouldVirtualize ? (
|
||||
<ul className="divide-y divide-secondary-100" role="list" style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={contact.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderContactItem(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 truncate">
|
||||
{getEmail(contact)} · {getCity(contact)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{contacts.map((contact) => renderContactItem(contact))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
@@ -160,11 +197,45 @@ export function ContactList({
|
||||
if (sortBy !== field) return null;
|
||||
return sortOrder === 'asc' ? ' ▲' : ' ▼';
|
||||
};
|
||||
|
||||
const renderContactRow = (contact: UnifiedContact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
|
||||
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getTags(contact).slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-table-view">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto" style={shouldVirtualize ? { maxHeight: '70vh' } : undefined}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white border-b border-secondary-200">
|
||||
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-secondary-600 w-10">
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
|
||||
@@ -202,38 +273,30 @@ export function ContactList({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{contacts.map((contact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
|
||||
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getTags(contact).slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{shouldVirtualize ? (
|
||||
<>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={contact.id}>
|
||||
{virtualRow.index === 0 && (
|
||||
<tr style={{ height: virtualRow.start }}>
|
||||
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
{renderContactRow(contact)}
|
||||
{virtualRow.index === rowVirtualizer.getVirtualItems().length - 1 && (
|
||||
<tr style={{ height: rowVirtualizer.getTotalSize() - virtualRow.end }}>
|
||||
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
contacts.map((contact) => renderContactRow(contact))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -249,48 +312,74 @@ export function ContactList({
|
||||
}
|
||||
|
||||
// ── Cards View ──
|
||||
const renderContactCard = (contact: UnifiedContact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
|
||||
selectedContactId === contact.id
|
||||
? 'border-primary-300 bg-primary-50'
|
||||
: 'border-secondary-200 bg-white hover:bg-secondary-50',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
</div>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 space-y-0.5">
|
||||
<div className="truncate">{getEmail(contact)}</div>
|
||||
<div className="truncate">{getPhone(contact)}</div>
|
||||
<div className="truncate">{getCity(contact)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-cards-view">
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className={clsx(
|
||||
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
|
||||
selectedContactId === contact.id
|
||||
? 'border-primary-300 bg-primary-50'
|
||||
: 'border-secondary-200 bg-white hover:bg-secondary-50',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
||||
{getInitials(contact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
|
||||
{shouldVirtualize ? (
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const contact = contacts[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={contact.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderContactCard(contact)}
|
||||
</div>
|
||||
<TypeBadge type={contact.type} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 space-y-0.5">
|
||||
<div className="truncate">{getEmail(contact)}</div>
|
||||
<div className="truncate">{getPhone(contact)}</div>
|
||||
<div className="truncate">{getCity(contact)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{contacts.map((contact) => renderContactCard(contact))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
import { Archive, ChevronDown, ChevronUp, Eye, FileText, Image, Loader2, Share2, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -184,6 +185,17 @@ export function FileExplorer({
|
||||
return sorted;
|
||||
}, [files, sortBy, sortOrder]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const shouldVirtualize = sortedFiles.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? sortedFiles.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 10,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
||||
@@ -204,8 +216,41 @@ export function FileExplorer({
|
||||
|
||||
// ─── List View ───
|
||||
if (viewMode === 'list') {
|
||||
const renderFileRow = (file: DmsFile) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
const isActive = selectedFile?.id === file.id;
|
||||
return (
|
||||
<li key={file.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
|
||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
data-testid={`file-row-${file.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<span className="text-sm font-medium text-secondary-900 truncate flex-1" title={file.name}>{file.name}</span>
|
||||
<span className="text-xs text-secondary-500 flex-shrink-0">{formatFileSize(getFileSize(file))}</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span>
|
||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full" data-testid="file-explorer-list">
|
||||
<div className="overflow-y-auto h-full" data-testid="file-explorer-list" ref={scrollRef}>
|
||||
{/* Sort header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
|
||||
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||
@@ -214,48 +259,79 @@ export function FileExplorer({
|
||||
<span className="text-secondary-300">|</span>
|
||||
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||
</div>
|
||||
<ul className="divide-y divide-secondary-50">
|
||||
{sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
const isActive = selectedFile?.id === file.id;
|
||||
return (
|
||||
<li key={file.id}>
|
||||
{shouldVirtualize ? (
|
||||
<ul className="divide-y divide-secondary-50" style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = sortedFiles[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
|
||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
data-testid={`file-row-${file.id}`}
|
||||
key={file.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<span className="text-sm font-medium text-secondary-900 truncate flex-1" title={file.name}>{file.name}</span>
|
||||
<span className="text-xs text-secondary-500 flex-shrink-0">{formatFileSize(getFileSize(file))}</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span>
|
||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
||||
{renderFileRow(file)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-50">
|
||||
{sortedFiles.map((file) => renderFileRow(file))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table View ───
|
||||
if (viewMode === 'table') {
|
||||
const renderFileRow = (file: DmsFile) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
const isActive = selectedFile?.id === file.id;
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'cursor-pointer motion-safe:transition-colors',
|
||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
data-testid={`file-row-${file.id}`}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{formatFileSize(getFileSize(file))}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{formatDate(file.updated_at || file.created_at)}</td>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full" data-testid="file-explorer-table">
|
||||
<div className="overflow-auto h-full" data-testid="file-explorer-table" ref={scrollRef} style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
||||
<tr>
|
||||
@@ -283,45 +359,30 @@ export function FileExplorer({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-50">
|
||||
{sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
const isActive = selectedFile?.id === file.id;
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'cursor-pointer motion-safe:transition-colors',
|
||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
data-testid={`file-row-${file.id}`}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{formatFileSize(getFileSize(file))}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</td>
|
||||
<td className="px-3 py-2 text-secondary-600">{formatDate(file.updated_at || file.created_at)}</td>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{shouldVirtualize ? (
|
||||
<>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, idx) => {
|
||||
const file = sortedFiles[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={file.id}>
|
||||
{idx === 0 && virtualRow.start > 0 && (
|
||||
<tr style={{ height: virtualRow.start }}>
|
||||
<td colSpan={6} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
{renderFileRow(file)}
|
||||
{idx === rowVirtualizer.getVirtualItems().length - 1 && (
|
||||
<tr style={{ height: rowVirtualizer.getTotalSize() - virtualRow.end }}>
|
||||
<td colSpan={6} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
sortedFiles.map((file) => renderFileRow(file))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Displays files with icons, names, sizes, and action buttons.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
import { Eye, FileText, Share2, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -33,11 +34,22 @@ export function FileGrid({
|
||||
loading = false,
|
||||
}: FileGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sortedFiles = useMemo(() => {
|
||||
return [...files].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [files]);
|
||||
|
||||
const shouldVirtualize = sortedFiles.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? sortedFiles.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 140,
|
||||
overscan: 8,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid gap-3 items-start content-start" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }} data-testid="file-grid-loading">
|
||||
@@ -62,67 +74,148 @@ export function FileGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 items-start content-start" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }} data-testid="file-grid">
|
||||
{sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="grid gap-3 items-start content-start overflow-y-auto h-full"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}
|
||||
data-testid="file-grid"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative', gridColumn: '1 / -1' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = sortedFiles[virtualRow.index];
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.preview')}
|
||||
title={t('dms.preview')}
|
||||
>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.share')}
|
||||
title={t('dms.share')}
|
||||
>
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.delete')}
|
||||
title={t('dms.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelect(file.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.preview')}
|
||||
title={t('dms.preview')}
|
||||
>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.share')}
|
||||
title={t('dms.share')}
|
||||
>
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.delete')}
|
||||
title={t('dms.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.preview')}
|
||||
title={t('dms.preview')}
|
||||
>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.share')}
|
||||
title={t('dms.share')}
|
||||
>
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
|
||||
aria-label={t('dms.delete')}
|
||||
title={t('dms.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Mail list component with pagination.
|
||||
* Mail list component with pagination and virtual scrolling.
|
||||
* Shows list of mails in selected folder with seen/flagged indicators.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { Mail } from '@/api/mail';
|
||||
import { decodeMimeHeader } from '@/api/mail';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
@@ -53,6 +54,18 @@ export function MailList({
|
||||
const { t } = useTranslation();
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
||||
const scrollRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Auto-skip virtualization for small datasets
|
||||
const shouldVirtualize = mails.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? mails.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 80, // approx mail item height
|
||||
overscan: 8,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newSortBy = e.target.value as 'date' | 'from' | 'subject';
|
||||
@@ -83,6 +96,75 @@ export function MailList({
|
||||
);
|
||||
}
|
||||
|
||||
const renderMailItem = (mail: Mail) => (
|
||||
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center pt-3 pl-3 md:pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMailIds.has(mail.id)}
|
||||
onChange={() => onToggleSelect(mail.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('mail.selectMail')}
|
||||
data-testid={`mail-checkbox-${mail.id}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
className={clsx(
|
||||
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
mail.id === selectedMailId && 'bg-primary-50',
|
||||
!mail.is_seen && 'font-semibold',
|
||||
)}
|
||||
aria-selected={mail.id === selectedMailId}
|
||||
data-testid={`mail-item-${mail.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{!mail.is_seen && (
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
|
||||
)}
|
||||
{mail.is_flagged && (
|
||||
<span className="flex-shrink-0" aria-label={t('mail.flagged')}>
|
||||
{mail.flag_type === 'star' && <span className="text-sm">⭐</span>}
|
||||
{mail.flag_type === 'flag' && <span className="text-sm">🚩</span>}
|
||||
{mail.flag_type === 'bookmark' && <span className="text-sm">🔖</span>}
|
||||
{mail.flag_type === 'important' && <span className="text-sm">❗</span>}
|
||||
{mail.flag_type === 'question' && <span className="text-sm">❓</span>}
|
||||
{(!mail.flag_type || mail.flag_type === '') && (
|
||||
<Star className="w-4 h-4 text-warning-500" fill="currentColor" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{mail.has_attachments && (
|
||||
<Paperclip className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
||||
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
||||
</div>
|
||||
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
||||
</p>
|
||||
{mail.labels && mail.labels.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{mail.labels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
|
||||
{/* Select-all + Sort header in one row */}
|
||||
@@ -133,76 +215,45 @@ export function MailList({
|
||||
)}
|
||||
</div>
|
||||
{/* Mail list — scrolls, pagination stays fixed */}
|
||||
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
|
||||
{mails.map((mail) => (
|
||||
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center pt-3 pl-3 md:pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMailIds.has(mail.id)}
|
||||
onChange={() => onToggleSelect(mail.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t('mail.selectMail')}
|
||||
data-testid={`mail-checkbox-${mail.id}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
className={clsx(
|
||||
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
mail.id === selectedMailId && 'bg-primary-50',
|
||||
!mail.is_seen && 'font-semibold',
|
||||
)}
|
||||
aria-selected={mail.id === selectedMailId}
|
||||
data-testid={`mail-item-${mail.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{!mail.is_seen && (
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
|
||||
)}
|
||||
{mail.is_flagged && (
|
||||
<span className="flex-shrink-0" aria-label={t('mail.flagged')}>
|
||||
{mail.flag_type === 'star' && <span className="text-sm">⭐</span>}
|
||||
{mail.flag_type === 'flag' && <span className="text-sm">🚩</span>}
|
||||
{mail.flag_type === 'bookmark' && <span className="text-sm">🔖</span>}
|
||||
{mail.flag_type === 'important' && <span className="text-sm">❗</span>}
|
||||
{mail.flag_type === 'question' && <span className="text-sm">❓</span>}
|
||||
{(!mail.flag_type || mail.flag_type === '') && (
|
||||
<Star className="w-4 h-4 text-warning-500" fill="currentColor" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{mail.has_attachments && (
|
||||
<Paperclip className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
||||
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
||||
</div>
|
||||
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
||||
</p>
|
||||
{mail.labels && mail.labels.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{mail.labels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldVirtualize ? (
|
||||
<ul
|
||||
ref={scrollRef}
|
||||
className="divide-y divide-secondary-100 flex-1 overflow-y-auto"
|
||||
role="list"
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
<li
|
||||
key="__virtual-spacer"
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const mail = mails[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={mail.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderMailItem(mail)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
|
||||
{mails.map((mail) => renderMailItem(mail))}
|
||||
</ul>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Pagination
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -30,6 +31,7 @@ export interface DataGridProps<T> {
|
||||
globalFilter?: string;
|
||||
onGlobalFilterChange?: (value: string) => void;
|
||||
testId?: string;
|
||||
virtualized?: boolean;
|
||||
}
|
||||
|
||||
export function DataGrid<T extends Record<string, any>>({
|
||||
@@ -44,9 +46,11 @@ export function DataGrid<T extends Record<string, any>>({
|
||||
loading = false,
|
||||
enableSorting = true,
|
||||
testId,
|
||||
virtualized = true,
|
||||
}: DataGridProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -60,11 +64,59 @@ export function DataGrid<T extends Record<string, any>>({
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Auto-skip virtualization for small datasets
|
||||
const shouldVirtualize = virtualized && data.length >= 50;
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? rows.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 53, // approx row height
|
||||
overscan: 10,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : [];
|
||||
const totalHeight = shouldVirtualize ? rowVirtualizer.getTotalSize() : 0;
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom = virtualRows.length > 0 ? totalHeight - virtualRows[virtualRows.length - 1].end : 0;
|
||||
|
||||
const renderRow = useCallback(
|
||||
(row: typeof rows[number]) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={clsx(
|
||||
'hover:bg-secondary-50 motion-safe:transition-colors',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onKeyDown={onRowClick ? (e) => {
|
||||
if (e.key === 'Enter') onRowClick(row.original);
|
||||
} : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-6 py-4 text-sm text-secondary-900">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
),
|
||||
[onRowClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 overflow-hidden" data-testid={testId}>
|
||||
<div className="overflow-x-auto" role="region" aria-label="Data grid">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto"
|
||||
role="region"
|
||||
aria-label="Data grid"
|
||||
style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-secondary-200">
|
||||
<thead className="bg-secondary-50">
|
||||
<thead className="bg-secondary-50 sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
@@ -114,33 +166,31 @@ export function DataGrid<T extends Record<string, any>>({
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
|
||||
{emptyMessage || t('table.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
) : shouldVirtualize ? (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<tr key="__padding-top" style={{ height: paddingTop }}>
|
||||
<td colSpan={columns.length} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return renderRow(row);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr key="__padding-bottom" style={{ height: paddingBottom }}>
|
||||
<td colSpan={columns.length} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={clsx(
|
||||
'hover:bg-secondary-50 motion-safe:transition-colors',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onKeyDown={onRowClick ? (e) => {
|
||||
if (e.key === 'Enter') onRowClick(row.original);
|
||||
} : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-6 py-4 text-sm text-secondary-900">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
rows.map((row) => renderRow(row))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,35 +1,52 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { LoginPage } from '@/pages/Login';
|
||||
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
|
||||
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
|
||||
import { DashboardPage } from '@/pages/Dashboard';
|
||||
import { ContactsListPage } from '@/pages/ContactsList';
|
||||
import { AuditLogPage } from '@/pages/AuditLog';
|
||||
import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults';
|
||||
import { SettingsPage } from '@/pages/Settings';
|
||||
import { SettingsProfilePage } from '@/pages/SettingsProfile';
|
||||
import { SettingsRolesPage } from '@/pages/SettingsRoles';
|
||||
import { SettingsUsersPage } from '@/pages/SettingsUsers';
|
||||
import { SettingsGroupsPage } from '@/pages/SettingsGroups';
|
||||
import { SettingsPluginsPage } from '@/pages/SettingsPlugins';
|
||||
import { SettingsSystemPage } from '@/pages/SettingsSystem';
|
||||
import { SettingsCurrenciesPage } from '@/pages/SettingsCurrencies';
|
||||
import { SettingsTaxesPage } from '@/pages/SettingsTaxes';
|
||||
import { SettingsSequencesPage } from '@/pages/SettingsSequences';
|
||||
import { CalendarPage } from '@/pages/Calendar';
|
||||
import { CalendarKanbanPage } from '@/pages/CalendarKanban';
|
||||
import { DmsPage } from '@/pages/Dms';
|
||||
import { DmsTrashPage } from '@/pages/DmsTrash';
|
||||
import { MailPage } from '@/pages/Mail';
|
||||
import { MailSettingsPage } from '@/pages/MailSettings';
|
||||
import { SettingsNotificationsPage } from '@/pages/SettingsNotifications';
|
||||
import { AIAssistantPage } from '@/pages/AIAssistant';
|
||||
import { AISettingsPage } from '@/pages/AISettings';
|
||||
import { ProactiveAISettings } from '@/pages/ProactiveAISettings';
|
||||
import { SettingsThemePage } from '@/pages/SettingsTheme';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// Lazy-loaded pages (code-splitting)
|
||||
const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage })));
|
||||
const ContactsListPage = React.lazy(() => import('@/pages/ContactsList').then(m => ({ default: m.ContactsListPage })));
|
||||
const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage })));
|
||||
const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage })));
|
||||
const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage })));
|
||||
const SettingsProfilePage = React.lazy(() => import('@/pages/SettingsProfile').then(m => ({ default: m.SettingsProfilePage })));
|
||||
const SettingsRolesPage = React.lazy(() => import('@/pages/SettingsRoles').then(m => ({ default: m.SettingsRolesPage })));
|
||||
const SettingsUsersPage = React.lazy(() => import('@/pages/SettingsUsers').then(m => ({ default: m.SettingsUsersPage })));
|
||||
const SettingsGroupsPage = React.lazy(() => import('@/pages/SettingsGroups').then(m => ({ default: m.SettingsGroupsPage })));
|
||||
const SettingsPluginsPage = React.lazy(() => import('@/pages/SettingsPlugins').then(m => ({ default: m.SettingsPluginsPage })));
|
||||
const SettingsSystemPage = React.lazy(() => import('@/pages/SettingsSystem').then(m => ({ default: m.SettingsSystemPage })));
|
||||
const SettingsCurrenciesPage = React.lazy(() => import('@/pages/SettingsCurrencies').then(m => ({ default: m.SettingsCurrenciesPage })));
|
||||
const SettingsTaxesPage = React.lazy(() => import('@/pages/SettingsTaxes').then(m => ({ default: m.SettingsTaxesPage })));
|
||||
const SettingsSequencesPage = React.lazy(() => import('@/pages/SettingsSequences').then(m => ({ default: m.SettingsSequencesPage })));
|
||||
const CalendarPage = React.lazy(() => import('@/pages/Calendar').then(m => ({ default: m.CalendarPage })));
|
||||
const CalendarKanbanPage = React.lazy(() => import('@/pages/CalendarKanban').then(m => ({ default: m.CalendarKanbanPage })));
|
||||
const DmsPage = React.lazy(() => import('@/pages/Dms').then(m => ({ default: m.DmsPage })));
|
||||
const DmsTrashPage = React.lazy(() => import('@/pages/DmsTrash').then(m => ({ default: m.DmsTrashPage })));
|
||||
const MailPage = React.lazy(() => import('@/pages/Mail').then(m => ({ default: m.MailPage })));
|
||||
const MailSettingsPage = React.lazy(() => import('@/pages/MailSettings').then(m => ({ default: m.MailSettingsPage })));
|
||||
const SettingsNotificationsPage = React.lazy(() => import('@/pages/SettingsNotifications').then(m => ({ default: m.SettingsNotificationsPage })));
|
||||
const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m => ({ default: m.AIAssistantPage })));
|
||||
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
|
||||
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
|
||||
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label="Seite wird geladen">
|
||||
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wrap a lazy element in Suspense with PageLoader fallback */
|
||||
function withSuspense(element: React.ReactElement) {
|
||||
return <Suspense fallback={<PageLoader />}>{element}</Suspense>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -51,36 +68,36 @@ const router = createBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/contacts', element: <ContactsListPage /> },
|
||||
{ path: '/audit-log', element: <AuditLogPage /> },
|
||||
{ path: '/search', element: <GlobalSearchResultsPage /> },
|
||||
{ path: '/calendar', element: <CalendarPage /> },
|
||||
{ path: '/calendar/kanban', element: <CalendarKanbanPage /> },
|
||||
{ path: '/dms', element: <DmsPage /> },
|
||||
{ path: '/dms/trash', element: <DmsTrashPage /> },
|
||||
{ path: '/mail', element: <MailPage /> },
|
||||
{ path: '/mail/settings', element: <MailSettingsPage /> },
|
||||
{ path: '/ai-assistant', element: <AIAssistantPage /> },
|
||||
{ path: '/profile', element: <SettingsProfilePage /> },
|
||||
{ path: '/', element: withSuspense(<DashboardPage />) },
|
||||
{ path: '/dashboard', element: withSuspense(<DashboardPage />) },
|
||||
{ path: '/contacts', element: withSuspense(<ContactsListPage />) },
|
||||
{ path: '/audit-log', element: withSuspense(<AuditLogPage />) },
|
||||
{ path: '/search', element: withSuspense(<GlobalSearchResultsPage />) },
|
||||
{ path: '/calendar', element: withSuspense(<CalendarPage />) },
|
||||
{ path: '/calendar/kanban', element: withSuspense(<CalendarKanbanPage />) },
|
||||
{ path: '/dms', element: withSuspense(<DmsPage />) },
|
||||
{ path: '/dms/trash', element: withSuspense(<DmsTrashPage />) },
|
||||
{ path: '/mail', element: withSuspense(<MailPage />) },
|
||||
{ path: '/mail/settings', element: withSuspense(<MailSettingsPage />) },
|
||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
element: <SettingsPage />,
|
||||
element: withSuspense(<SettingsPage />),
|
||||
children: [
|
||||
{ path: 'roles', element: <SettingsRolesPage /> },
|
||||
{ path: 'users', element: <SettingsUsersPage /> },
|
||||
{ path: 'groups', element: <SettingsGroupsPage /> },
|
||||
{ path: 'plugins', element: <SettingsPluginsPage /> },
|
||||
{ path: 'system', element: <SettingsSystemPage /> },
|
||||
{ path: 'currencies', element: <SettingsCurrenciesPage /> },
|
||||
{ path: 'taxes', element: <SettingsTaxesPage /> },
|
||||
{ path: 'sequences', element: <SettingsSequencesPage /> },
|
||||
{ path: 'mail', element: <MailSettingsPage /> },
|
||||
{ path: 'notifications', element: <SettingsNotificationsPage /> },
|
||||
{ path: 'ai', element: <AISettingsPage /> },
|
||||
{ path: 'ai-proactive', element: <ProactiveAISettings /> },
|
||||
{ path: 'theme', element: <SettingsThemePage /> },
|
||||
{ path: 'roles', element: withSuspense(<SettingsRolesPage />) },
|
||||
{ path: 'users', element: withSuspense(<SettingsUsersPage />) },
|
||||
{ path: 'groups', element: withSuspense(<SettingsGroupsPage />) },
|
||||
{ path: 'plugins', element: withSuspense(<SettingsPluginsPage />) },
|
||||
{ path: 'system', element: withSuspense(<SettingsSystemPage />) },
|
||||
{ path: 'currencies', element: withSuspense(<SettingsCurrenciesPage />) },
|
||||
{ path: 'taxes', element: withSuspense(<SettingsTaxesPage />) },
|
||||
{ path: 'sequences', element: withSuspense(<SettingsSequencesPage />) },
|
||||
{ path: 'mail', element: withSuspense(<MailSettingsPage />) },
|
||||
{ path: 'notifications', element: withSuspense(<SettingsNotificationsPage />) },
|
||||
{ path: 'ai', element: withSuspense(<AISettingsPage />) },
|
||||
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
|
||||
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -18,6 +18,22 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('react-router')) return 'react-vendor';
|
||||
if (id.includes('react-dom')) return 'react-vendor';
|
||||
if (id.includes('/react/') || id.includes('\\react\\')) return 'react-vendor';
|
||||
if (id.includes('@tanstack')) return 'tanstack';
|
||||
if (id.includes('lucide-react') || id.includes('date-fns') || id.includes('clsx')) return 'ui';
|
||||
if (id.includes('i18next') || id.includes('react-i18next')) return 'i18n';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user