This commit is contained in:
2026-03-22 11:20:51 -04:00
parent 27e651c4f5
commit 9ed63b2695
16 changed files with 303 additions and 156 deletions

69
frontend/AGENTS.md Normal file
View 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.

View File

@@ -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=="],

View File

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

View File

@@ -1,3 +1,4 @@
import { describe, expect, it } from 'vitest';
import { getSVGGraphData } from './ReadingHistoryGraph';
// Test data matching Go test exactly

View File

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

View File

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

View File

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

View File

@@ -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%'),
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,4 +23,4 @@
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}
}