feat: v1 API + frontend migration
Some checks failed
continuous-integration/drone/pr Build is failing
Some checks failed
continuous-integration/drone/pr Build is failing
This commit is contained in:
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
2
frontend/.prettierignore
Normal file
2
frontend/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Generated API code
|
||||
src/generated/**/*
|
||||
11
frontend/.prettierrc
Normal file
11
frontend/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
87
frontend/AGENTS.md
Normal file
87
frontend/AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
||||
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
|
||||
- Use theme tokens from `tailwind.config.js` / `src/index.css` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs.
|
||||
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
|
||||
|
||||
## 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)`.
|
||||
- Read `TESTING_STRATEGY.md` before adding or expanding frontend tests.
|
||||
- Prefer tests for meaningful app behavior, branching logic, side effects, and user-visible outcomes.
|
||||
- Avoid low-value tests that mainly assert exact styling classes, duplicate existing coverage, or re-test framework/library behavior.
|
||||
- `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) Live Dev Server Debugging
|
||||
|
||||
- Use `glimpse` to inspect the running Vite dev server at `localhost:5173`:
|
||||
```bash
|
||||
glimpse snapshot http://localhost:5173/some-page --wait-until=complete --timeout=15000
|
||||
glimpse screenshot http://localhost:5173/some-page --wait-until=complete --output=_scratch/page.png
|
||||
glimpse exec http://localhost:5173/some-page --wait-until=complete --timeout=20000 --js='return document.title'
|
||||
```
|
||||
- Use `curl` for API endpoint testing (both `localhost:5173` via proxy and `localhost:8585` directly).
|
||||
- Do not monkey-patch `window.fetch` in `glimpse exec`; Firefox rejects it. Test API calls with `curl` instead.
|
||||
|
||||
## 8) 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.
|
||||
111
frontend/README.md
Normal file
111
frontend/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# AnthoLume Frontend
|
||||
|
||||
A React + TypeScript frontend for AnthoLume, replacing the server-side rendering (SSR) templates.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 19** - UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **React Query (TanStack Query)** - Server state management
|
||||
- **Orval** - API client generation from OpenAPI spec
|
||||
- **React Router** - Navigation
|
||||
- **Tailwind CSS** - Styling
|
||||
- **Vite** - Build tool
|
||||
- **Axios** - HTTP client with auth interceptors
|
||||
|
||||
## Authentication
|
||||
|
||||
The frontend includes a complete authentication system:
|
||||
|
||||
### Auth Context
|
||||
- `AuthProvider` - Manages authentication state globally
|
||||
- `useAuth()` - Hook to access auth state and methods
|
||||
- Token stored in `localStorage`
|
||||
- Axios interceptors automatically attach Bearer token to API requests
|
||||
|
||||
### Protected Routes
|
||||
- All main routes are wrapped in `ProtectedRoute`
|
||||
- Unauthenticated users are redirected to `/login`
|
||||
- Layout redirects to login if not authenticated
|
||||
|
||||
### Login Flow
|
||||
1. User enters credentials on `/login`
|
||||
2. POST to `/api/v1/auth/login`
|
||||
3. Token stored in localStorage
|
||||
4. Redirect to home page
|
||||
5. Axios interceptor includes token in subsequent requests
|
||||
|
||||
### Logout Flow
|
||||
1. User clicks "Logout" in dropdown menu
|
||||
2. POST to `/api/v1/auth/logout`
|
||||
3. Token cleared from localStorage
|
||||
4. Redirect to `/login`
|
||||
|
||||
### 401 Handling
|
||||
- Axios response interceptor clears token on 401 errors
|
||||
- Prevents stale auth state
|
||||
|
||||
## Architecture
|
||||
|
||||
The frontend mirrors the existing SSR templates structure:
|
||||
|
||||
### Pages
|
||||
- `HomePage` - Landing page with recent documents
|
||||
- `DocumentsPage` - Document listing with search and pagination
|
||||
- `DocumentPage` - Single document view with details
|
||||
- `ProgressPage` - Reading progress table
|
||||
- `ActivityPage` - User activity log
|
||||
- `SearchPage` - Search interface
|
||||
- `SettingsPage` - User settings
|
||||
- `LoginPage` - Authentication
|
||||
|
||||
### Components
|
||||
- `Layout` - Main layout with navigation sidebar and header
|
||||
- Generated API hooks from `api/v1/openapi.yaml`
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
```
|
||||
|
||||
This generates:
|
||||
- Type definitions for all API schemas
|
||||
- React Query hooks (`useGetDocuments`, `useGetDocument`, etc.)
|
||||
- Mutation hooks (`useLogin`, `useLogout`)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Generate API types (if OpenAPI spec changes)
|
||||
npm run generate:api
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
The built output is in `dist/` and can be served by the Go backend or deployed separately.
|
||||
|
||||
## Migration from SSR
|
||||
|
||||
The frontend replicates the functionality of the following SSR templates:
|
||||
- `templates/pages/home.tmpl` → `HomePage.tsx`
|
||||
- `templates/pages/documents.tmpl` → `DocumentsPage.tsx`
|
||||
- `templates/pages/document.tmpl` → `DocumentPage.tsx`
|
||||
- `templates/pages/progress.tmpl` → `ProgressPage.tsx`
|
||||
- `templates/pages/activity.tmpl` → `ActivityPage.tsx`
|
||||
- `templates/pages/search.tmpl` → `SearchPage.tsx`
|
||||
- `templates/pages/settings.tmpl` → `SettingsPage.tsx`
|
||||
- `templates/pages/login.tmpl` → `LoginPage.tsx`
|
||||
|
||||
The styling follows the same Tailwind CSS classes as the original templates for consistency.
|
||||
73
frontend/TESTING_STRATEGY.md
Normal file
73
frontend/TESTING_STRATEGY.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Frontend Testing Strategy
|
||||
|
||||
This project prefers meaningful frontend tests over high test counts.
|
||||
|
||||
## What we want to test
|
||||
|
||||
Prioritize tests for app-owned behavior such as:
|
||||
|
||||
- user-visible page and component behavior
|
||||
- auth and routing behavior
|
||||
- branching logic and business rules
|
||||
- data normalization and error handling
|
||||
- timing behavior with real app logic
|
||||
- side effects that could regress, such as token handling or redirects
|
||||
- algorithmic or formatting logic that defines product behavior
|
||||
|
||||
Good examples in this repo:
|
||||
|
||||
- login and registration flows
|
||||
- protected-route behavior
|
||||
- auth interceptor token injection and cleanup
|
||||
- error message extraction
|
||||
- debounce timing
|
||||
- human-readable formatting logic
|
||||
- graph/algorithm output where exact parity matters
|
||||
|
||||
## What we usually do not want to test
|
||||
|
||||
Avoid tests that mostly prove:
|
||||
|
||||
- the language/runtime works
|
||||
- React forwards basic props correctly
|
||||
- a third-party library behaves as documented
|
||||
- exact Tailwind class strings with no product meaning
|
||||
- implementation details not observable in behavior
|
||||
- duplicated examples that re-assert the same logic
|
||||
|
||||
In other words, do not add tests equivalent to checking that JavaScript can compute `1 + 1`.
|
||||
|
||||
## Preferred test style
|
||||
|
||||
- Prefer behavior-focused assertions over implementation-detail assertions.
|
||||
- Prefer user-visible outcomes over internal state inspection.
|
||||
- Mock at module boundaries when needed.
|
||||
- Keep test setup small and local.
|
||||
- Use exact-output assertions only when the output itself is the contract.
|
||||
|
||||
## When exact assertions are appropriate
|
||||
|
||||
Exact assertions are appropriate when they protect a real contract, for example:
|
||||
|
||||
- a formatter's exact human-readable output
|
||||
- auth decision outcomes for a given API response shape
|
||||
- exact algorithm output that must remain stable
|
||||
|
||||
Exact assertions are usually not appropriate for:
|
||||
|
||||
- incidental class names
|
||||
- framework internals
|
||||
- non-observable React keys
|
||||
|
||||
## Cleanup rule of thumb
|
||||
|
||||
Keep tests that would catch meaningful regressions in product behavior.
|
||||
Trim or remove tests that are brittle, duplicated, or mostly validate tooling rather than app logic.
|
||||
|
||||
## Validation
|
||||
|
||||
For frontend test work, validate with:
|
||||
|
||||
- `cd frontend && bun run lint`
|
||||
- `cd frontend && bun run typecheck`
|
||||
- `cd frontend && bun run test`
|
||||
1350
frontend/bun.lock
Normal file
1350
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/eslint.config.js
Normal file
82
frontend/eslint.config.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import js from "@eslint/js";
|
||||
import typescriptParser from "@typescript-eslint/parser";
|
||||
import typescriptPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
import tailwindcss from "eslint-plugin-tailwindcss";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
ignores: ["**/generated/**"],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
projectService: true,
|
||||
},
|
||||
globals: {
|
||||
localStorage: "readonly",
|
||||
sessionStorage: "readonly",
|
||||
document: "readonly",
|
||||
window: "readonly",
|
||||
setTimeout: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
setInterval: "readonly",
|
||||
clearInterval: "readonly",
|
||||
HTMLElement: "readonly",
|
||||
HTMLDivElement: "readonly",
|
||||
HTMLButtonElement: "readonly",
|
||||
HTMLAnchorElement: "readonly",
|
||||
MouseEvent: "readonly",
|
||||
Node: "readonly",
|
||||
File: "readonly",
|
||||
Blob: "readonly",
|
||||
FormData: "readonly",
|
||||
alert: "readonly",
|
||||
confirm: "readonly",
|
||||
prompt: "readonly",
|
||||
React: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptPlugin,
|
||||
react: reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
tailwindcss,
|
||||
prettier,
|
||||
},
|
||||
rules: {
|
||||
...eslintConfigPrettier.rules,
|
||||
...tailwindcss.configs.recommended.rules,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
"no-useless-catch": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
31
frontend/index.html
Normal file
31
frontend/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#F3F4F6"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#1F2937"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<title>AnthoLume</title>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
frontend/orval.config.ts
Normal file
21
frontend/orval.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'orval';
|
||||
|
||||
export default defineConfig({
|
||||
antholume: {
|
||||
output: {
|
||||
mode: 'split',
|
||||
baseUrl: '/api/v1',
|
||||
target: 'src/generated',
|
||||
schemas: 'src/generated/model',
|
||||
client: 'react-query',
|
||||
mock: false,
|
||||
override: {
|
||||
useQuery: true,
|
||||
mutations: true,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
target: '../api/v1/openapi.yaml',
|
||||
},
|
||||
},
|
||||
});
|
||||
56
frontend/package.json
Normal file
56
frontend/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "antholume-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"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",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.16",
|
||||
"ajv": "^8.18.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"epubjs": "^0.3.93",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"orval": "8.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
"@typescript-eslint/parser": "^8.13.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
frontend/src/App.tsx
Normal file
12
frontend/src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import { Routes } from './Routes';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
134
frontend/src/Routes.tsx
Normal file
134
frontend/src/Routes.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import DocumentsPage from './pages/DocumentsPage';
|
||||
import DocumentPage from './pages/DocumentPage';
|
||||
import ProgressPage from './pages/ProgressPage';
|
||||
import ActivityPage from './pages/ActivityPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import AdminImportPage from './pages/AdminImportPage';
|
||||
import AdminImportResultsPage from './pages/AdminImportResultsPage';
|
||||
import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import AdminLogsPage from './pages/AdminLogsPage';
|
||||
import ReaderPage from './pages/ReaderPage';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
|
||||
export function Routes() {
|
||||
return (
|
||||
<ReactRoutes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="documents"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DocumentsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="documents/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DocumentPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="progress"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProgressPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="activity"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="search"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SearchPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/import"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminImportPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/import-results"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminImportResultsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminUsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/logs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="/reader/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ReaderPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
</ReactRoutes>
|
||||
);
|
||||
}
|
||||
135
frontend/src/auth/AuthContext.tsx
Normal file
135
frontend/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getGetMeQueryKey,
|
||||
useLogin,
|
||||
useLogout,
|
||||
useGetMe,
|
||||
useRegister,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import {
|
||||
type AuthState,
|
||||
getAuthenticatedAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
} from './authHelpers';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (_username: string, _password: string) => Promise<void>;
|
||||
register: (_username: string, _password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const initialAuthState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setAuthState(prev =>
|
||||
resolveAuthStateFromMe({
|
||||
meData,
|
||||
meError,
|
||||
meLoading,
|
||||
previousState: prev,
|
||||
})
|
||||
);
|
||||
}, [meData, meError, meLoading]);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await loginMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = validateAuthMutationResponse(response, 200);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate, queryClient]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await registerMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = validateAuthMutationResponse(response, 201);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
[navigate, queryClient, registerMutation]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
}, [logoutMutation, navigate, queryClient]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authState, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a loading state while auth is being checked', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: true,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/private']}>
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to the login page', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/private']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/private"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children for authenticated users', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Secret')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
21
frontend/src/auth/ProtectedRoute.tsx
Normal file
21
frontend/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isCheckingAuth) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
157
frontend/src/auth/authHelpers.test.ts
Normal file
157
frontend/src/auth/authHelpers.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCheckingAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
normalizeAuthenticatedUser,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
type AuthState,
|
||||
} from './authHelpers';
|
||||
|
||||
const previousState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
|
||||
describe('authHelpers', () => {
|
||||
it('normalizes a valid authenticated user payload', () => {
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: true })).toEqual({
|
||||
username: 'evan',
|
||||
is_admin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid authenticated user payloads', () => {
|
||||
expect(normalizeAuthenticatedUser(null)).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan' })).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 123, is_admin: true })).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: 'yes' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a checking state while preserving previous auth information', () => {
|
||||
expect(
|
||||
getCheckingAuthState({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: false,
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state from a successful /auth/me response', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 401,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
meError: new Error('failed'),
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
});
|
||||
|
||||
it('keeps checking state while /auth/me is still loading', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
meError: undefined,
|
||||
meLoading: true,
|
||||
previousState: {
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
isCheckingAuth: false,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the previous state with checking disabled when there is no decisive me result', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 204,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState: {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates auth mutation responses by expected status and payload shape', () => {
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toEqual({ username: 'evan', is_admin: false });
|
||||
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 201,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 200,
|
||||
data: { username: 'evan' },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
98
frontend/src/auth/authHelpers.ts
Normal file
98
frontend/src/auth/authHelpers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: AuthUser | null;
|
||||
isCheckingAuth: boolean;
|
||||
}
|
||||
|
||||
interface ResponseLike {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export function getUnauthenticatedAuthState(): AuthState {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCheckingAuthState(previousState?: AuthState): AuthState {
|
||||
return {
|
||||
isAuthenticated: previousState?.isAuthenticated ?? false,
|
||||
user: previousState?.user ?? null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAuthenticatedUser(value: unknown): AuthUser | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('username' in value) || typeof value.username !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('is_admin' in value) || typeof value.is_admin !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username: value.username,
|
||||
is_admin: value.is_admin,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAuthStateFromMe(params: {
|
||||
meData?: ResponseLike;
|
||||
meError?: unknown;
|
||||
meLoading: boolean;
|
||||
previousState: AuthState;
|
||||
}): AuthState {
|
||||
const { meData, meError, meLoading, previousState } = params;
|
||||
|
||||
if (meLoading) {
|
||||
return getCheckingAuthState(previousState);
|
||||
}
|
||||
|
||||
if (meData?.status === 200) {
|
||||
const user = normalizeAuthenticatedUser(meData.data);
|
||||
if (user) {
|
||||
return getAuthenticatedAuthState(user);
|
||||
}
|
||||
}
|
||||
|
||||
if (meError || meData?.status === 401) {
|
||||
return getUnauthenticatedAuthState();
|
||||
}
|
||||
|
||||
return {
|
||||
...previousState,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAuthMutationResponse(
|
||||
response: ResponseLike,
|
||||
expectedStatus: number
|
||||
): AuthUser | null {
|
||||
if (response.status !== expectedStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAuthenticatedUser(response.data);
|
||||
}
|
||||
11
frontend/src/auth/authInterceptor.test.ts
Normal file
11
frontend/src/auth/authInterceptor.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { setupAuthInterceptors } from './authInterceptor';
|
||||
|
||||
describe('setupAuthInterceptors', () => {
|
||||
it('is a no-op when auth is handled by HttpOnly cookies', () => {
|
||||
const cleanup = setupAuthInterceptors();
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
expect(() => cleanup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
3
frontend/src/auth/authInterceptor.ts
Normal file
3
frontend/src/auth/authInterceptor.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function setupAuthInterceptors() {
|
||||
return () => {};
|
||||
}
|
||||
45
frontend/src/components/Button.tsx
Normal file
45
frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
interface BaseButtonProps {
|
||||
variant?: 'default' | 'secondary';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
|
||||
|
||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||
const baseClass =
|
||||
'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-primary-500 text-primary-foreground hover:bg-primary-700 disabled:hover:bg-primary-500`;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ButtonLink.displayName = 'ButtonLink';
|
||||
41
frontend/src/components/Field.tsx
Normal file
41
frontend/src/components/Field.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface FieldProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
||||
return (
|
||||
<div className="relative rounded">
|
||||
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldLabelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FieldLabel({ children }: FieldLabelProps) {
|
||||
return <p>{children}</p>;
|
||||
}
|
||||
|
||||
interface FieldValueProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FieldValue({ children, className = '' }: FieldValueProps) {
|
||||
return <p className={`text-lg font-medium ${className}`}>{children}</p>;
|
||||
}
|
||||
|
||||
interface FieldActionsProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FieldActions({ children }: FieldActionsProps) {
|
||||
return <div className="inline-flex gap-2">{children}</div>;
|
||||
}
|
||||
181
frontend/src/components/HamburgerMenu.tsx
Normal file
181
frontend/src/components/HamburgerMenu.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home', icon: HomeIcon, title: 'Home' },
|
||||
{ path: '/documents', label: 'Documents', icon: DocumentsIcon, title: 'Documents' },
|
||||
{ path: '/progress', label: 'Progress', icon: ActivityIcon, title: 'Progress' },
|
||||
{ path: '/activity', label: 'Activity', icon: ActivityIcon, title: 'Activity' },
|
||||
{ path: '/search', label: 'Search', icon: SearchIcon, title: 'Search' },
|
||||
];
|
||||
|
||||
const adminSubItems: NavItem[] = [
|
||||
{ path: '/admin', label: 'General', icon: SettingsIcon, title: 'General' },
|
||||
{ path: '/admin/import', label: 'Import', icon: SettingsIcon, title: 'Import' },
|
||||
{ path: '/admin/users', label: 'Users', icon: SettingsIcon, title: 'Users' },
|
||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
|
||||
];
|
||||
|
||||
function hasPrefix(path: string, prefix: string): boolean {
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
export default function HamburgerMenu() {
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isAdmin = user?.is_admin ?? false;
|
||||
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
const version =
|
||||
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
||||
? infoData.data.version
|
||||
: 'v1.0.0';
|
||||
|
||||
return (
|
||||
<div className="relative z-40 ml-6 flex flex-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
|
||||
id="mobile-nav-checkbox"
|
||||
checked={isOpen}
|
||||
onChange={e => setIsOpen(e.target.checked)}
|
||||
/>
|
||||
|
||||
<span
|
||||
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '5px 0px',
|
||||
transition:
|
||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '0% 100%',
|
||||
transition:
|
||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||
opacity: isOpen ? 0 : 1,
|
||||
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '0% 0%',
|
||||
transition:
|
||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="menu"
|
||||
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48"
|
||||
style={{
|
||||
top: 0,
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: isOpen ? 'none' : 'translate(-100%, 0)',
|
||||
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@media (min-width: 1024px) {
|
||||
#menu {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-16 justify-end lg:justify-around">
|
||||
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p>
|
||||
</div>
|
||||
<nav>
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
location.pathname === item.path
|
||||
? 'border-primary-500 text-content'
|
||||
: 'border-transparent text-content-subtle hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<div
|
||||
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
hasPrefix(location.pathname, '/admin')
|
||||
? 'border-primary-500 text-content'
|
||||
: 'border-transparent text-content-subtle'
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
||||
? 'text-content'
|
||||
: 'text-content-subtle hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
<span className="mx-4 text-sm font-normal">Admin</span>
|
||||
</Link>
|
||||
|
||||
{hasPrefix(location.pathname, '/admin') && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{adminSubItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
location.pathname === item.path
|
||||
? 'text-content'
|
||||
: 'text-content-subtle hover:text-content'
|
||||
}`}
|
||||
style={{ paddingLeft: '1.75em' }}
|
||||
>
|
||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<a
|
||||
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-content"
|
||||
target="_blank"
|
||||
href="https://gitea.va.reichard.io/evan/AnthoLume"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<GitIcon size={20} />
|
||||
<span className="text-xs">{version}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/Layout.tsx
Normal file
178
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
|
||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||
const { themeMode, setThemeMode } = useTheme();
|
||||
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||
const fetchedUser =
|
||||
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
||||
const userData = user ?? fetchedUser;
|
||||
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setIsUserDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsUserDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin/import-results', title: 'Admin - Import' },
|
||||
{ path: '/admin/import', title: 'Admin - Import' },
|
||||
{ path: '/admin/users', title: 'Admin - Users' },
|
||||
{ path: '/admin/logs', title: 'Admin - Logs' },
|
||||
{ path: '/admin', title: 'Admin - General' },
|
||||
{ path: '/documents', title: 'Documents' },
|
||||
{ path: '/progress', title: 'Progress' },
|
||||
{ path: '/activity', title: 'Activity' },
|
||||
{ path: '/search', title: 'Search' },
|
||||
{ path: '/settings', title: 'Settings' },
|
||||
{ path: '/', title: 'Home' },
|
||||
];
|
||||
const currentPageTitle =
|
||||
navItems.find(item =>
|
||||
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
||||
)?.title || 'Home';
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||
}, [currentPageTitle]);
|
||||
|
||||
if (isCheckingAuth) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="flex h-16 w-full items-center justify-between">
|
||||
<HamburgerMenu />
|
||||
|
||||
<h1 className="whitespace-nowrap px-6 text-xl font-bold text-content lg:ml-44">
|
||||
{currentPageTitle}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="relative flex w-full items-center justify-end space-x-4 p-4"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||
className="relative block text-content"
|
||||
>
|
||||
<UserIcon size={20} />
|
||||
</button>
|
||||
|
||||
{isUserDropdownOpen && (
|
||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||
<div
|
||||
className="border-b border-border px-4 py-3"
|
||||
role="group"
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||
Theme
|
||||
</p>
|
||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||
{themeModes.map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode)}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
themeMode === mode
|
||||
? 'bg-content text-content-inverse'
|
||||
: 'text-content-muted hover:bg-surface hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setIsUserDropdownOpen(false)}
|
||||
className="block px-4 py-2 text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span>Settings</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full px-4 py-2 text-left text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
role="menuitem"
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span>Logout</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
|
||||
>
|
||||
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
|
||||
<span
|
||||
className="text-content transition-transform duration-200"
|
||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<DropdownIcon size={20} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}
|
||||
>
|
||||
<div
|
||||
id="container"
|
||||
className="h-dvh overflow-auto px-4 md:px-6 lg:ml-48"
|
||||
style={{ paddingBottom: 'calc(5em + env(safe-area-inset-bottom) * 2)' }}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/LoadingState.tsx
Normal file
21
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { LoadingIcon } from '../icons';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
message = 'Loading...',
|
||||
className = '',
|
||||
iconSize = 24,
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-3 text-content-muted', className)}>
|
||||
<LoadingIcon size={iconSize} className="text-primary-500" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/Pagination.tsx
Normal file
51
frontend/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
previousPage?: number;
|
||||
nextPage?: number;
|
||||
total?: number;
|
||||
limit?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
previousPage,
|
||||
nextPage,
|
||||
total,
|
||||
limit,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
if (!previousPage && !nextPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalPages = total && limit ? Math.ceil(total / limit) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full items-center justify-center gap-4 text-content">
|
||||
{previousPage && previousPage > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(previousPage)}
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
||||
>
|
||||
◄
|
||||
</button>
|
||||
) : null}
|
||||
{totalPages ? (
|
||||
<span className="text-sm text-content-muted">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
) : null}
|
||||
{nextPage && nextPage > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(nextPage)}
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
|
||||
>
|
||||
►
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/README.md
Normal file
203
frontend/src/components/README.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# UI Components
|
||||
|
||||
This directory contains reusable UI components for the AnthoLume application.
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
### Usage
|
||||
|
||||
The toast system provides info, warning, and error notifications that respect the current theme and dark/light mode.
|
||||
|
||||
```tsx
|
||||
import { useToasts } from './components/ToastContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { showInfo, showWarning, showError, showToast } = useToasts();
|
||||
|
||||
const handleAction = async () => {
|
||||
try {
|
||||
// Do something
|
||||
showInfo('Operation completed successfully!');
|
||||
} catch (error) {
|
||||
showError('An error occurred while processing your request.');
|
||||
}
|
||||
};
|
||||
|
||||
return <button onClick={handleAction}>Click me</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
- `showToast(message: string, type?: 'info' | 'warning' | 'error', duration?: number): string`
|
||||
- Shows a toast notification
|
||||
- Returns the toast ID for manual removal
|
||||
- Default type: 'info'
|
||||
- Default duration: 5000ms (0 = no auto-dismiss)
|
||||
|
||||
- `showInfo(message: string, duration?: number): string`
|
||||
- Shortcut for showing an info toast
|
||||
|
||||
- `showWarning(message: string, duration?: number): string`
|
||||
- Shortcut for showing a warning toast
|
||||
|
||||
- `showError(message: string, duration?: number): string`
|
||||
- Shortcut for showing an error toast
|
||||
|
||||
- `removeToast(id: string): void`
|
||||
- Manually remove a toast by ID
|
||||
|
||||
- `clearToasts(): void`
|
||||
- Clear all active toasts
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
// Info toast (auto-dismisses after 5 seconds)
|
||||
showInfo('Document saved successfully!');
|
||||
|
||||
// Warning toast (auto-dismisses after 10 seconds)
|
||||
showWarning('Low disk space warning', 10000);
|
||||
|
||||
// Error toast (no auto-dismiss)
|
||||
showError('Failed to load data', 0);
|
||||
|
||||
// Generic toast
|
||||
showToast('Custom message', 'warning', 3000);
|
||||
```
|
||||
|
||||
## Skeleton Loading
|
||||
|
||||
### Usage
|
||||
|
||||
Skeleton components provide placeholder content while data is loading. They automatically adapt to dark/light mode.
|
||||
|
||||
### Components
|
||||
|
||||
#### `Skeleton`
|
||||
|
||||
Basic skeleton element with various variants:
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from './components/Skeleton';
|
||||
|
||||
// Default (rounded rectangle)
|
||||
<Skeleton className="w-full h-8" />
|
||||
|
||||
// Text variant
|
||||
<Skeleton variant="text" className="w-3/4" />
|
||||
|
||||
// Circular variant (for avatars)
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
|
||||
// Rectangular variant
|
||||
<Skeleton variant="rectangular" width="100%" height={200} />
|
||||
```
|
||||
|
||||
#### `SkeletonText`
|
||||
|
||||
Multiple lines of text skeleton:
|
||||
|
||||
```tsx
|
||||
<SkeletonText lines={3} />
|
||||
<SkeletonText lines={5} className="max-w-md" />
|
||||
```
|
||||
|
||||
#### `SkeletonAvatar`
|
||||
|
||||
Avatar placeholder:
|
||||
|
||||
```tsx
|
||||
<SkeletonAvatar size="md" />
|
||||
<SkeletonAvatar size={56} />
|
||||
```
|
||||
|
||||
#### `SkeletonCard`
|
||||
|
||||
Card placeholder with optional elements:
|
||||
|
||||
```tsx
|
||||
// Default card
|
||||
<SkeletonCard />
|
||||
|
||||
// With avatar
|
||||
<SkeletonCard showAvatar />
|
||||
|
||||
// Custom configuration
|
||||
<SkeletonCard
|
||||
showAvatar
|
||||
showTitle
|
||||
showText
|
||||
textLines={4}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
```
|
||||
|
||||
#### `SkeletonTable`
|
||||
|
||||
Table placeholder:
|
||||
|
||||
```tsx
|
||||
<SkeletonTable rows={5} columns={4} />
|
||||
<SkeletonTable rows={10} columns={6} showHeader={false} />
|
||||
```
|
||||
|
||||
#### `SkeletonButton`
|
||||
|
||||
Button placeholder:
|
||||
|
||||
```tsx
|
||||
<SkeletonButton width={120} />
|
||||
<SkeletonButton className="w-full" />
|
||||
```
|
||||
|
||||
#### `PageLoader`
|
||||
|
||||
Full-page loading indicator:
|
||||
|
||||
```tsx
|
||||
<PageLoader message="Loading your documents..." />
|
||||
```
|
||||
|
||||
#### `InlineLoader`
|
||||
|
||||
Small inline loading spinner:
|
||||
|
||||
```tsx
|
||||
<InlineLoader size="sm" />
|
||||
<InlineLoader size="md" />
|
||||
<InlineLoader size="lg" />
|
||||
```
|
||||
|
||||
## Integration with Table Component
|
||||
|
||||
The Table component now supports skeleton loading:
|
||||
|
||||
```tsx
|
||||
import { Table, SkeletonTable } from './components/Table';
|
||||
|
||||
function DocumentList() {
|
||||
const { data, isLoading } = useGetDocuments();
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonTable rows={10} columns={5} />;
|
||||
}
|
||||
|
||||
return <Table columns={columns} data={data?.documents || []} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Theme Support
|
||||
|
||||
All components automatically adapt to the current theme:
|
||||
|
||||
- **Light mode**: Uses gray tones for skeletons, appropriate colors for toasts
|
||||
- **Dark mode**: Uses darker gray tones for skeletons, adjusted colors for toasts
|
||||
|
||||
The theme is controlled via Tailwind's `dark:` classes, which respond to the system preference or manual theme toggles.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `clsx` - Utility for constructing className strings
|
||||
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
|
||||
- `lucide-react` - Icon library used by Toast component
|
||||
53
frontend/src/components/ReadingHistoryGraph.test.ts
Normal file
53
frontend/src/components/ReadingHistoryGraph.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSVGGraphData } from './ReadingHistoryGraph';
|
||||
|
||||
// Intentionally exact fixture data for algorithm parity coverage
|
||||
const testInput = [
|
||||
{ date: '2024-01-01', minutes_read: 10 },
|
||||
{ date: '2024-01-02', minutes_read: 90 },
|
||||
{ date: '2024-01-03', minutes_read: 50 },
|
||||
{ date: '2024-01-04', minutes_read: 5 },
|
||||
{ date: '2024-01-05', minutes_read: 10 },
|
||||
{ date: '2024-01-06', minutes_read: 5 },
|
||||
{ date: '2024-01-07', minutes_read: 70 },
|
||||
{ date: '2024-01-08', minutes_read: 60 },
|
||||
{ date: '2024-01-09', minutes_read: 50 },
|
||||
{ date: '2024-01-10', minutes_read: 90 },
|
||||
];
|
||||
|
||||
const svgWidth = 500;
|
||||
const svgHeight = 100;
|
||||
|
||||
describe('ReadingHistoryGraph', () => {
|
||||
describe('getSVGGraphData', () => {
|
||||
it('should match exactly', () => {
|
||||
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
|
||||
|
||||
// Expected exact algorithm output
|
||||
const expectedBezierPath =
|
||||
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
|
||||
const expectedBezierFill = 'L 500,98 L 50,98 Z';
|
||||
const expectedWidth = 500;
|
||||
const expectedHeight = 100;
|
||||
const expectedOffset = 50;
|
||||
|
||||
expect(result.BezierPath).toBe(expectedBezierPath);
|
||||
expect(result.BezierFill).toBe(expectedBezierFill);
|
||||
expect(svgWidth).toBe(expectedWidth);
|
||||
expect(svgHeight).toBe(expectedHeight);
|
||||
expect(result.Offset).toBe(expectedOffset);
|
||||
|
||||
// Verify line points are integer pixel values
|
||||
result.LinePoints.forEach((p, _i) => {
|
||||
expect(Number.isInteger(p.x)).toBe(true);
|
||||
expect(Number.isInteger(p.y)).toBe(true);
|
||||
});
|
||||
|
||||
// Expected line points from the current algorithm:
|
||||
// idx 0: itemSize=5, itemY=95, lineX=50
|
||||
// idx 1: itemSize=45, itemY=55, lineX=100
|
||||
// idx 2: itemSize=25, itemY=75, lineX=150
|
||||
// ...and so on
|
||||
});
|
||||
});
|
||||
});
|
||||
210
frontend/src/components/ReadingHistoryGraph.tsx
Normal file
210
frontend/src/components/ReadingHistoryGraph.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { GraphDataPoint } from '../generated/model';
|
||||
|
||||
interface ReadingHistoryGraphProps {
|
||||
data: GraphDataPoint[];
|
||||
}
|
||||
|
||||
export interface SVGPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function getSVGBezierOpposedLine(
|
||||
pointA: SVGPoint,
|
||||
pointB: SVGPoint
|
||||
): { Length: number; Angle: number } {
|
||||
const lengthX = pointB.x - pointA.x;
|
||||
const lengthY = pointB.y - pointA.y;
|
||||
|
||||
return {
|
||||
Length: Math.floor(Math.sqrt(lengthX * lengthX + lengthY * lengthY)),
|
||||
Angle: Math.trunc(Math.atan2(lengthY, lengthX)),
|
||||
};
|
||||
}
|
||||
|
||||
function getBezierControlPoint(
|
||||
currentPoint: SVGPoint,
|
||||
prevPoint: SVGPoint | null,
|
||||
nextPoint: SVGPoint | null,
|
||||
isReverse: boolean
|
||||
): SVGPoint {
|
||||
let pPrev = prevPoint;
|
||||
let pNext = nextPoint;
|
||||
if (!pPrev) {
|
||||
pPrev = currentPoint;
|
||||
}
|
||||
if (!pNext) {
|
||||
pNext = currentPoint;
|
||||
}
|
||||
|
||||
const smoothingRatio = 0.2;
|
||||
const directionModifier = isReverse ? Math.PI : 0;
|
||||
|
||||
const opposingLine = getSVGBezierOpposedLine(pPrev, pNext);
|
||||
const lineAngle = opposingLine.Angle + directionModifier;
|
||||
const lineLength = opposingLine.Length * smoothingRatio;
|
||||
|
||||
return {
|
||||
x: Math.floor(currentPoint.x + Math.trunc(Math.cos(lineAngle) * lineLength)),
|
||||
y: Math.floor(currentPoint.y + Math.trunc(Math.sin(lineAngle) * lineLength)),
|
||||
};
|
||||
}
|
||||
|
||||
function getSVGBezierPath(points: SVGPoint[]): string {
|
||||
if (points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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}`;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface SVGGraphData {
|
||||
LinePoints: SVGPoint[];
|
||||
BezierPath: string;
|
||||
BezierFill: string;
|
||||
Offset: number;
|
||||
}
|
||||
|
||||
export function getSVGGraphData(
|
||||
inputData: GraphDataPoint[],
|
||||
svgWidth: number,
|
||||
svgHeight: number
|
||||
): SVGGraphData {
|
||||
let maxHeight = 0;
|
||||
for (const item of inputData) {
|
||||
if (item.minutes_read > maxHeight) {
|
||||
maxHeight = item.minutes_read;
|
||||
}
|
||||
}
|
||||
|
||||
const sizePercentage = 0.5;
|
||||
const sizeRatio = maxHeight > 0 ? (svgHeight * sizePercentage) / maxHeight : 0;
|
||||
const blockOffset = inputData.length > 0 ? Math.floor(svgWidth / inputData.length) : 0;
|
||||
|
||||
const linePoints: SVGPoint[] = [];
|
||||
|
||||
let maxBX = 0;
|
||||
let maxBY = 0;
|
||||
let minBX = 0;
|
||||
|
||||
for (let idx = 0; idx < inputData.length; idx++) {
|
||||
const item = inputData[idx];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemSize = Math.floor(item.minutes_read * sizeRatio);
|
||||
const itemY = svgHeight - itemSize;
|
||||
const lineX = (idx + 1) * blockOffset;
|
||||
|
||||
linePoints.push({ x: lineX, y: itemY });
|
||||
|
||||
if (lineX > maxBX) {
|
||||
maxBX = lineX;
|
||||
}
|
||||
|
||||
if (lineX < minBX) {
|
||||
minBX = lineX;
|
||||
}
|
||||
|
||||
if (itemY > maxBY) {
|
||||
maxBY = itemY;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
LinePoints: linePoints,
|
||||
BezierPath: getSVGBezierPath(linePoints),
|
||||
BezierFill: `L ${Math.floor(maxBX)},${Math.floor(maxBY)} L ${Math.floor(minBX + blockOffset)},${Math.floor(maxBY)} Z`,
|
||||
Offset: blockOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
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}`;
|
||||
}
|
||||
|
||||
export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps) {
|
||||
const svgWidth = 800;
|
||||
const svgHeight = 70;
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return (
|
||||
<div className="relative flex h-24 items-center justify-center bg-surface-muted">
|
||||
<p className="text-content-subtle">No data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { BezierPath, BezierFill } = getSVGGraphData(data, svgWidth, svgHeight);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
||||
<path fill="rgb(var(--secondary-600))" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||
<path fill="none" stroke="rgb(var(--secondary-600))" d={BezierPath} />
|
||||
</svg>
|
||||
<div
|
||||
className="absolute top-0 flex size-full"
|
||||
style={{
|
||||
width: 'calc(100% * 31 / 30)',
|
||||
transform: 'translateX(-50%)',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{data.map((point, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-full opacity-0 hover:opacity-100"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute top-3 flex flex-col items-center rounded bg-surface/80 p-2 text-xs text-content"
|
||||
style={{
|
||||
transform: 'translateX(-50%)',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
<span>{formatDate(point.date)}</span>
|
||||
<span>{point.minutes_read} minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
frontend/src/components/Skeleton.tsx
Normal file
215
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className = '',
|
||||
variant = 'default',
|
||||
width,
|
||||
height,
|
||||
animation = 'pulse',
|
||||
}: SkeletonProps) {
|
||||
const baseClasses = 'bg-surface-strong';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'rounded',
|
||||
text: 'h-4 rounded-md',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-none',
|
||||
};
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-wave',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const style = {
|
||||
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
|
||||
height:
|
||||
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTextProps {
|
||||
lines?: number;
|
||||
className?: string;
|
||||
lineClassName?: string;
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonAvatarProps {
|
||||
size?: number | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
|
||||
const sizeMap = {
|
||||
sm: 32,
|
||||
md: 40,
|
||||
lg: 56,
|
||||
};
|
||||
|
||||
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
|
||||
|
||||
return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
className?: string;
|
||||
showAvatar?: boolean;
|
||||
showTitle?: boolean;
|
||||
showText?: boolean;
|
||||
textLines?: number;
|
||||
}
|
||||
|
||||
export function SkeletonCard({
|
||||
className = '',
|
||||
showAvatar = false,
|
||||
showTitle = true,
|
||||
showText = true,
|
||||
textLines = 3,
|
||||
}: SkeletonCardProps) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
|
||||
{showAvatar && (
|
||||
<div className="mb-4 flex items-start gap-4">
|
||||
<SkeletonAvatar />
|
||||
<div className="flex-1">
|
||||
<Skeleton variant="text" className="mb-2 w-3/4" />
|
||||
<Skeleton variant="text" className="w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
|
||||
{showText && <SkeletonText lines={textLines} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTableProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function SkeletonTable({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
className = '',
|
||||
showHeader = true,
|
||||
}: SkeletonTableProps) {
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
|
||||
<table className="min-w-full">
|
||||
{showHeader && (
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className="p-3">
|
||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-3">
|
||||
<Skeleton
|
||||
variant="text"
|
||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonButtonProps {
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
}
|
||||
|
||||
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
|
||||
return (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
height={36}
|
||||
width={width || '100%'}
|
||||
className={cn('rounded', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageLoaderProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
|
||||
return (
|
||||
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
|
||||
<div className="relative">
|
||||
<div className="size-12 animate-spin rounded-full border-4 border-surface-strong border-t-secondary-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-muted">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineLoaderProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
|
||||
const sizeMap = {
|
||||
sm: 'h-4 w-4 border-2',
|
||||
md: 'h-6 w-6 border-[3px]',
|
||||
lg: 'h-8 w-8 border-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<div
|
||||
className={`${sizeMap[size]} animate-spin rounded-full border-surface-strong border-t-secondary-500`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SkeletonTable as SkeletonTableExport };
|
||||
56
frontend/src/components/Table.test.tsx
Normal file
56
frontend/src/components/Table.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Table, type Column } from './Table';
|
||||
|
||||
interface TestRow {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const columns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
},
|
||||
];
|
||||
|
||||
const data: TestRow[] = [
|
||||
{ id: 'user-1', name: 'Ada', role: 'Admin' },
|
||||
{ id: 'user-2', name: 'Grace', role: 'Reader' },
|
||||
];
|
||||
|
||||
describe('Table', () => {
|
||||
it('renders a skeleton table while loading', () => {
|
||||
const { container } = render(<Table columns={columns} data={[]} loading />);
|
||||
|
||||
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders the empty state message when there is no data', () => {
|
||||
render(<Table columns={columns} data={[]} emptyMessage="Nothing here" />);
|
||||
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses a custom render function for column output', () => {
|
||||
const customColumns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
|
||||
},
|
||||
];
|
||||
|
||||
render(<Table columns={customColumns} data={data} />);
|
||||
|
||||
expect(screen.getByText('1. ADA')).toBeInTheDocument();
|
||||
expect(screen.getByText('2. GRACE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
125
frontend/src/components/Table.tsx
Normal file
125
frontend/src/components/Table.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
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 object> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
rowKey?: keyof T | ((row: T) => string);
|
||||
}
|
||||
|
||||
function SkeletonTable({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
className = '',
|
||||
}: {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className="p-3">
|
||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-3">
|
||||
<Skeleton
|
||||
variant="text"
|
||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Table<T extends object>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
emptyMessage = 'No Results',
|
||||
rowKey,
|
||||
}: TableProps<T>) {
|
||||
const getRowKey = (row: T, index: number): string => {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(row);
|
||||
}
|
||||
if (rowKey) {
|
||||
return String(row[rowKey] ?? index);
|
||||
}
|
||||
return `row-${index}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <SkeletonTable rows={5} columns={columns.length} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-surface">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={String(column.key)}
|
||||
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="p-3 text-center text-content-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={`${getRowKey(row, index)}-${String(column.key)}`}
|
||||
className={`p-3 text-content ${column.className || ''}`}
|
||||
>
|
||||
{column.render
|
||||
? column.render(row[column.key], row, index)
|
||||
: (row[column.key] as React.ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/Toast.tsx
Normal file
87
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
|
||||
|
||||
export type ToastType = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
onClose?: (id: string) => void;
|
||||
}
|
||||
|
||||
const getToastStyles = (_type: ToastType) => {
|
||||
const baseStyles =
|
||||
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
|
||||
|
||||
const typeStyles = {
|
||||
info: 'border-secondary-500 bg-secondary-100',
|
||||
warning: 'border-yellow-500 bg-yellow-100',
|
||||
error: 'border-red-500 bg-red-100',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
info: 'text-secondary-700',
|
||||
warning: 'text-yellow-700',
|
||||
error: 'text-red-700',
|
||||
};
|
||||
|
||||
const textStyles = {
|
||||
info: 'text-secondary-900',
|
||||
warning: 'text-yellow-900',
|
||||
error: 'text-red-900',
|
||||
};
|
||||
|
||||
return { baseStyles, typeStyles, iconStyles, textStyles };
|
||||
};
|
||||
|
||||
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||
|
||||
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.(id);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(handleClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: <InfoIcon size={20} className={iconStyles[type]} />,
|
||||
warning: <WarningIcon size={20} className={iconStyles[type]} />,
|
||||
error: <ErrorIcon size={20} className={iconStyles[type]} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseStyles} ${typeStyles[type]} ${
|
||||
isAnimatingOut ? 'translate-x-full opacity-0' : 'animate-slideInRight opacity-100'
|
||||
}`}
|
||||
>
|
||||
{icons[type]}
|
||||
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>{message}</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/ToastContext.tsx
Normal file
95
frontend/src/components/ToastContext.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { Toast, ToastType, ToastProps } from './Toast';
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
||||
showInfo: (message: string, duration?: number) => string;
|
||||
showWarning: (message: string, duration?: number) => string;
|
||||
showError: (message: string, duration?: number) => string;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, _type: ToastType = 'info', _duration?: number): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
setToasts(prev => [
|
||||
...prev,
|
||||
{ id, type: _type, message, duration: _duration, onClose: removeToast },
|
||||
]);
|
||||
return id;
|
||||
},
|
||||
[removeToast]
|
||||
);
|
||||
|
||||
const showInfo = useCallback(
|
||||
(message: string, _duration?: number) => {
|
||||
return showToast(message, 'info', _duration);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showWarning = useCallback(
|
||||
(message: string, _duration?: number) => {
|
||||
return showToast(message, 'warning', _duration);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showError = useCallback(
|
||||
(message: string, _duration?: number) => {
|
||||
return showToast(message, 'error', _duration);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
||||
>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: (ToastProps & { id: string })[];
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts }: ToastContainerProps) {
|
||||
if (toasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<Toast {...toast} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToasts() {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useToasts must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
24
frontend/src/components/index.ts
Normal file
24
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Reading History Graph
|
||||
export { default as ReadingHistoryGraph } from './ReadingHistoryGraph';
|
||||
|
||||
// Toast components
|
||||
export { Toast } from './Toast';
|
||||
export { ToastProvider, useToasts } from './ToastContext';
|
||||
export type { ToastType, ToastProps } from './Toast';
|
||||
|
||||
// Skeleton components
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonButton,
|
||||
PageLoader,
|
||||
InlineLoader,
|
||||
} from './Skeleton';
|
||||
export { LoadingState } from './LoadingState';
|
||||
export { Pagination } from './Pagination';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
4117
frontend/src/generated/anthoLumeAPIV1.ts
Normal file
4117
frontend/src/generated/anthoLumeAPIV1.ts
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/src/generated/model/activity.ts
Normal file
19
frontend/src/generated/model/activity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface Activity {
|
||||
document_id: string;
|
||||
device_id: string;
|
||||
start_time: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
duration: number;
|
||||
start_percentage: number;
|
||||
end_percentage: number;
|
||||
read_percentage: number;
|
||||
}
|
||||
17
frontend/src/generated/model/activityResponse.ts
Normal file
17
frontend/src/generated/model/activityResponse.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Activity } from './activity';
|
||||
|
||||
export interface ActivityResponse {
|
||||
activities: Activity[];
|
||||
page: number;
|
||||
limit: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
total: number;
|
||||
}
|
||||
15
frontend/src/generated/model/backupType.ts
Normal file
15
frontend/src/generated/model/backupType.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type BackupType = typeof BackupType[keyof typeof BackupType];
|
||||
|
||||
|
||||
export const BackupType = {
|
||||
COVERS: 'COVERS',
|
||||
DOCUMENTS: 'DOCUMENTS',
|
||||
} as const;
|
||||
13
frontend/src/generated/model/configResponse.ts
Normal file
13
frontend/src/generated/model/configResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface ConfigResponse {
|
||||
version: string;
|
||||
search_enabled: boolean;
|
||||
registration_enabled: boolean;
|
||||
}
|
||||
15
frontend/src/generated/model/createActivityItem.ts
Normal file
15
frontend/src/generated/model/createActivityItem.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface CreateActivityItem {
|
||||
document_id: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
}
|
||||
14
frontend/src/generated/model/createActivityRequest.ts
Normal file
14
frontend/src/generated/model/createActivityRequest.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { CreateActivityItem } from './createActivityItem';
|
||||
|
||||
export interface CreateActivityRequest {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
activity: CreateActivityItem[];
|
||||
}
|
||||
11
frontend/src/generated/model/createActivityResponse.ts
Normal file
11
frontend/src/generated/model/createActivityResponse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface CreateActivityResponse {
|
||||
added: number;
|
||||
}
|
||||
11
frontend/src/generated/model/createDocumentBody.ts
Normal file
11
frontend/src/generated/model/createDocumentBody.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type CreateDocumentBody = {
|
||||
document_file: Blob;
|
||||
};
|
||||
14
frontend/src/generated/model/databaseInfo.ts
Normal file
14
frontend/src/generated/model/databaseInfo.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface DatabaseInfo {
|
||||
documents_size: number;
|
||||
activity_size: number;
|
||||
progress_size: number;
|
||||
devices_size: number;
|
||||
}
|
||||
14
frontend/src/generated/model/device.ts
Normal file
14
frontend/src/generated/model/device.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface Device {
|
||||
id?: string;
|
||||
device_name?: string;
|
||||
created_at?: string;
|
||||
last_synced?: string;
|
||||
}
|
||||
12
frontend/src/generated/model/directoryItem.ts
Normal file
12
frontend/src/generated/model/directoryItem.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface DirectoryItem {
|
||||
name?: string;
|
||||
path?: string;
|
||||
}
|
||||
13
frontend/src/generated/model/directoryListResponse.ts
Normal file
13
frontend/src/generated/model/directoryListResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { DirectoryItem } from './directoryItem';
|
||||
|
||||
export interface DirectoryListResponse {
|
||||
current_path?: string;
|
||||
items?: DirectoryItem[];
|
||||
}
|
||||
26
frontend/src/generated/model/document.ts
Normal file
26
frontend/src/generated/model/document.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description?: string;
|
||||
isbn10?: string;
|
||||
isbn13?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted: boolean;
|
||||
words?: number;
|
||||
filepath?: string;
|
||||
percentage?: number;
|
||||
total_time_seconds?: number;
|
||||
wpm?: number;
|
||||
seconds_per_percent?: number;
|
||||
last_read?: string;
|
||||
}
|
||||
12
frontend/src/generated/model/documentResponse.ts
Normal file
12
frontend/src/generated/model/documentResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Document } from './document';
|
||||
|
||||
export interface DocumentResponse {
|
||||
document: Document;
|
||||
}
|
||||
18
frontend/src/generated/model/documentsResponse.ts
Normal file
18
frontend/src/generated/model/documentsResponse.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Document } from './document';
|
||||
|
||||
export interface DocumentsResponse {
|
||||
documents: Document[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
search?: string;
|
||||
}
|
||||
16
frontend/src/generated/model/editDocumentBody.ts
Normal file
16
frontend/src/generated/model/editDocumentBody.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type EditDocumentBody = {
|
||||
title?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
isbn10?: string;
|
||||
isbn13?: string;
|
||||
cover_gbid?: string;
|
||||
};
|
||||
12
frontend/src/generated/model/errorResponse.ts
Normal file
12
frontend/src/generated/model/errorResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface ErrorResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
14
frontend/src/generated/model/getActivityParams.ts
Normal file
14
frontend/src/generated/model/getActivityParams.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type GetActivityParams = {
|
||||
doc_filter?: boolean;
|
||||
document_id?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
12
frontend/src/generated/model/getAdmin200.ts
Normal file
12
frontend/src/generated/model/getAdmin200.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { DatabaseInfo } from './databaseInfo';
|
||||
|
||||
export type GetAdmin200 = {
|
||||
database_info?: DatabaseInfo;
|
||||
};
|
||||
13
frontend/src/generated/model/getDocumentsParams.ts
Normal file
13
frontend/src/generated/model/getDocumentsParams.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type GetDocumentsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
};
|
||||
12
frontend/src/generated/model/getImportDirectoryParams.ts
Normal file
12
frontend/src/generated/model/getImportDirectoryParams.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type GetImportDirectoryParams = {
|
||||
directory?: string;
|
||||
select?: string;
|
||||
};
|
||||
19
frontend/src/generated/model/getLogsParams.ts
Normal file
19
frontend/src/generated/model/getLogsParams.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type GetLogsParams = {
|
||||
filter?: string;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
13
frontend/src/generated/model/getProgressListParams.ts
Normal file
13
frontend/src/generated/model/getProgressListParams.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type GetProgressListParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
document?: string;
|
||||
};
|
||||
13
frontend/src/generated/model/getSearchParams.ts
Normal file
13
frontend/src/generated/model/getSearchParams.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { GetSearchSource } from './getSearchSource';
|
||||
|
||||
export type GetSearchParams = {
|
||||
query: string;
|
||||
source: GetSearchSource;
|
||||
};
|
||||
15
frontend/src/generated/model/getSearchSource.ts
Normal file
15
frontend/src/generated/model/getSearchSource.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type GetSearchSource = typeof GetSearchSource[keyof typeof GetSearchSource];
|
||||
|
||||
|
||||
export const GetSearchSource = {
|
||||
LibGen: 'LibGen',
|
||||
Annas_Archive: 'Annas Archive',
|
||||
} as const;
|
||||
12
frontend/src/generated/model/graphDataPoint.ts
Normal file
12
frontend/src/generated/model/graphDataPoint.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface GraphDataPoint {
|
||||
date: string;
|
||||
minutes_read: number;
|
||||
}
|
||||
12
frontend/src/generated/model/graphDataResponse.ts
Normal file
12
frontend/src/generated/model/graphDataResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { GraphDataPoint } from './graphDataPoint';
|
||||
|
||||
export interface GraphDataResponse {
|
||||
graph_data: GraphDataPoint[];
|
||||
}
|
||||
18
frontend/src/generated/model/homeResponse.ts
Normal file
18
frontend/src/generated/model/homeResponse.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { DatabaseInfo } from './databaseInfo';
|
||||
import type { GraphDataResponse } from './graphDataResponse';
|
||||
import type { StreaksResponse } from './streaksResponse';
|
||||
import type { UserStatisticsResponse } from './userStatisticsResponse';
|
||||
|
||||
export interface HomeResponse {
|
||||
database_info: DatabaseInfo;
|
||||
streaks: StreaksResponse;
|
||||
graph_data: GraphDataResponse;
|
||||
user_statistics: UserStatisticsResponse;
|
||||
}
|
||||
16
frontend/src/generated/model/importResult.ts
Normal file
16
frontend/src/generated/model/importResult.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { ImportResultStatus } from './importResultStatus';
|
||||
|
||||
export interface ImportResult {
|
||||
id?: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
status?: ImportResultStatus;
|
||||
error?: string;
|
||||
}
|
||||
16
frontend/src/generated/model/importResultStatus.ts
Normal file
16
frontend/src/generated/model/importResultStatus.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type ImportResultStatus = typeof ImportResultStatus[keyof typeof ImportResultStatus];
|
||||
|
||||
|
||||
export const ImportResultStatus = {
|
||||
FAILED: 'FAILED',
|
||||
SUCCESS: 'SUCCESS',
|
||||
EXISTS: 'EXISTS',
|
||||
} as const;
|
||||
12
frontend/src/generated/model/importResultsResponse.ts
Normal file
12
frontend/src/generated/model/importResultsResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { ImportResult } from './importResult';
|
||||
|
||||
export interface ImportResultsResponse {
|
||||
results?: ImportResult[];
|
||||
}
|
||||
15
frontend/src/generated/model/importType.ts
Normal file
15
frontend/src/generated/model/importType.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type ImportType = typeof ImportType[keyof typeof ImportType];
|
||||
|
||||
|
||||
export const ImportType = {
|
||||
DIRECT: 'DIRECT',
|
||||
COPY: 'COPY',
|
||||
} as const;
|
||||
73
frontend/src/generated/model/index.ts
Normal file
73
frontend/src/generated/model/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export * from './activity';
|
||||
export * from './activityResponse';
|
||||
export * from './backupType';
|
||||
export * from './configResponse';
|
||||
export * from './createActivityItem';
|
||||
export * from './createActivityRequest';
|
||||
export * from './createActivityResponse';
|
||||
export * from './createDocumentBody';
|
||||
export * from './databaseInfo';
|
||||
export * from './device';
|
||||
export * from './directoryItem';
|
||||
export * from './directoryListResponse';
|
||||
export * from './document';
|
||||
export * from './documentResponse';
|
||||
export * from './documentsResponse';
|
||||
export * from './editDocumentBody';
|
||||
export * from './errorResponse';
|
||||
export * from './getActivityParams';
|
||||
export * from './getAdmin200';
|
||||
export * from './getDocumentsParams';
|
||||
export * from './getImportDirectoryParams';
|
||||
export * from './getLogsParams';
|
||||
export * from './getProgressListParams';
|
||||
export * from './getSearchParams';
|
||||
export * from './getSearchSource';
|
||||
export * from './graphDataPoint';
|
||||
export * from './graphDataResponse';
|
||||
export * from './homeResponse';
|
||||
export * from './importResult';
|
||||
export * from './importResultsResponse';
|
||||
export * from './importResultStatus';
|
||||
export * from './importType';
|
||||
export * from './infoResponse';
|
||||
export * from './leaderboardData';
|
||||
export * from './leaderboardEntry';
|
||||
export * from './logEntry';
|
||||
export * from './loginRequest';
|
||||
export * from './loginResponse';
|
||||
export * from './logsResponse';
|
||||
export * from './messageResponse';
|
||||
export * from './operationType';
|
||||
export * from './postAdminActionBody';
|
||||
export * from './postAdminActionBodyAction';
|
||||
export * from './postImportBody';
|
||||
export * from './postSearchBody';
|
||||
export * from './progress';
|
||||
export * from './progressListResponse';
|
||||
export * from './progressResponse';
|
||||
export * from './searchItem';
|
||||
export * from './searchResponse';
|
||||
export * from './setting';
|
||||
export * from './settingsResponse';
|
||||
export * from './streaksResponse';
|
||||
export * from './updateDocumentBody';
|
||||
export * from './updateProgressRequest';
|
||||
export * from './updateProgressResponse';
|
||||
export * from './updateSettingsRequest';
|
||||
export * from './updateUserBody';
|
||||
export * from './uploadDocumentCoverBody';
|
||||
export * from './user';
|
||||
export * from './userData';
|
||||
export * from './usersResponse';
|
||||
export * from './userStatisticsResponse';
|
||||
export * from './userStreak';
|
||||
export * from './wordCount';
|
||||
13
frontend/src/generated/model/infoResponse.ts
Normal file
13
frontend/src/generated/model/infoResponse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface InfoResponse {
|
||||
version: string;
|
||||
search_enabled: boolean;
|
||||
registration_enabled: boolean;
|
||||
}
|
||||
15
frontend/src/generated/model/leaderboardData.ts
Normal file
15
frontend/src/generated/model/leaderboardData.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { LeaderboardEntry } from './leaderboardEntry';
|
||||
|
||||
export interface LeaderboardData {
|
||||
all: LeaderboardEntry[];
|
||||
year: LeaderboardEntry[];
|
||||
month: LeaderboardEntry[];
|
||||
week: LeaderboardEntry[];
|
||||
}
|
||||
12
frontend/src/generated/model/leaderboardEntry.ts
Normal file
12
frontend/src/generated/model/leaderboardEntry.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
user_id: string;
|
||||
value: number;
|
||||
}
|
||||
9
frontend/src/generated/model/logEntry.ts
Normal file
9
frontend/src/generated/model/logEntry.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type LogEntry = string;
|
||||
12
frontend/src/generated/model/loginRequest.ts
Normal file
12
frontend/src/generated/model/loginRequest.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
12
frontend/src/generated/model/loginResponse.ts
Normal file
12
frontend/src/generated/model/loginResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface LoginResponse {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
18
frontend/src/generated/model/logsResponse.ts
Normal file
18
frontend/src/generated/model/logsResponse.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { LogEntry } from './logEntry';
|
||||
|
||||
export interface LogsResponse {
|
||||
logs?: LogEntry[];
|
||||
filter?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
total?: number;
|
||||
}
|
||||
11
frontend/src/generated/model/messageResponse.ts
Normal file
11
frontend/src/generated/model/messageResponse.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string;
|
||||
}
|
||||
16
frontend/src/generated/model/operationType.ts
Normal file
16
frontend/src/generated/model/operationType.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type OperationType = typeof OperationType[keyof typeof OperationType];
|
||||
|
||||
|
||||
export const OperationType = {
|
||||
CREATE: 'CREATE',
|
||||
UPDATE: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
} as const;
|
||||
15
frontend/src/generated/model/postAdminActionBody.ts
Normal file
15
frontend/src/generated/model/postAdminActionBody.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { BackupType } from './backupType';
|
||||
import type { PostAdminActionBodyAction } from './postAdminActionBodyAction';
|
||||
|
||||
export type PostAdminActionBody = {
|
||||
action: PostAdminActionBodyAction;
|
||||
backup_types?: BackupType[];
|
||||
restore_file?: Blob;
|
||||
};
|
||||
17
frontend/src/generated/model/postAdminActionBodyAction.ts
Normal file
17
frontend/src/generated/model/postAdminActionBodyAction.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type PostAdminActionBodyAction = typeof PostAdminActionBodyAction[keyof typeof PostAdminActionBodyAction];
|
||||
|
||||
|
||||
export const PostAdminActionBodyAction = {
|
||||
BACKUP: 'BACKUP',
|
||||
RESTORE: 'RESTORE',
|
||||
METADATA_MATCH: 'METADATA_MATCH',
|
||||
CACHE_TABLES: 'CACHE_TABLES',
|
||||
} as const;
|
||||
13
frontend/src/generated/model/postImportBody.ts
Normal file
13
frontend/src/generated/model/postImportBody.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { ImportType } from './importType';
|
||||
|
||||
export type PostImportBody = {
|
||||
directory: string;
|
||||
type: ImportType;
|
||||
};
|
||||
14
frontend/src/generated/model/postSearchBody.ts
Normal file
14
frontend/src/generated/model/postSearchBody.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type PostSearchBody = {
|
||||
source: string;
|
||||
title: string;
|
||||
author: string;
|
||||
id: string;
|
||||
};
|
||||
19
frontend/src/generated/model/progress.ts
Normal file
19
frontend/src/generated/model/progress.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface Progress {
|
||||
title?: string;
|
||||
author?: string;
|
||||
device_name?: string;
|
||||
device_id?: string;
|
||||
percentage?: number;
|
||||
progress?: string;
|
||||
document_id?: string;
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
17
frontend/src/generated/model/progressListResponse.ts
Normal file
17
frontend/src/generated/model/progressListResponse.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Progress } from './progress';
|
||||
|
||||
export interface ProgressListResponse {
|
||||
progress?: Progress[];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
total?: number;
|
||||
}
|
||||
12
frontend/src/generated/model/progressResponse.ts
Normal file
12
frontend/src/generated/model/progressResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Progress } from './progress';
|
||||
|
||||
export interface ProgressResponse {
|
||||
progress?: Progress;
|
||||
}
|
||||
18
frontend/src/generated/model/searchItem.ts
Normal file
18
frontend/src/generated/model/searchItem.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface SearchItem {
|
||||
id?: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
series?: string;
|
||||
file_type?: string;
|
||||
file_size?: string;
|
||||
upload_date?: string;
|
||||
}
|
||||
14
frontend/src/generated/model/searchResponse.ts
Normal file
14
frontend/src/generated/model/searchResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { SearchItem } from './searchItem';
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchItem[];
|
||||
source: string;
|
||||
query: string;
|
||||
}
|
||||
14
frontend/src/generated/model/setting.ts
Normal file
14
frontend/src/generated/model/setting.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface Setting {
|
||||
id: string;
|
||||
user_id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
15
frontend/src/generated/model/settingsResponse.ts
Normal file
15
frontend/src/generated/model/settingsResponse.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { Device } from './device';
|
||||
import type { UserData } from './userData';
|
||||
|
||||
export interface SettingsResponse {
|
||||
user: UserData;
|
||||
timezone?: string;
|
||||
devices?: Device[];
|
||||
}
|
||||
12
frontend/src/generated/model/streaksResponse.ts
Normal file
12
frontend/src/generated/model/streaksResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { UserStreak } from './userStreak';
|
||||
|
||||
export interface StreaksResponse {
|
||||
streaks: UserStreak[];
|
||||
}
|
||||
15
frontend/src/generated/model/updateDocumentBody.ts
Normal file
15
frontend/src/generated/model/updateDocumentBody.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type UpdateDocumentBody = {
|
||||
title?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
isbn10?: string;
|
||||
isbn13?: string;
|
||||
};
|
||||
15
frontend/src/generated/model/updateProgressRequest.ts
Normal file
15
frontend/src/generated/model/updateProgressRequest.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UpdateProgressRequest {
|
||||
document_id: string;
|
||||
percentage: number;
|
||||
progress: string;
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
}
|
||||
12
frontend/src/generated/model/updateProgressResponse.ts
Normal file
12
frontend/src/generated/model/updateProgressResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UpdateProgressResponse {
|
||||
document_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
13
frontend/src/generated/model/updateSettingsRequest.ts
Normal file
13
frontend/src/generated/model/updateSettingsRequest.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
password?: string;
|
||||
new_password?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
15
frontend/src/generated/model/updateUserBody.ts
Normal file
15
frontend/src/generated/model/updateUserBody.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { OperationType } from './operationType';
|
||||
|
||||
export type UpdateUserBody = {
|
||||
operation: OperationType;
|
||||
user: string;
|
||||
password?: string;
|
||||
is_admin?: boolean;
|
||||
};
|
||||
11
frontend/src/generated/model/uploadDocumentCoverBody.ts
Normal file
11
frontend/src/generated/model/uploadDocumentCoverBody.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export type UploadDocumentCoverBody = {
|
||||
cover_file: Blob;
|
||||
};
|
||||
13
frontend/src/generated/model/user.ts
Normal file
13
frontend/src/generated/model/user.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
12
frontend/src/generated/model/userData.ts
Normal file
12
frontend/src/generated/model/userData.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
14
frontend/src/generated/model/userStatisticsResponse.ts
Normal file
14
frontend/src/generated/model/userStatisticsResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.5.3 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
import type { LeaderboardData } from './leaderboardData';
|
||||
|
||||
export interface UserStatisticsResponse {
|
||||
wpm: LeaderboardData;
|
||||
duration: LeaderboardData;
|
||||
words: LeaderboardData;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user