wip 20
This commit is contained in:
69
frontend/AGENTS.md
Normal file
69
frontend/AGENTS.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# AnthoLume Frontend Agent Guide
|
||||
|
||||
Read this file for work in `frontend/`.
|
||||
Also follow the repository root guide at `../AGENTS.md`.
|
||||
|
||||
## 1) Stack
|
||||
|
||||
- Package manager: `bun`
|
||||
- Framework: React + Vite
|
||||
- Data fetching: React Query
|
||||
- API generation: Orval
|
||||
- Linting: ESLint + Tailwind plugin
|
||||
- Formatting: Prettier
|
||||
|
||||
## 2) Conventions
|
||||
|
||||
- Use local icon components from `src/icons/`.
|
||||
- Do not add external icon libraries.
|
||||
- Prefer generated types from `src/generated/model/` over `any`.
|
||||
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
||||
|
||||
## 3) Generated API client
|
||||
|
||||
- Do not edit `src/generated/**` directly.
|
||||
- Edit `../api/v1/openapi.yaml` and regenerate instead.
|
||||
- Regenerate with: `bun run generate:api`
|
||||
|
||||
### Important behavior
|
||||
|
||||
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
||||
- Do not assume non-2xx responses throw.
|
||||
- Check `response.status` and response shape before treating a request as successful.
|
||||
|
||||
## 4) Auth / Query State
|
||||
|
||||
- When changing auth flows, account for React Query cache state.
|
||||
- Pay special attention to `/api/v1/auth/me`.
|
||||
- A local auth state update may not be enough if cached query data still reflects a previous auth state.
|
||||
|
||||
## 5) Commands
|
||||
|
||||
- Lint: `bun run lint`
|
||||
- Typecheck: `bun run typecheck`
|
||||
- Lint fix: `bun run lint:fix`
|
||||
- Format check: `bun run format`
|
||||
- Format fix: `bun run format:fix`
|
||||
- Build: `bun run build`
|
||||
- Generate API client: `bun run generate:api`
|
||||
|
||||
## 6) Validation Notes
|
||||
|
||||
- ESLint ignores `src/generated/**`.
|
||||
- Frontend unit tests use Vitest and live alongside source as `src/**/*.test.ts(x)`.
|
||||
- `bun run lint` includes test files but does not typecheck.
|
||||
- Use `bun run typecheck` to run TypeScript validation for app code and colocated tests without a full production build.
|
||||
- Run frontend tests with `bun run test`.
|
||||
- `bun run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
||||
- When possible, validate changed files directly before escalating to full-project fixes.
|
||||
|
||||
## 7) Updating This File
|
||||
|
||||
After completing a frontend task, update this file if you learned something general that would help future frontend agents.
|
||||
|
||||
Rules for updates:
|
||||
|
||||
- Add only frontend-wide guidance.
|
||||
- Do not record one-off task history.
|
||||
- Keep updates concise and action-oriented.
|
||||
- Prefer notes that prevent repeated mistakes.
|
||||
@@ -9,7 +9,6 @@
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"orval": "8.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -35,6 +34,7 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -277,6 +277,8 @@
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
|
||||
@@ -289,6 +291,10 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
@@ -325,6 +331,20 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -363,6 +383,8 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
@@ -397,6 +419,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -463,6 +487,8 @@
|
||||
|
||||
"es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
@@ -501,10 +527,14 @@
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
@@ -707,10 +737,10 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
|
||||
|
||||
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -759,6 +789,8 @@
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"orval": ["orval@8.5.3", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.5.3", "@orval/axios": "8.5.3", "@orval/core": "8.5.3", "@orval/fetch": "8.5.3", "@orval/hono": "8.5.3", "@orval/mcp": "8.5.3", "@orval/mock": "8.5.3", "@orval/query": "8.5.3", "@orval/solid-start": "8.5.3", "@orval/swr": "8.5.3", "@orval/zod": "8.5.3", "@scalar/json-magic": "^0.11.5", "@scalar/openapi-parser": "^0.24.13", "@scalar/openapi-types": "0.5.3", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/orval.mjs" }, "sha512-+8Es2ZR3tPthzAL27X1a9AlboqTQ/w9U/PhMkp4vsLA9OvdkpXr+9f8lCfJUV/wtdX+lXBDQ4imx42Em943JSg=="],
|
||||
@@ -887,12 +919,18 @@
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||
@@ -929,8 +967,14 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
@@ -975,6 +1019,8 @@
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@@ -985,6 +1031,8 @@
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate:api": "orval",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"format": "prettier --check src",
|
||||
"format:fix": "prettier --write src"
|
||||
"format:fix": "prettier --write src",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.16",
|
||||
@@ -42,6 +44,7 @@
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5"
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSVGGraphData } from './ReadingHistoryGraph';
|
||||
|
||||
// Test data matching Go test exactly
|
||||
|
||||
@@ -9,9 +9,6 @@ export interface SVGPoint {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates bezier control points for smooth curves
|
||||
*/
|
||||
function getSVGBezierOpposedLine(
|
||||
pointA: SVGPoint,
|
||||
pointB: SVGPoint
|
||||
@@ -19,7 +16,6 @@ function getSVGBezierOpposedLine(
|
||||
const lengthX = pointB.x - pointA.x;
|
||||
const lengthY = pointB.y - pointA.y;
|
||||
|
||||
// Go uses int() which truncates toward zero, JavaScript Math.trunc matches this
|
||||
return {
|
||||
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
||||
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
||||
@@ -32,7 +28,6 @@ function getBezierControlPoint(
|
||||
nextPoint: SVGPoint | null,
|
||||
isReverse: boolean
|
||||
): SVGPoint {
|
||||
// First / Last Point
|
||||
let pPrev = prevPoint;
|
||||
let pNext = nextPoint;
|
||||
if (!pPrev) {
|
||||
@@ -42,57 +37,49 @@ function getBezierControlPoint(
|
||||
pNext = currentPoint;
|
||||
}
|
||||
|
||||
// Modifiers
|
||||
const smoothingRatio: number = 0.2;
|
||||
const directionModifier: number = isReverse ? Math.PI : 0;
|
||||
const smoothingRatio = 0.2;
|
||||
const directionModifier = isReverse ? Math.PI : 0;
|
||||
|
||||
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
||||
const lineAngle: number = opposingLine.Angle + directionModifier;
|
||||
const lineLength: number = opposingLine.Length * smoothingRatio;
|
||||
const lineAngle = opposingLine.Angle + directionModifier;
|
||||
const lineLength = opposingLine.Length * smoothingRatio;
|
||||
|
||||
// Calculate Control Point - Go converts everything to int
|
||||
// Note: int(math.Cos(...) * lineLength) means truncate product, not truncate then multiply
|
||||
return {
|
||||
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
||||
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the bezier path for the graph
|
||||
*/
|
||||
function getSVGBezierPath(points: SVGPoint[]): string {
|
||||
if (points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let bezierSVGPath: string = '';
|
||||
let bezierSVGPath = '';
|
||||
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const point = points[index];
|
||||
if (!point) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
bezierSVGPath += `M ${point.x},${point.y}`;
|
||||
} else {
|
||||
const pointPlusOne = points[index + 1];
|
||||
const pointMinusOne = points[index - 1];
|
||||
const pointMinusTwo: SVGPoint | null = index - 2 >= 0 ? points[index - 2] : null;
|
||||
|
||||
const startControlPoint: SVGPoint = getBezierControlPoint(
|
||||
pointMinusOne,
|
||||
pointMinusTwo,
|
||||
point,
|
||||
false
|
||||
);
|
||||
const endControlPoint: SVGPoint = getBezierControlPoint(
|
||||
point,
|
||||
pointMinusOne,
|
||||
pointPlusOne || point,
|
||||
true
|
||||
);
|
||||
|
||||
// Go converts all coordinates to int
|
||||
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointMinusOne = points[index - 1];
|
||||
if (!pointMinusOne) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointPlusOne = points[index + 1] ?? point;
|
||||
const pointMinusTwo = index - 2 >= 0 ? (points[index - 2] ?? null) : null;
|
||||
|
||||
const startControlPoint = getBezierControlPoint(pointMinusOne, pointMinusTwo, point, false);
|
||||
const endControlPoint = getBezierControlPoint(point, pointMinusOne, pointPlusOne, true);
|
||||
|
||||
bezierSVGPath += ` C${startControlPoint.x},${startControlPoint.y} ${endControlPoint.x},${endControlPoint.y} ${point.x},${point.y}`;
|
||||
}
|
||||
|
||||
return bezierSVGPath;
|
||||
@@ -105,42 +92,35 @@ export interface SVGGraphData {
|
||||
Offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG Graph Data
|
||||
*/
|
||||
export function getSVGGraphData(
|
||||
inputData: GraphDataPoint[],
|
||||
svgWidth: number,
|
||||
svgHeight: number
|
||||
): SVGGraphData {
|
||||
// Derive Height
|
||||
let maxHeight: number = 0;
|
||||
let maxHeight = 0;
|
||||
for (const item of inputData) {
|
||||
if (item.minutes_read > maxHeight) {
|
||||
maxHeight = item.minutes_read;
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical Graph Real Estate
|
||||
const sizePercentage: number = 0.5;
|
||||
const sizePercentage = 0.5;
|
||||
const sizeRatio = maxHeight > 0 ? (svgHeight * sizePercentage) / maxHeight : 0;
|
||||
const blockOffset = inputData.length > 0 ? Math.floor(svgWidth / inputData.length) : 0;
|
||||
|
||||
// Scale Ratio -> Desired Height
|
||||
const sizeRatio: number = (svgHeight * sizePercentage) / maxHeight;
|
||||
|
||||
// Point Block Offset
|
||||
const blockOffset: number = Math.floor(svgWidth / inputData.length);
|
||||
|
||||
// Line & Bar Points
|
||||
const linePoints: SVGPoint[] = [];
|
||||
|
||||
// Bezier Fill Coordinates (Max X, Min X, Max Y)
|
||||
let maxBX: number = 0;
|
||||
let maxBY: number = 0;
|
||||
let minBX: number = 0;
|
||||
let maxBX = 0;
|
||||
let maxBY = 0;
|
||||
let minBX = 0;
|
||||
|
||||
for (let idx = 0; idx < inputData.length; idx++) {
|
||||
// Go uses int conversion
|
||||
const itemSize = Math.floor(inputData[idx].minutes_read * sizeRatio);
|
||||
const item = inputData[idx];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemSize = Math.floor(item.minutes_read * sizeRatio);
|
||||
const itemY = svgHeight - itemSize;
|
||||
const lineX = (idx + 1) * blockOffset;
|
||||
|
||||
@@ -162,7 +142,6 @@ export function getSVGGraphData(
|
||||
}
|
||||
}
|
||||
|
||||
// Return Data
|
||||
return {
|
||||
LinePoints: linePoints,
|
||||
BezierPath: getSVGBezierPath(linePoints),
|
||||
@@ -171,27 +150,14 @@ export function getSVGGraphData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to YYYY-MM-DD format (ISO-like)
|
||||
* Note: The date string from the API is already in YYYY-MM-DD format,
|
||||
* but since JavaScript Date parsing can add timezone offsets, we use UTC
|
||||
* methods to ensure we get the correct date.
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
// Use UTC methods to avoid timezone offset issues
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadingHistoryGraph component
|
||||
*
|
||||
* Displays a bezier curve graph of daily reading totals with hover tooltips.
|
||||
* Exact copy of Go template implementation.
|
||||
*/
|
||||
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||
const svgWidth = 800;
|
||||
const svgHeight = 70;
|
||||
@@ -204,11 +170,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
BezierPath,
|
||||
BezierFill,
|
||||
LinePoints: _linePoints,
|
||||
} = getSVGGraphData(data, svgWidth, svgHeight);
|
||||
const { BezierPath, BezierFill } = getSVGGraphData(data, svgWidth, svgHeight);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -227,7 +189,6 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
||||
{data.map((point, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick
|
||||
className="w-full opacity-0 hover:opacity-100"
|
||||
style={{
|
||||
background:
|
||||
|
||||
@@ -2,14 +2,14 @@ import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export interface Column<T extends Record<string, unknown>> {
|
||||
export interface Column<T extends object> {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T extends Record<string, unknown>> {
|
||||
export interface TableProps<T extends object> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
@@ -17,7 +17,6 @@ export interface TableProps<T extends Record<string, unknown>> {
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
}
|
||||
|
||||
// Skeleton table component for loading state
|
||||
function SkeletonTable({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
@@ -28,7 +27,7 @@ function SkeletonTable({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
|
||||
<div className={cn('overflow-hidden rounded-lg bg-white dark:bg-gray-700', className)}>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-600">
|
||||
@@ -58,19 +57,19 @@ function SkeletonTable({
|
||||
);
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, unknown>>({
|
||||
export function Table<T extends object>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
emptyMessage = 'No Results',
|
||||
rowKey,
|
||||
}: TableProps<T>) {
|
||||
const getRowKey = (_row: T, index: number): string => {
|
||||
const getRowKey = (row: T, index: number): string => {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(_row);
|
||||
return rowKey(row);
|
||||
}
|
||||
if (rowKey) {
|
||||
return String(_row[rowKey] ?? index);
|
||||
return String(row[rowKey] ?? index);
|
||||
}
|
||||
return `row-${index}`;
|
||||
};
|
||||
@@ -113,7 +112,9 @@ export function Table<T extends Record<string, unknown>>({
|
||||
key={`${getRowKey(row, index)}-${String(column.key)}`}
|
||||
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
|
||||
>
|
||||
{column.render ? column.render(row[column.key], row, index) : row[column.key]}
|
||||
{column.render
|
||||
? column.render(row[column.key], row, index)
|
||||
: (row[column.key] as React.ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
export function GitIcon() {
|
||||
interface GitIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitIcon({ size = 20, className = '' }: GitIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-black dark:text-white"
|
||||
height="20"
|
||||
className={`${className} text-black dark:text-white`.trim()}
|
||||
height={size}
|
||||
viewBox="0 0 219 92"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -22,19 +27,19 @@ export function GitIcon() {
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M130.871 31.836c-4.785 0-8.351 2.352-8.351 8.008 0 4.261 2.347 7.222 8.093 7.222 4.871 0 8.18-2.867 8.18-7.398 0-5.133-2.961-7.832-7.922-7.832Zm-9.57 39.95c-1.133 1.39-2.262 2.87-2.262 4.612 0 3.48 4.434 4.524 10.527 4.524 5.051 0 11.926-.352 11.926-5.043 0-2.793-3.308-2.965-7.488-3.227Zm25.761-39.688c1.563 2.004 3.22 4.789 3.22 8.793 0 9.656-7.571 15.316-18.536 15.316-2.789 0-5.312-.348-6.879-.785l-2.87 4.613 8.526.52c15.059.96 23.934 1.398 23.934 12.968 0 10.008-8.789 15.665-23.934 15.665-15.75 0-21.757-4.004-21.757-10.88 0-3.917 1.742-6 4.789-8.878-2.875-1.211-3.828-3.387-3.828-5.739 0-1.914.953-3.656 2.523-5.312 1.566-1.652 3.305-3.305 5.395-5.219-4.262-2.09-7.485-6.617-7.485-13.058 0-10.008 6.613-16.88 19.93-16.88 3.742 0 6.004.344 8.008.872h16.972v7.394l-8.007.61"
|
||||
/>
|
||||
<g clip-path="url(#a)">
|
||||
<g clipPath="url(#a)">
|
||||
<path
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M170.379 16.281c-4.961 0-7.832-2.87-7.832-7.836 0-4.957 2.871-7.656 7.832-7.656 5.05 0 7.922 2.7 7.922 7.656 0 4.965-2.871 7.836-7.922 7.836Zm-11.227 52.305V61.71l4.438-.606c1.219-.175 1.394-.437 1.394-1.746V33.773c0-.953-.261-1.566-1.132-1.824l-4.7-1.656.957-7.047h18.016V59.36c0 1.399.086 1.57 1.395 1.746l4.437.606v6.875h-24.805"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#b)">
|
||||
<g clipPath="url(#b)">
|
||||
<path
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M218.371 65.21c-3.742 1.825-9.223 3.481-14.187 3.481-10.356 0-14.27-4.175-14.27-14.015V31.879c0-.524 0-.871-.7-.871h-6.093v-7.746c7.664-.871 10.707-4.703 11.664-14.188h8.27v12.36c0 .609 0 .87.695.87h12.27v8.704h-12.965v20.797c0 5.136 1.218 7.136 5.918 7.136 2.437 0 4.96-.609 7.047-1.39l2.351 7.66"
|
||||
/>
|
||||
</g>
|
||||
<g clip-path="url(#c)">
|
||||
<g clipPath="url(#c)">
|
||||
<path
|
||||
style={{ stroke: 'none', fillRule: 'nonzero', fillOpacity: 1 }}
|
||||
d="M89.422 42.371 49.629 2.582a5.868 5.868 0 0 0-8.3 0l-8.263 8.262 10.48 10.484a6.965 6.965 0 0 1 7.173 1.668 6.98 6.98 0 0 1 1.656 7.215l10.102 10.105a6.963 6.963 0 0 1 7.214 1.657 6.976 6.976 0 0 1 0 9.875 6.98 6.98 0 0 1-9.879 0 6.987 6.987 0 0 1-1.519-7.594l-9.422-9.422v24.793a6.979 6.979 0 0 1 1.848 1.32 6.988 6.988 0 0 1 0 9.88c-2.73 2.726-7.153 2.726-9.875 0a6.98 6.98 0 0 1 0-9.88 6.893 6.893 0 0 1 2.285-1.523V34.398a6.893 6.893 0 0 1-2.285-1.523 6.988 6.988 0 0 1-1.508-7.637L29.004 14.902 1.719 42.187a5.868 5.868 0 0 0 0 8.301l39.793 39.793a5.868 5.868 0 0 0 8.3 0l39.61-39.605a5.873 5.873 0 0 0 0-8.305"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||
const activities = data?.status === 200 ? data.data.activities : [];
|
||||
|
||||
const columns = [
|
||||
const columns: Column<Activity>[] = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_value: Activity['document_id'], row: Activity) => (
|
||||
render: (_value, row) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -24,19 +24,19 @@ export default function ActivityPage() {
|
||||
{
|
||||
key: 'start_time' as const,
|
||||
header: 'Time',
|
||||
render: (value: Activity['start_time']) => value || 'N/A',
|
||||
render: value => String(value || 'N/A'),
|
||||
},
|
||||
{
|
||||
key: 'duration' as const,
|
||||
header: 'Duration',
|
||||
render: (value: Activity['duration']) => {
|
||||
return formatDuration(value || 0);
|
||||
render: value => {
|
||||
return formatDuration(typeof value === 'number' ? value : 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'end_percentage' as const,
|
||||
header: 'Percent',
|
||||
render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'),
|
||||
render: value => (typeof value === 'number' ? `${value}%` : '0%'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import type { DirectoryItem, DirectoryListResponse } from '../generated/model';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
@@ -17,8 +18,10 @@ export default function AdminImportPage() {
|
||||
|
||||
const postImport = usePostImport();
|
||||
|
||||
const directories = directoryData?.data?.items || [];
|
||||
const currentPathDisplay = directoryData?.data?.current_path ?? currentPath ?? '/data';
|
||||
const directoryResponse =
|
||||
directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
|
||||
const directories = directoryResponse?.items ?? [];
|
||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||
|
||||
const handleSelectDirectory = (directory: string) => {
|
||||
setSelectedDirectory(`${currentPath}/${directory}`);
|
||||
@@ -148,7 +151,7 @@ export default function AdminImportPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
directories.map(item => (
|
||||
directories.map((item: DirectoryItem) => (
|
||||
<tr key={item.name}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||
import type { ImportResult } from '../generated/model/importResult';
|
||||
import type { ImportResult, ImportResultsResponse } from '../generated/model';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function AdminImportResultsPage() {
|
||||
const { data: resultsData, isLoading } = useGetImportResults();
|
||||
const results = resultsData?.data?.results || [];
|
||||
const results =
|
||||
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||
import type { LogsResponse } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { SearchIcon } from '../icons';
|
||||
|
||||
@@ -8,7 +9,7 @@ export default function AdminLogsPage() {
|
||||
|
||||
const { data: logsData, isLoading, refetch } = useGetLogs(filter ? { filter } : {});
|
||||
|
||||
const logs = logsData?.data?.logs || [];
|
||||
const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : [];
|
||||
|
||||
const handleFilterSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -21,7 +22,6 @@ export default function AdminLogsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter Form */}
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
@@ -46,14 +46,13 @@ export default function AdminLogsPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Log Display */}
|
||||
<div
|
||||
className="flex w-full flex-col-reverse overflow-scroll text-black dark:text-white"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{logs.map((log: string, index: number) => (
|
||||
{logs.map((log, index) => (
|
||||
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
||||
{log}
|
||||
{typeof log === 'string' ? log : JSON.stringify(log)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import type { User, UsersResponse } from '../generated/model';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -14,7 +15,7 @@ export default function AdminUsersPage() {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||
|
||||
const users = usersData?.data?.users || [];
|
||||
const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : [];
|
||||
|
||||
const handleCreateUser = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -73,7 +74,7 @@ export default function AdminUsersPage() {
|
||||
data: {
|
||||
operation: 'UPDATE',
|
||||
user: userId,
|
||||
password: password,
|
||||
password,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -116,7 +117,6 @@ export default function AdminUsersPage() {
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-x-auto">
|
||||
{/* Add User Form */}
|
||||
{showAddForm && (
|
||||
<div className="absolute left-10 top-10 rounded bg-gray-200 p-3 shadow-lg shadow-gray-500 transition-all duration-200 dark:bg-gray-600 dark:shadow-gray-900">
|
||||
<form
|
||||
@@ -156,7 +156,6 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="min-w-full overflow-scroll rounded shadow">
|
||||
<table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
@@ -188,19 +187,16 @@ export default function AdminUsersPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map(user => (
|
||||
users.map((user: User) => (
|
||||
<tr key={user.id}>
|
||||
{/* Delete Button */}
|
||||
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
||||
<button onClick={() => handleDeleteUser(user.id)}>
|
||||
<DeleteIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
{/* User ID */}
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<p>{user.id}</p>
|
||||
</td>
|
||||
{/* Password Reset */}
|
||||
<td className="border-b border-gray-200 px-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -212,7 +208,6 @@ export default function AdminUsersPage() {
|
||||
Reset
|
||||
</button>
|
||||
</td>
|
||||
{/* Admin Toggle */}
|
||||
<td className="flex min-w-40 justify-center gap-2 border-b border-gray-200 p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, true)}
|
||||
@@ -237,7 +232,6 @@ export default function AdminUsersPage() {
|
||||
user
|
||||
</button>
|
||||
</td>
|
||||
{/* Created Date */}
|
||||
<td className="border-b border-gray-200 p-3">
|
||||
<p>{user.created_at}</p>
|
||||
</td>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
|
||||
export default function ProgressPage() {
|
||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||
const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
|
||||
|
||||
const columns = [
|
||||
const columns: Column<Progress>[] = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_value: Progress['document_id'], row: Progress) => (
|
||||
render: (_value, row) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -23,18 +23,18 @@ export default function ProgressPage() {
|
||||
{
|
||||
key: 'device_name' as const,
|
||||
header: 'Device Name',
|
||||
render: (value: Progress['device_name']) => value || 'Unknown',
|
||||
render: value => String(value || 'Unknown'),
|
||||
},
|
||||
{
|
||||
key: 'percentage' as const,
|
||||
header: 'Percentage',
|
||||
render: (value: Progress['percentage']) => (value ? `${Math.round(value)}%` : '0%'),
|
||||
render: value => (typeof value === 'number' ? `${Math.round(value)}%` : '0%'),
|
||||
},
|
||||
{
|
||||
key: 'created_at' as const,
|
||||
header: 'Created At',
|
||||
render: (value: Progress['created_at']) =>
|
||||
value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
render: value =>
|
||||
typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
22
frontend/src/types/window.d.ts
vendored
Normal file
22
frontend/src/types/window.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
interface FilePickerAcceptType {
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface SaveFilePickerOptions {
|
||||
suggestedName?: string;
|
||||
types?: FilePickerAcceptType[];
|
||||
}
|
||||
|
||||
interface FileSystemWritableFileStream {
|
||||
write(data: BufferSource | Blob | string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface FileSystemFileHandle {
|
||||
createWritable(): Promise<FileSystemWritableFileStream>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
|
||||
}
|
||||
@@ -23,4 +23,4 @@
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user