Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09daf6d511 | |||
| 9158648f3f | |||
| 1ff96ee9c8 | |||
| 457eb550e4 | |||
| 5a8b773a0b | |||
| c9c9563a1f | |||
| 232a821dc0 | |||
| 6231befaa8 | |||
| c327a3905d | |||
| 45e018b024 | |||
| 2c2eca8360 |
@@ -1,9 +1,9 @@
|
||||
root = "."
|
||||
tmp_dir = "_scratch/air"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./_scratch/air/antholume ."
|
||||
full_bin = "./_scratch/air/antholume serve"
|
||||
cmd = "go build -o ./tmp/antholume ."
|
||||
full_bin = "./tmp/antholume serve"
|
||||
delay = 1000
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "css", "js", "yaml", "yml"]
|
||||
exclude_dir = ["_scratch", "build", "data", "frontend", "node_modules"]
|
||||
|
||||
@@ -22,9 +22,11 @@ Edit:
|
||||
|
||||
Regenerate:
|
||||
- `go generate ./api/v1/generate.go`
|
||||
- `cd frontend && bun run generate:api`
|
||||
- `cd frontend && pnpm run generate:api`
|
||||
|
||||
Notes:
|
||||
- `format: date-time` diverges by generator: **oapi-codegen (Go)** emits `time.Time`, while **orval (TS)** keeps `string`. Adding it to an existing string field will break Go handlers until they supply a `time.Time` (see `parseTime` / `parseTimeAny` in `api/v1/utils.go`).
|
||||
- Marking a schema field `required` flips the generated Go type from a pointer to a value; update every handler build site to drop the `&`. Only require a field when **all** endpoints sharing that schema populate it — `Progress` is populated with different subsets by its list vs single handlers.
|
||||
- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly.
|
||||
|
||||
Examples of generated files:
|
||||
@@ -51,7 +53,7 @@ Regenerate:
|
||||
|
||||
### Notes
|
||||
- The Go server embeds `templates/*` and `assets/*`.
|
||||
- Root Tailwind output is built to `assets/style.css`.
|
||||
- Root Tailwind output is built to `assets/style.css`. Two separate Tailwind setups exist: the legacy server-rendered app uses **Tailwind v3** via the root `tailwind.config.js` + `make legacy_tailwind`; the React app in `frontend/` uses **Tailwind v4** (CSS-first, no config file). Keep them distinct.
|
||||
- Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both.
|
||||
- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
|
||||
- `DISABLE_AUTH=true` bypasses authentication on **all** routes (v1 API, legacy web app, KOSync, OPDS). Set `DISABLE_AUTH_USER=<username>` to control which database user the session impersonates (defaults to the first user in the DB). The user must already exist.
|
||||
@@ -64,7 +66,7 @@ For frontend-specific implementation notes and commands, also read:
|
||||
## 6) Regeneration Summary
|
||||
|
||||
- Go API: `go generate ./api/v1/generate.go`
|
||||
- Frontend API client: `cd frontend && bun run generate:api`
|
||||
- Frontend API client: `cd frontend && pnpm run generate:api`
|
||||
- SQLC: `sqlc generate`
|
||||
|
||||
## 7) Live Dev Server Debugging
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend clean tests
|
||||
.PHONY: build_local docker_build_local docker_build_release_dev docker_build_release_latest build_tailwind legacy_tailwind dev dev_backend dev_frontend dev_noauth clean tests
|
||||
|
||||
DEV_ENV = GIN_MODE=release \
|
||||
CONFIG_PATH=./data \
|
||||
@@ -9,6 +9,8 @@ DEV_ENV = GIN_MODE=release \
|
||||
COOKIE_AUTH_KEY=1234 \
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DEV_USER ?= evan
|
||||
|
||||
build_local: legacy_tailwind
|
||||
go mod download
|
||||
rm -r ./build || true
|
||||
@@ -49,7 +51,10 @@ dev_backend:
|
||||
$(DEV_ENV) air
|
||||
|
||||
dev_frontend:
|
||||
cd frontend && bun run dev
|
||||
cd frontend && pnpm run dev
|
||||
|
||||
dev_noauth:
|
||||
DISABLE_AUTH=true DISABLE_AUTH_USER=$(DEV_USER) $(MAKE) dev
|
||||
|
||||
clean:
|
||||
rm -rf ./build
|
||||
|
||||
+1
-9
@@ -68,18 +68,10 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
||||
|
||||
apiActivities := make([]Activity, len(activities))
|
||||
for i, a := range activities {
|
||||
// Convert StartTime from interface{} to string
|
||||
startTimeStr := ""
|
||||
if a.StartTime != nil {
|
||||
if str, ok := a.StartTime.(string); ok {
|
||||
startTimeStr = str
|
||||
}
|
||||
}
|
||||
|
||||
apiActivities[i] = Activity{
|
||||
DocumentId: a.DocumentID,
|
||||
DeviceId: a.DeviceID,
|
||||
StartTime: startTimeStr,
|
||||
StartTime: parseTimeAny(a.StartTime),
|
||||
Title: a.Title,
|
||||
Author: a.Author,
|
||||
Duration: a.Duration,
|
||||
|
||||
+29
-30
@@ -145,15 +145,15 @@ func (e GetSearchParamsSource) Valid() bool {
|
||||
|
||||
// Activity defines model for Activity.
|
||||
type Activity struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
DeviceId string `json:"device_id"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Duration int64 `json:"duration"`
|
||||
EndPercentage float32 `json:"end_percentage"`
|
||||
ReadPercentage float32 `json:"read_percentage"`
|
||||
StartPercentage float32 `json:"start_percentage"`
|
||||
StartTime string `json:"start_time"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
DeviceId string `json:"device_id"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Duration int64 `json:"duration"`
|
||||
EndPercentage float32 `json:"end_percentage"`
|
||||
ReadPercentage float32 `json:"read_percentage"`
|
||||
StartPercentage float32 `json:"start_percentage"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ActivityResponse defines model for ActivityResponse.
|
||||
@@ -200,10 +200,10 @@ type DatabaseInfo struct {
|
||||
|
||||
// Device defines model for Device.
|
||||
type Device struct {
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
DeviceName *string `json:"device_name,omitempty"`
|
||||
Id *string `json:"id,omitempty"`
|
||||
LastSynced *time.Time `json:"last_synced,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Id string `json:"id"`
|
||||
LastSynced time.Time `json:"last_synced"`
|
||||
}
|
||||
|
||||
// DirectoryItem defines model for DirectoryItem.
|
||||
@@ -356,15 +356,15 @@ type OperationType string
|
||||
|
||||
// Progress defines model for Progress.
|
||||
type Progress struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
DeviceName *string `json:"device_name,omitempty"`
|
||||
DocumentId *string `json:"document_id,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
Progress *string `json:"progress,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UserId *string `json:"user_id,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Progress *string `json:"progress,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UserId string `json:"user_id"`
|
||||
}
|
||||
|
||||
// ProgressListResponse defines model for ProgressListResponse.
|
||||
@@ -384,14 +384,13 @@ type ProgressResponse struct {
|
||||
|
||||
// SearchItem defines model for SearchItem.
|
||||
type SearchItem struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
FileSize *string `json:"file_size,omitempty"`
|
||||
FileType *string `json:"file_type,omitempty"`
|
||||
Id *string `json:"id,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Series *string `json:"series,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UploadDate *string `json:"upload_date,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
FileSize *string `json:"file_size,omitempty"`
|
||||
FileType *string `json:"file_type,omitempty"`
|
||||
Id *string `json:"id,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Series *string `json:"series,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResponse defines model for SearchResponse.
|
||||
|
||||
+12
-2
@@ -106,6 +106,12 @@ components:
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- device_name
|
||||
- percentage
|
||||
- document_id
|
||||
- user_id
|
||||
- created_at
|
||||
|
||||
UpdateProgressRequest:
|
||||
type: object
|
||||
@@ -198,6 +204,7 @@ components:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
title:
|
||||
type: string
|
||||
author:
|
||||
@@ -240,8 +247,6 @@ components:
|
||||
type: string
|
||||
file_size:
|
||||
type: string
|
||||
upload_date:
|
||||
type: string
|
||||
|
||||
SearchResponse:
|
||||
type: object
|
||||
@@ -384,6 +389,11 @@ components:
|
||||
last_synced:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- device_name
|
||||
- created_at
|
||||
- last_synced
|
||||
|
||||
SettingsResponse:
|
||||
type: object
|
||||
|
||||
+10
-10
@@ -68,11 +68,11 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
|
||||
apiProgress[i] = Progress{
|
||||
Title: row.Title,
|
||||
Author: row.Author,
|
||||
DeviceName: &row.DeviceName,
|
||||
Percentage: &row.Percentage,
|
||||
DocumentId: &row.DocumentID,
|
||||
UserId: &row.UserID,
|
||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||
DeviceName: row.DeviceName,
|
||||
Percentage: row.Percentage,
|
||||
DocumentId: row.DocumentID,
|
||||
UserId: row.UserID,
|
||||
CreatedAt: parseTimeAny(row.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
||||
}
|
||||
|
||||
apiProgress := Progress{
|
||||
DeviceName: &row.DeviceName,
|
||||
DeviceName: row.DeviceName,
|
||||
DeviceId: &row.DeviceID,
|
||||
Percentage: &row.Percentage,
|
||||
Percentage: row.Percentage,
|
||||
Progress: &row.Progress,
|
||||
DocumentId: &row.DocumentID,
|
||||
UserId: &row.UserID,
|
||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||
DocumentId: row.DocumentID,
|
||||
UserId: row.UserID,
|
||||
CreatedAt: parseTime(row.CreatedAt),
|
||||
}
|
||||
|
||||
response := ProgressResponse{
|
||||
|
||||
@@ -38,7 +38,6 @@ func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject)
|
||||
Series: ptrOf(item.Series),
|
||||
FileType: ptrOf(item.FileType),
|
||||
FileSize: ptrOf(item.FileSize),
|
||||
UploadDate: ptrOf(item.UploadDate),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -29,10 +29,10 @@ func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObje
|
||||
apiDevices := make([]Device, len(devices))
|
||||
for i, device := range devices {
|
||||
apiDevices[i] = Device{
|
||||
Id: &device.ID,
|
||||
DeviceName: &device.DeviceName,
|
||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||
LastSynced: parseTimePtr(device.LastSynced),
|
||||
Id: device.ID,
|
||||
DeviceName: device.DeviceName,
|
||||
CreatedAt: parseTimeAny(device.CreatedAt),
|
||||
LastSynced: parseTimeAny(device.LastSynced),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsReque
|
||||
apiDevices := make([]Device, len(devices))
|
||||
for i, device := range devices {
|
||||
apiDevices[i] = Device{
|
||||
Id: &device.ID,
|
||||
DeviceName: &device.DeviceName,
|
||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||
LastSynced: parseTimePtr(device.LastSynced),
|
||||
Id: device.ID,
|
||||
DeviceName: device.DeviceName,
|
||||
CreatedAt: parseTimeAny(device.CreatedAt),
|
||||
LastSynced: parseTimeAny(device.LastSynced),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,14 @@ func parseTime(s string) time.Time {
|
||||
return t
|
||||
}
|
||||
|
||||
// parseTimeAny parses an interface{} (from SQL) to time.Time, zero on failure.
|
||||
func parseTimeAny(v interface{}) time.Time {
|
||||
if s, ok := v.(string); ok {
|
||||
return parseTime(s)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// parseTimePtr parses an interface{} (from SQL) to *time.Time
|
||||
func parseTimePtr(v interface{}) *time.Time {
|
||||
if v == nil {
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
golangci-lint
|
||||
gopls
|
||||
|
||||
bun
|
||||
nodejs
|
||||
pnpm
|
||||
tailwindcss
|
||||
typescript-language-server
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"jsPlugins": ["oxlint-tailwindcss"],
|
||||
"ignorePatterns": ["src/generated/**", "dist/**"],
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"settings": {
|
||||
"tailwindcss": {
|
||||
"entryPoint": "src/index.css"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"tailwindcss/no-duplicate-classes": "error",
|
||||
"tailwindcss/no-conflicting-classes": "error",
|
||||
"tailwindcss/no-deprecated-classes": "error",
|
||||
"tailwindcss/no-unnecessary-whitespace": "warn",
|
||||
"tailwindcss/no-unknown-classes": "off"
|
||||
}
|
||||
}
|
||||
+36
-20
@@ -5,11 +5,12 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
|
||||
## 1) Stack
|
||||
|
||||
- Package manager: `bun`
|
||||
- Package manager: `pnpm`
|
||||
- Framework: React + Vite
|
||||
- Data fetching: React Query
|
||||
- API generation: Orval
|
||||
- Linting: ESLint + Tailwind plugin
|
||||
- Styling: Tailwind CSS v4 (CSS-first config)
|
||||
- Linting: oxlint (config in `.oxlintrc.json`) + `oxlint-tailwindcss` for Tailwind rules
|
||||
- Formatting: Prettier
|
||||
|
||||
## 2) Conventions
|
||||
@@ -17,23 +18,35 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
- Use local icon components from `src/icons/`.
|
||||
- Do not add external icon libraries.
|
||||
- Prefer generated types from `src/generated/model/` over `any`.
|
||||
- Unwrap API responses by narrowing on `status` (e.g. `data?.status === 200 ? data.data : undefined`); the generated response types are discriminated unions, so this needs no `as XResponse` cast.
|
||||
- Type form submit handlers as `SyntheticEvent` (optionally `SyntheticEvent<HTMLFormElement>`); `@types/react@19` deprecates `FormEvent`.
|
||||
- Route mutation success/error feedback through `useMutationWithToast` (`src/hooks/`); error-toast-on-failure is the app-wide pattern (treat non-2xx as failure).
|
||||
- Shared input styling lives in `TextInput` / the exported `inputClassName` (`src/components/TextInput.tsx`); reuse rather than re-pasting the input class string.
|
||||
- Render tabular data with the shared `<Table>` (`src/components/Table.tsx`). Columns are `{ id, header: ReactNode, render(row, index), className? }` — `id` is decoupled from data, so action columns are first-class. The table owns its own loading skeleton and empty state; don't hand-roll `<table>` markup for data grids. (Genuinely different widget shapes — e.g. a file-browser or a card-grid — are fine to build bespoke.)
|
||||
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
|
||||
- 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.
|
||||
- Prefer `LoadingState` for result-area loading indicators (the single loading convention); avoid early returns that unmount search/filter forms during fetches. React Query `isLoading` is initial-load-only (false on background refetches), so a full-page early-return on `isLoading` is fine for pages with no persistent filter form.
|
||||
- For mutations, use `useMutationWithToast` (declarative `.mutate(vars, options)` for fire-and-forget) or its imperative sibling `useToastMutation` (awaited, returns a success `boolean`) instead of hand-rolling `mutateAsync` → toast/catch blocks.
|
||||
- Use `SegmentedControl` for active/inactive toggle groups (view mode, period, reader theme/font) rather than re-implementing `option.map` + ternary class toggling; pass per-call `activeClassName`/`inactiveClassName`.
|
||||
- For a persistent/progress toast that resolves in place (long-running actions), create it with `showInfo(msg, 0)` and finish with `updateToast(id, { message, type, duration })`.
|
||||
- Use theme tokens defined in `src/index.css` `@theme` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs. There is no `tailwind.config.js` — Tailwind v4 config is CSS-first.
|
||||
- Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`.
|
||||
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
|
||||
- Reuse shared primitives instead of re-rolling them: `formatDate` / `formatDateTime` (`src/utils/formatters.ts`) for user-facing timestamps (`formatUtcDate` is intentionally UTC, for graph day-buckets only); `SegmentedControl` for toggle groups (default `pill` variant needs only `options`/`value`/`onChange`; pass `variant="unstyled"` for bespoke shapes like grids/inline text); `usePaginatedList` + `documentColumn` for paginated list/table pages.
|
||||
|
||||
## 3) Generated API client
|
||||
|
||||
- Do not edit `src/generated/**` directly.
|
||||
- Edit `../api/v1/openapi.yaml` and regenerate instead.
|
||||
- Regenerate with: `bun run generate:api`
|
||||
- Regenerate with: `pnpm 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.
|
||||
- The generated client returns the documented **success body directly** and throws `ApiError` (`src/utils/apiFetch.ts`) for non-2xx responses.
|
||||
- Use React Query's native error flow (`isError`, `error`, `onError`) instead of status narrowing.
|
||||
- Use `getErrorMessage` (`src/utils/errors.ts`) to display caught errors; `ApiError.message` already contains the server-provided error message when present.
|
||||
- Centralize mutation error/success feedback via `useMutationWithToast` or `useToastMutation` rather than inline toast/catch duplication.
|
||||
|
||||
## 4) Auth / Query State
|
||||
|
||||
@@ -43,26 +56,29 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
|
||||
## 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`
|
||||
- Lint: `pnpm run lint`
|
||||
- Typecheck: `pnpm run typecheck`
|
||||
- Lint fix: `pnpm run lint:fix`
|
||||
- Format check: `pnpm run format`
|
||||
- Format fix: `pnpm run format:fix`
|
||||
- Build: `pnpm run build`
|
||||
- Generate API client: `pnpm run generate:api`
|
||||
|
||||
## 6) Validation Notes
|
||||
|
||||
- ESLint ignores `src/generated/**`.
|
||||
- oxlint ignores `src/generated/**` and `dist/**` (via `ignorePatterns` in `.oxlintrc.json`).
|
||||
- `lint` runs `oxlint --max-warnings=0`; keep the tree warning-free. `react-hooks/exhaustive-deps` is enforced — fix deps rather than disabling the rule; use a justified inline `// oxlint-disable-next-line` only for genuine init-once effects.
|
||||
- The Tailwind lint plugin uses oxlint `jsPlugins` (experimental, not run by the editor language server), so Tailwind diagnostics surface via CLI/CI, not in-editor. It reads the theme from `src/index.css` (`settings.tailwindcss.entryPoint`).
|
||||
- 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.
|
||||
- `pnpm run lint` includes test files but does not typecheck.
|
||||
- Use `pnpm run typecheck` to run TypeScript validation for app code and colocated tests without a full production build.
|
||||
- Run frontend tests with `pnpm run test`.
|
||||
- `pnpm 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.
|
||||
- `pnpm run format` currently reports pre-existing style violations in files unrelated to most changes; format only the files you touched (`npx prettier --write <files>`) rather than reformatting the whole tree.
|
||||
|
||||
## 7) Live Dev Server Debugging
|
||||
|
||||
|
||||
+5
-5
@@ -68,7 +68,7 @@ The frontend mirrors the existing SSR templates structure:
|
||||
The frontend uses **Orval** to generate TypeScript types and React Query hooks from the OpenAPI spec:
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
pnpm run generate:api
|
||||
```
|
||||
|
||||
This generates:
|
||||
@@ -80,16 +80,16 @@ This generates:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
pnpm install
|
||||
|
||||
# Generate API types (if OpenAPI spec changes)
|
||||
npm run generate:api
|
||||
pnpm run generate:api
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
pnpm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -68,6 +68,6 @@ Trim or remove tests that are brittle, duplicated, or mostly validate tooling ra
|
||||
|
||||
For frontend test work, validate with:
|
||||
|
||||
- `cd frontend && bun run lint`
|
||||
- `cd frontend && bun run typecheck`
|
||||
- `cd frontend && bun run test`
|
||||
- `cd frontend && pnpm run lint`
|
||||
- `cd frontend && pnpm run typecheck`
|
||||
- `cd frontend && pnpm run test`
|
||||
|
||||
-1350
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -8,10 +8,16 @@ export default defineConfig({
|
||||
target: 'src/generated',
|
||||
schemas: 'src/generated/model',
|
||||
client: 'react-query',
|
||||
httpClient: 'fetch',
|
||||
mock: false,
|
||||
override: {
|
||||
useQuery: true,
|
||||
mutations: true,
|
||||
fetch: {
|
||||
includeHttpResponseReturnType: false,
|
||||
},
|
||||
mutator: {
|
||||
path: './src/utils/apiFetch.ts',
|
||||
name: 'apiFetch',
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
|
||||
+11
-16
@@ -9,16 +9,14 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate:api": "orval",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"lint": "oxlint --max-warnings=0",
|
||||
"lint:fix": "oxlint --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",
|
||||
@@ -29,28 +27,25 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@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",
|
||||
"oxlint": "^1.72.0",
|
||||
"oxlint-tailwindcss": "^1.3.4",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4233
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
core-js: set this to true or false
|
||||
es5-ext: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+24
-99
@@ -1,4 +1,4 @@
|
||||
import { Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||
import { Outlet, Route, Routes as ReactRoutes } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import DocumentsPage from './pages/DocumentsPage';
|
||||
@@ -16,108 +16,33 @@ import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import AdminLogsPage from './pages/AdminLogsPage';
|
||||
import ReaderPage from './pages/ReaderPage';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { AdminRoute } from './auth/AdminRoute';
|
||||
|
||||
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
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="documents" element={<DocumentsPage />} />
|
||||
<Route path="documents/:id" element={<DocumentPage />} />
|
||||
<Route path="progress" element={<ProgressPage />} />
|
||||
<Route path="activity" element={<ActivityPage />} />
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin" element={<AdminRoute><Outlet /></AdminRoute>}>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route path="import" element={<AdminImportPage />} />
|
||||
<Route path="import-results" element={<AdminImportResultsPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route
|
||||
path="/reader/:id"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { AdminRoute } from './AdminRoute';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
|
||||
describe('AdminRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children for admin users', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminRoute>
|
||||
<div>Admin Panel</div>
|
||||
</AdminRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects non-admin users to the home page', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<div>Admin Panel</div>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<div>Home Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Role Guard - Runs behind ProtectedRoute, so auth is already resolved and `user` is populated by the time this renders.
|
||||
export function AdminRoute({ children }: AdminRouteProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { createContext, useContext, ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import {
|
||||
type AuthState,
|
||||
getAuthenticatedAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
} from './authHelpers';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
@@ -24,15 +22,9 @@ interface AuthContextType extends AuthState {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const initialAuthState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
const unauthenticatedState = getUnauthenticatedAuthState();
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
const logoutMutation = useLogout();
|
||||
@@ -42,40 +34,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setAuthState(prev =>
|
||||
const authState = useMemo(
|
||||
() =>
|
||||
resolveAuthStateFromMe({
|
||||
meData,
|
||||
meError,
|
||||
meLoading,
|
||||
previousState: prev,
|
||||
})
|
||||
);
|
||||
}, [meData, meError, meLoading]);
|
||||
previousState: unauthenticatedState,
|
||||
}),
|
||||
[meData, meError, meLoading]
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await loginMutation.mutateAsync({
|
||||
const user = await loginMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = validateAuthMutationResponse(response, 200);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
} catch (error) {
|
||||
queryClient.setQueryData(getGetMeQueryKey(), undefined);
|
||||
throw error instanceof Error ? error : new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate, queryClient]
|
||||
@@ -84,26 +69,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await registerMutation.mutateAsync({
|
||||
const user = await registerMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = validateAuthMutationResponse(response, 201);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
} catch (error) {
|
||||
queryClient.setQueryData(getGetMeQueryKey(), undefined);
|
||||
throw error instanceof Error ? error : new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
[navigate, queryClient, registerMutation]
|
||||
@@ -111,9 +89,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
onSettled: () => {
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,9 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCheckingAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
normalizeAuthenticatedUser,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
type AuthState,
|
||||
} from './authHelpers';
|
||||
|
||||
@@ -15,20 +13,6 @@ const previousState: AuthState = {
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -46,10 +30,7 @@ describe('authHelpers', () => {
|
||||
it('resolves auth state from a successful /auth/me response', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
meData: { username: 'evan', is_admin: false },
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
@@ -61,18 +42,7 @@ describe('authHelpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 401,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
|
||||
it('resolves auth state to unauthenticated when the me query errors (e.g. 401)', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
@@ -105,9 +75,7 @@ describe('authHelpers', () => {
|
||||
it('returns the previous state with checking disabled when there is no decisive me result', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 204,
|
||||
},
|
||||
meData: undefined,
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState: {
|
||||
@@ -122,36 +90,4 @@ describe('authHelpers', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
import type { LoginResponse } from '../generated/model';
|
||||
|
||||
export type AuthUser = LoginResponse;
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
@@ -9,11 +8,6 @@ export interface AuthState {
|
||||
isCheckingAuth: boolean;
|
||||
}
|
||||
|
||||
interface ResponseLike {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export function getUnauthenticatedAuthState(): AuthState {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
@@ -38,27 +32,8 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
meData?: LoginResponse;
|
||||
meError?: unknown;
|
||||
meLoading: boolean;
|
||||
previousState: AuthState;
|
||||
@@ -69,14 +44,11 @@ export function resolveAuthStateFromMe(params: {
|
||||
return getCheckingAuthState(previousState);
|
||||
}
|
||||
|
||||
if (meData?.status === 200) {
|
||||
const user = normalizeAuthenticatedUser(meData.data);
|
||||
if (user) {
|
||||
return getAuthenticatedAuthState(user);
|
||||
}
|
||||
if (meData) {
|
||||
return getAuthenticatedAuthState(meData);
|
||||
}
|
||||
|
||||
if (meError || meData?.status === 401) {
|
||||
if (meError) {
|
||||
return getUnauthenticatedAuthState();
|
||||
}
|
||||
|
||||
@@ -85,14 +57,3 @@ export function resolveAuthStateFromMe(params: {
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAuthMutationResponse(
|
||||
response: ResponseLike,
|
||||
expectedStatus: number
|
||||
): AuthUser | null {
|
||||
if (response.status !== expectedStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAuthenticatedUser(response.data);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function setupAuthInterceptors() {
|
||||
return () => {};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ButtonHTMLAttributes, AnchorHTMLAttributes, forwardRef } from 'react';
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface BaseButtonProps {
|
||||
variant?: 'default' | 'secondary';
|
||||
@@ -7,11 +8,10 @@ interface BaseButtonProps {
|
||||
}
|
||||
|
||||
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';
|
||||
'inline-flex items-center justify-center px-4 py-2 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`;
|
||||
@@ -21,9 +21,9 @@ const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
||||
({ variant = 'default', children, className, ...props }, ref) => {
|
||||
return (
|
||||
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
||||
<button ref={ref} className={cn(getVariantClasses(variant), className)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -31,15 +31,3 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
@@ -3,10 +3,9 @@ import { ReactNode } from 'react';
|
||||
interface FieldProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
||||
export function Field({ label, children }: FieldProps) {
|
||||
return (
|
||||
<div className="relative rounded">
|
||||
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon, GitIcon } from '../icons';
|
||||
import { 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' },
|
||||
];
|
||||
import { navItems, adminNavItems } from './navigation';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
function hasPrefix(path: string, prefix: string): boolean {
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
function NavToggleIcon({ isOpen }: { isOpen: boolean }) {
|
||||
return (
|
||||
<span className="relative block size-7" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-1 h-0.5 w-7 bg-content transition-transform duration-200',
|
||||
isOpen && 'translate-y-2 rotate-45'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-3 h-0.5 w-7 bg-content transition-opacity duration-200',
|
||||
isOpen && 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-5 h-0.5 w-7 bg-content transition-transform duration-200',
|
||||
isOpen && '-translate-y-2 -rotate-45'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HamburgerMenu() {
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
@@ -41,82 +46,47 @@ export default function HamburgerMenu() {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
const version =
|
||||
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
||||
? infoData.data.version
|
||||
: 'v1.0.0';
|
||||
const version = infoData?.version ?? 'v1.0.0';
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-50 flex size-8 items-center justify-center lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-navigation"
|
||||
onClick={() => setIsOpen(open => !open)}
|
||||
>
|
||||
<NavToggleIcon isOpen={isOpen} />
|
||||
</button>
|
||||
|
||||
<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)',
|
||||
}}
|
||||
id="mobile-navigation"
|
||||
className={cn(
|
||||
'fixed -ml-6 h-full w-56 bg-surface shadow-lg transition-transform duration-200 lg:w-48 lg:translate-x-0',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
style={{ top: 0, paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<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>
|
||||
<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 ${
|
||||
onClick={closeMenu}
|
||||
className={cn(
|
||||
'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>
|
||||
@@ -125,20 +95,22 @@ export default function HamburgerMenu() {
|
||||
|
||||
{isAdmin && (
|
||||
<div
|
||||
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
className={cn(
|
||||
'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 ${
|
||||
onClick={closeMenu}
|
||||
className={cn(
|
||||
'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>
|
||||
@@ -146,17 +118,17 @@ export default function HamburgerMenu() {
|
||||
|
||||
{hasPrefix(location.pathname, '/admin') && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{adminSubItems.map(item => (
|
||||
{adminNavItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
onClick={closeMenu}
|
||||
className={cn(
|
||||
'flex w-full justify-start pl-7',
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface IconInputProps {
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconInput({ icon, children, className }: IconInputProps) {
|
||||
return (
|
||||
<div className={cn('relative flex', className)}>
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
{icon}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import { THEME_MODES } from '../utils/localSettings';
|
||||
import { SegmentedControl } from './SegmentedControl';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
|
||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
import { getPageTitle } from './navigation';
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||
const { user, logout } = 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);
|
||||
|
||||
@@ -38,36 +33,12 @@ export default function Layout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
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';
|
||||
const currentPageTitle = getPageTitle(location.pathname);
|
||||
|
||||
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">
|
||||
@@ -91,30 +62,17 @@ export default function Layout() {
|
||||
{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"
|
||||
>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<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>
|
||||
<SegmentedControl
|
||||
className="w-full"
|
||||
ariaLabel="Theme mode"
|
||||
value={themeMode}
|
||||
onChange={setThemeMode}
|
||||
options={THEME_MODES.map(mode => ({ value: mode, label: mode }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="py-1"
|
||||
@@ -150,7 +108,7 @@ export default function Layout() {
|
||||
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>{user?.username ?? 'User'}</span>
|
||||
<span
|
||||
className="text-content transition-transform duration-200"
|
||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Pagination({
|
||||
<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"
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-hidden"
|
||||
>
|
||||
◄
|
||||
</button>
|
||||
@@ -41,7 +41,7 @@ export function Pagination({
|
||||
<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"
|
||||
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-hidden"
|
||||
>
|
||||
►
|
||||
</button>
|
||||
|
||||
@@ -47,9 +47,6 @@ function MyComponent() {
|
||||
- `removeToast(id: string): void`
|
||||
- Manually remove a toast by ID
|
||||
|
||||
- `clearToasts(): void`
|
||||
- Clear all active toasts
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
@@ -76,61 +73,13 @@ Skeleton components provide placeholder content while data is loading. They auto
|
||||
|
||||
#### `Skeleton`
|
||||
|
||||
Basic skeleton element with various variants:
|
||||
A pulsing placeholder block. Size and shape are controlled with Tailwind classes via `className`:
|
||||
|
||||
```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"
|
||||
/>
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="w-3/4" />
|
||||
```
|
||||
|
||||
#### `SkeletonTable`
|
||||
@@ -142,33 +91,6 @@ Table placeholder:
|
||||
<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:
|
||||
@@ -200,4 +122,4 @@ The theme is controlled via Tailwind's `dark:` classes, which respond to the sys
|
||||
|
||||
- `clsx` - Utility for constructing className strings
|
||||
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
|
||||
- `lucide-react` - Icon library used by Toast component
|
||||
- Local icon components in `src/icons/`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GraphDataPoint } from '../generated/model';
|
||||
import { formatUtcDate } from '../utils/formatters';
|
||||
|
||||
interface ReadingHistoryGraphProps {
|
||||
data: GraphDataPoint[];
|
||||
@@ -147,14 +148,6 @@ export function getSVGGraphData(
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -199,7 +192,7 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
<span>{formatDate(point.date)}</span>
|
||||
<span>{formatUtcDate(point.date)}</span>
|
||||
<span>{point.minutes_read} minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: SegmentedOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
variant?: 'pill' | 'unstyled';
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
activeClassName?: string;
|
||||
inactiveClassName?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// Pill Variant Defaults - The bordered "segmented" look most call sites want, so they pass only
|
||||
// options/value/onChange. `unstyled` opts out entirely for callers with a bespoke shape (grid, inline text).
|
||||
const PILL = {
|
||||
container: 'inline-flex rounded border border-border bg-surface-muted p-1',
|
||||
button: 'flex-1 rounded px-3 py-1 text-sm font-medium capitalize transition-colors',
|
||||
active: 'bg-content text-content-inverse',
|
||||
inactive: 'text-content-muted hover:bg-surface hover:text-content',
|
||||
};
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
variant = 'pill',
|
||||
className,
|
||||
buttonClassName,
|
||||
activeClassName,
|
||||
inactiveClassName,
|
||||
ariaLabel,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const styles =
|
||||
variant === 'pill'
|
||||
? {
|
||||
container: cn(PILL.container, className),
|
||||
button: cn(PILL.button, buttonClassName),
|
||||
active: activeClassName ?? PILL.active,
|
||||
inactive: inactiveClassName ?? PILL.inactive,
|
||||
}
|
||||
: {
|
||||
container: className,
|
||||
button: buttonClassName,
|
||||
active: activeClassName,
|
||||
inactive: inactiveClassName,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container} role="group" aria-label={ariaLabel}>
|
||||
{options.map(option => {
|
||||
const isActive = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isActive) onChange(option.value);
|
||||
}}
|
||||
aria-pressed={isActive}
|
||||
className={cn(styles.button, isActive ? styles.active : styles.inactive)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,115 +2,10 @@ 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>
|
||||
);
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return <div className={cn('h-4 animate-pulse rounded-md bg-surface-strong', className)} />;
|
||||
}
|
||||
|
||||
interface SkeletonTableProps {
|
||||
@@ -134,7 +29,7 @@ export function SkeletonTable({
|
||||
<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" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -145,10 +40,7 @@ export function SkeletonTable({
|
||||
<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'}
|
||||
/>
|
||||
<Skeleton className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
@@ -158,58 +50,3 @@ export function SkeletonTable({
|
||||
</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 };
|
||||
|
||||
@@ -10,12 +10,14 @@ interface TestRow {
|
||||
|
||||
const columns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
render: row => row.name,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
id: 'role',
|
||||
header: 'Role',
|
||||
render: row => row.role,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,9 +43,9 @@ describe('Table', () => {
|
||||
it('uses a custom render function for column output', () => {
|
||||
const customColumns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
|
||||
render: (row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,63 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { ReactNode } from 'react';
|
||||
import { SkeletonTable } 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;
|
||||
export interface Column<T> {
|
||||
id: string;
|
||||
header: ReactNode;
|
||||
className?: string;
|
||||
render: (row: T, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
export interface TableProps<T extends object> {
|
||||
export interface TableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyMessage?: ReactNode;
|
||||
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>({
|
||||
export function Table<T>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
@@ -80,14 +40,14 @@ export function Table<T extends object>({
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
||||
<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 || ''}`}
|
||||
key={column.id}
|
||||
className={cn('p-3 text-left text-content-muted', column.className)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
@@ -105,13 +65,8 @@ export function Table<T extends object>({
|
||||
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 key={column.id} className={cn('p-3 text-content', column.className)}>
|
||||
{column.render(row, index)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export const inputClassName =
|
||||
'w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-xs placeholder:text-content-subtle focus:border-transparent focus:outline-hidden focus:ring-2 focus:ring-primary-600';
|
||||
|
||||
type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input ref={ref} className={cn(inputClassName, className)} {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { InfoIcon, WarningIcon, ErrorIcon, CloseIcon } from '../icons';
|
||||
|
||||
export type ToastType = 'info' | 'warning' | 'error';
|
||||
@@ -11,51 +11,45 @@ export interface ToastProps {
|
||||
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 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 typeStyles: Record<ToastType, string> = {
|
||||
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 iconStyles: Record<ToastType, string> = {
|
||||
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 };
|
||||
const textStyles: Record<ToastType, string> = {
|
||||
info: 'text-secondary-900',
|
||||
warning: 'text-yellow-900',
|
||||
error: 'text-red-900',
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
const handleClose = useCallback(() => {
|
||||
setIsAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.(id);
|
||||
}, 300);
|
||||
};
|
||||
}, [id, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(handleClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration]);
|
||||
}, [duration, handleClose]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { Toast, ToastType, ToastProps } from './Toast';
|
||||
|
||||
interface ToastUpdate {
|
||||
message?: string;
|
||||
type?: ToastType;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
updateToast: (id: string, update: ToastUpdate) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
@@ -20,45 +26,43 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
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 },
|
||||
]);
|
||||
(message: string, type: ToastType = 'info', duration?: number): string => {
|
||||
const id = crypto.randomUUID();
|
||||
setToasts(prev => [...prev, { id, type, message, duration, onClose: removeToast }]);
|
||||
return id;
|
||||
},
|
||||
[removeToast]
|
||||
);
|
||||
|
||||
const showInfo = useCallback(
|
||||
(message: string, _duration?: number) => {
|
||||
return showToast(message, 'info', _duration);
|
||||
(message: string, duration?: number) => {
|
||||
return showToast(message, 'info', duration);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showWarning = useCallback(
|
||||
(message: string, _duration?: number) => {
|
||||
return showToast(message, 'warning', _duration);
|
||||
(message: string, duration?: number) => {
|
||||
return showToast(message, 'warning', duration);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const showError = useCallback(
|
||||
(message: string, _duration?: number) => {
|
||||
return showToast(message, 'error', _duration);
|
||||
(message: string, duration?: number) => {
|
||||
return showToast(message, 'error', duration);
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
// In-Place Update - Long-running flows show a persistent toast (duration 0) that resolves into its result toast; changing duration restarts the Toast's auto-dismiss timer.
|
||||
const updateToast = useCallback((id: string, update: ToastUpdate) => {
|
||||
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
||||
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast }}
|
||||
>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Column } from './Table';
|
||||
|
||||
interface DocumentRow {
|
||||
document_id?: string;
|
||||
author?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Shared "Document" Column - Progress and Activity render the same author/title link to the doc.
|
||||
export const documentColumn: Column<DocumentRow> = {
|
||||
id: 'document',
|
||||
header: 'Document',
|
||||
render: row => (
|
||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
),
|
||||
};
|
||||
@@ -2,23 +2,19 @@
|
||||
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 { Skeleton, SkeletonTable } from './Skeleton';
|
||||
export { LoadingState } from './LoadingState';
|
||||
export { Pagination } from './Pagination';
|
||||
export { TextInput } from './TextInput';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
|
||||
// Button / Table
|
||||
export { Button } from './Button';
|
||||
export { Table, type Column, type TableProps } from './Table';
|
||||
export { IconInput } from './IconInput';
|
||||
export { SegmentedControl } from './SegmentedControl';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ElementType } from 'react';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
|
||||
|
||||
export interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
}
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
|
||||
{ path: '/progress', label: 'Progress', icon: ClockIcon },
|
||||
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
|
||||
{ path: '/search', label: 'Search', icon: SearchIcon },
|
||||
];
|
||||
|
||||
export const adminNavItems: { path: string; label: string }[] = [
|
||||
{ path: '/admin', label: 'General' },
|
||||
{ path: '/admin/import', label: 'Import' },
|
||||
{ path: '/admin/users', label: 'Users' },
|
||||
{ path: '/admin/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
|
||||
const pageTitles: { path: string; title: string }[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
export function getPageTitle(pathname: string): string {
|
||||
return (
|
||||
pageTitles.find(item =>
|
||||
item.path === '/' ? pathname === item.path : pathname.startsWith(item.path)
|
||||
)?.title ?? 'Home'
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
export interface Device {
|
||||
id?: string;
|
||||
device_name?: string;
|
||||
created_at?: string;
|
||||
last_synced?: string;
|
||||
id: string;
|
||||
device_name: string;
|
||||
created_at: string;
|
||||
last_synced: string;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
export interface Progress {
|
||||
title?: string;
|
||||
author?: string;
|
||||
device_name?: string;
|
||||
device_name: string;
|
||||
device_id?: string;
|
||||
percentage?: number;
|
||||
percentage: number;
|
||||
progress?: string;
|
||||
document_id?: string;
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
document_id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -14,5 +14,4 @@ export interface SearchItem {
|
||||
series?: string;
|
||||
file_type?: string;
|
||||
file_size?: string;
|
||||
upload_date?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useState, type SyntheticEvent } from 'react';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export interface UseAuthFormResult {
|
||||
username: string;
|
||||
password: string;
|
||||
isLoading: boolean;
|
||||
isLoadingInfo: boolean;
|
||||
registrationEnabled: boolean;
|
||||
setUsername: (value: string) => void;
|
||||
setPassword: (value: string) => void;
|
||||
submit: (e: SyntheticEvent<HTMLFormElement>) => Promise<void>;
|
||||
}
|
||||
|
||||
// Shared auth form state + submit for login/register. Server error messages are surfaced via the
|
||||
// generated ErrorResponse contract rather than hardcoded fallbacks.
|
||||
export function useAuthForm(mode: 'login' | 'register'): UseAuthFormResult {
|
||||
const { login, register } = useAuth();
|
||||
const { showError } = useToasts();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
||||
query: { staleTime: Infinity },
|
||||
});
|
||||
const registrationEnabled = infoData?.registration_enabled ?? false;
|
||||
|
||||
const submit = useCallback(
|
||||
async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, password);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
getErrorMessage(error, mode === 'login' ? 'Login failed' : 'Registration failed')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[mode, login, register, username, password, showError]
|
||||
);
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
isLoadingInfo,
|
||||
registrationEnabled,
|
||||
setUsername,
|
||||
setPassword,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
describe('useDebounce', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns the initial value immediately', () => {
|
||||
const { result } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 300 },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
});
|
||||
|
||||
it('delays updates until the debounce interval has passed', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 300 },
|
||||
});
|
||||
|
||||
rerender({ value: 'updated', delay: 300 });
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('updated');
|
||||
});
|
||||
|
||||
it('cancels the previous timer when the value changes again', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'first', delay: 300 },
|
||||
});
|
||||
|
||||
rerender({ value: 'second', delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
rerender({ value: 'third', delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('first');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('third');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Debounces a value by delaying updates until after a specified delay
|
||||
* @param value The value to debounce
|
||||
* @param delay The delay in milliseconds
|
||||
* @returns The debounced value
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDebouncedState } from './useDebouncedState';
|
||||
|
||||
describe('useDebouncedState', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns the initial value for both the input and the debounced value', () => {
|
||||
const { result } = renderHook(() => useDebouncedState('initial', 300));
|
||||
|
||||
const [value, , debounced] = result.current;
|
||||
expect(value).toBe('initial');
|
||||
expect(debounced).toBe('initial');
|
||||
});
|
||||
|
||||
it('updates the input immediately but delays the debounced value', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result } = renderHook(() => useDebouncedState('initial', 300));
|
||||
|
||||
act(() => {
|
||||
const setValue = result.current[1];
|
||||
setValue('updated');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('updated');
|
||||
expect(result.current[2]).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(result.current[2]).toBe('updated');
|
||||
});
|
||||
|
||||
it('cancels the previous timer when the value changes again', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result } = renderHook(() => useDebouncedState('first', 300));
|
||||
|
||||
act(() => result.current[1]('second'));
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
act(() => result.current[1]('third'));
|
||||
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
expect(result.current[2]).toBe('first');
|
||||
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
expect(result.current[2]).toBe('third');
|
||||
});
|
||||
|
||||
it('flush resolves the debounced value immediately, skipping the debounce window', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result } = renderHook(() => useDebouncedState('initial', 300));
|
||||
|
||||
act(() => result.current[1]('updated'));
|
||||
expect(result.current[2]).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
const flush = result.current[3];
|
||||
flush();
|
||||
});
|
||||
|
||||
expect(result.current[2]).toBe('updated');
|
||||
});
|
||||
|
||||
it('flush with an argument sets both the input and the resolved value', () => {
|
||||
const { result } = renderHook(() => useDebouncedState('initial', 300));
|
||||
|
||||
act(() => {
|
||||
const flush = result.current[3];
|
||||
flush('forced');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('forced');
|
||||
expect(result.current[2]).toBe('forced');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const DEFAULT_DELAY = 300;
|
||||
|
||||
/**
|
||||
* Owns a search/filter input and a debounced view of it. The fourth value, `flush`,
|
||||
* resolves the debounced value immediately (e.g. on an explicit submit button)
|
||||
* without waiting for the debounce window to elapse.
|
||||
*/
|
||||
export function useDebouncedState<T>(initialValue: T, delay: number = DEFAULT_DELAY) {
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
const [debounced, setDebounced] = useState<T>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
// Flush - skip the debounce window so an explicit submit resolves the active value right away.
|
||||
const flush = useCallback((next?: T) => {
|
||||
if (next !== undefined) setValue(next);
|
||||
setDebounced(next !== undefined ? next : value);
|
||||
}, [value]);
|
||||
|
||||
return [value, setValue, debounced, flush] as const;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { CreateActivityRequest } from '../generated/model/createActivityReq
|
||||
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
|
||||
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface UseEpubReaderOptions {
|
||||
documentId: string;
|
||||
@@ -20,7 +22,7 @@ interface UseEpubReaderOptions {
|
||||
}
|
||||
|
||||
interface UseEpubReaderResult {
|
||||
viewerRef: (_node: HTMLDivElement | null) => void;
|
||||
viewerRef: (node: HTMLDivElement | null) => void;
|
||||
isReady: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -29,11 +31,6 @@ interface UseEpubReaderResult {
|
||||
nextPage: () => Promise<void>;
|
||||
prevPage: () => Promise<void>;
|
||||
goToHref: (href: string) => Promise<void>;
|
||||
setTheme: (theme: {
|
||||
colorScheme?: ReaderColorScheme;
|
||||
fontFamily?: ReaderFontFamily;
|
||||
fontSize?: number;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useEpubReader({
|
||||
@@ -65,6 +62,7 @@ export function useEpubReader({
|
||||
sectionTotalPages: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
const { showError } = useToasts();
|
||||
|
||||
useEffect(() => {
|
||||
isPaginationDisabledRef.current = isPaginationDisabled;
|
||||
@@ -95,20 +93,20 @@ export function useEpubReader({
|
||||
});
|
||||
|
||||
const saveProgress = async (payload: UpdateProgressRequest) => {
|
||||
const response = await updateProgress(payload);
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
'message' in response.data ? response.data.message : 'Unable to save reader progress'
|
||||
);
|
||||
// Swallow Save Failures - Transient progress-save errors must not take down the reader
|
||||
// (they previously routed to onError, which hides the whole book behind an error overlay).
|
||||
try {
|
||||
await updateProgress(payload);
|
||||
} catch (err) {
|
||||
showError(`Failed to save progress: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const saveActivity = async (payload: CreateActivityRequest) => {
|
||||
const response = await createActivity(payload);
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
'message' in response.data ? response.data.message : 'Unable to save reader activity'
|
||||
);
|
||||
try {
|
||||
await createActivity(payload);
|
||||
} catch (err) {
|
||||
showError(`Failed to save activity: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,6 +179,8 @@ export function useEpubReader({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
// Init Reader Once - Theme values seed the constructor; the effect below (applyThemeChange) handles later changes, so re-running here would needlessly destroy and recreate the reader.
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deviceId, deviceName, documentId, initialProgress, viewerNode]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,17 +208,6 @@ export function useEpubReader({
|
||||
await readerRef.current?.displayHref(href);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
async (theme: {
|
||||
colorScheme?: ReaderColorScheme;
|
||||
fontFamily?: ReaderFontFamily;
|
||||
fontSize?: number;
|
||||
}) => {
|
||||
await readerRef.current?.applyThemeChange(theme);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
viewerRef: setViewerNode,
|
||||
@@ -230,8 +219,7 @@ export function useEpubReader({
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToHref,
|
||||
setTheme,
|
||||
}),
|
||||
[error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc]
|
||||
[error, goToHref, isLoading, isReady, nextPage, prevPage, stats, toc]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface ToastMutationOptions {
|
||||
success: string;
|
||||
error: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call,
|
||||
* centralizing the shared "toast success / toast error" pattern. The generated client throws on
|
||||
* non-2xx, so success and failure map cleanly onto React Query's onSuccess/onError.
|
||||
*/
|
||||
export function useMutationWithToast() {
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
||||
return {
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
showInfo(success);
|
||||
},
|
||||
onError: (err: unknown) => showError(`${error}: ${getErrorMessage(err)}`),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface RunToastMutationOptions<T> {
|
||||
error: string;
|
||||
success?: string;
|
||||
onSuccess?: (result: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative sibling of `useMutationWithToast` for flows that must `await` a result (e.g. keep an
|
||||
* editor open on failure). Runs the action, toasts on success/failure, and resolves to `true` only
|
||||
* when the action succeeds.
|
||||
*/
|
||||
export function useToastMutation() {
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
return async function runWithToast<T>(
|
||||
action: () => Promise<T>,
|
||||
{ error, success, onSuccess }: RunToastMutationOptions<T>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await action();
|
||||
onSuccess?.(result);
|
||||
if (success) {
|
||||
showInfo(success);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
showError(`${error}: ${getErrorMessage(err)}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Page state for a paginated list. Passing `resetKey` (e.g. a search term or filter id) resets
|
||||
* back to page 1 whenever it changes, so a filter change never strands the user on a now-empty page.
|
||||
*/
|
||||
export function usePaginatedList(resetKey?: unknown) {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [resetKey]);
|
||||
|
||||
return { page, setPage };
|
||||
}
|
||||
+134
-69
@@ -1,6 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Class-Based Dark Mode - v4 defaults to prefers-color-scheme; ThemeProvider toggles `.dark`. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--white: 255 255 255;
|
||||
@@ -184,7 +185,137 @@
|
||||
--error-foreground: 255 255 255;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-canvas: rgb(var(--canvas));
|
||||
--color-surface: rgb(var(--surface));
|
||||
--color-surface-muted: rgb(var(--surface-muted));
|
||||
--color-surface-strong: rgb(var(--surface-strong));
|
||||
--color-overlay: rgb(var(--overlay));
|
||||
--color-content: rgb(var(--content));
|
||||
--color-content-muted: rgb(var(--content-muted));
|
||||
--color-content-subtle: rgb(var(--content-subtle));
|
||||
--color-content-inverse: rgb(var(--content-inverse));
|
||||
--color-border: rgb(var(--border));
|
||||
--color-border-muted: rgb(var(--border-muted));
|
||||
--color-border-strong: rgb(var(--border-strong));
|
||||
--color-white: rgb(var(--white));
|
||||
--color-black: rgb(var(--black));
|
||||
|
||||
--color-gray-50: rgb(var(--neutral-50));
|
||||
--color-gray-100: rgb(var(--neutral-100));
|
||||
--color-gray-200: rgb(var(--neutral-200));
|
||||
--color-gray-300: rgb(var(--neutral-300));
|
||||
--color-gray-400: rgb(var(--neutral-400));
|
||||
--color-gray-500: rgb(var(--neutral-500));
|
||||
--color-gray-600: rgb(var(--neutral-600));
|
||||
--color-gray-700: rgb(var(--neutral-700));
|
||||
--color-gray-800: rgb(var(--neutral-800));
|
||||
--color-gray-900: rgb(var(--neutral-900));
|
||||
--color-gray: rgb(var(--neutral-500));
|
||||
--color-gray-foreground: rgb(var(--neutral-foreground));
|
||||
|
||||
--color-purple-50: rgb(var(--primary-50));
|
||||
--color-purple-100: rgb(var(--primary-100));
|
||||
--color-purple-200: rgb(var(--primary-200));
|
||||
--color-purple-300: rgb(var(--primary-300));
|
||||
--color-purple-400: rgb(var(--primary-400));
|
||||
--color-purple-500: rgb(var(--primary-500));
|
||||
--color-purple-600: rgb(var(--primary-600));
|
||||
--color-purple-700: rgb(var(--primary-700));
|
||||
--color-purple-800: rgb(var(--primary-800));
|
||||
--color-purple-900: rgb(var(--primary-900));
|
||||
--color-purple: rgb(var(--primary-500));
|
||||
--color-purple-foreground: rgb(var(--primary-foreground));
|
||||
|
||||
--color-blue-50: rgb(var(--secondary-50));
|
||||
--color-blue-100: rgb(var(--secondary-100));
|
||||
--color-blue-200: rgb(var(--secondary-200));
|
||||
--color-blue-300: rgb(var(--secondary-300));
|
||||
--color-blue-400: rgb(var(--secondary-400));
|
||||
--color-blue-500: rgb(var(--secondary-500));
|
||||
--color-blue-600: rgb(var(--secondary-600));
|
||||
--color-blue-700: rgb(var(--secondary-700));
|
||||
--color-blue-800: rgb(var(--secondary-800));
|
||||
--color-blue-900: rgb(var(--secondary-900));
|
||||
--color-blue: rgb(var(--secondary-500));
|
||||
--color-blue-foreground: rgb(var(--secondary-foreground));
|
||||
|
||||
--color-yellow-50: rgb(var(--warning-50));
|
||||
--color-yellow-100: rgb(var(--warning-100));
|
||||
--color-yellow-200: rgb(var(--warning-200));
|
||||
--color-yellow-300: rgb(var(--warning-300));
|
||||
--color-yellow-400: rgb(var(--warning-400));
|
||||
--color-yellow-500: rgb(var(--warning-500));
|
||||
--color-yellow-600: rgb(var(--warning-600));
|
||||
--color-yellow-700: rgb(var(--warning-700));
|
||||
--color-yellow-800: rgb(var(--warning-800));
|
||||
--color-yellow-900: rgb(var(--warning-900));
|
||||
--color-yellow: rgb(var(--warning-500));
|
||||
--color-yellow-foreground: rgb(var(--warning-foreground));
|
||||
|
||||
--color-red-50: rgb(var(--error-50));
|
||||
--color-red-100: rgb(var(--error-100));
|
||||
--color-red-200: rgb(var(--error-200));
|
||||
--color-red-300: rgb(var(--error-300));
|
||||
--color-red-400: rgb(var(--error-400));
|
||||
--color-red-500: rgb(var(--error-500));
|
||||
--color-red-600: rgb(var(--error-600));
|
||||
--color-red-700: rgb(var(--error-700));
|
||||
--color-red-800: rgb(var(--error-800));
|
||||
--color-red-900: rgb(var(--error-900));
|
||||
--color-red: rgb(var(--error-500));
|
||||
--color-red-foreground: rgb(var(--error-foreground));
|
||||
|
||||
--color-primary-50: rgb(var(--primary-50));
|
||||
--color-primary-100: rgb(var(--primary-100));
|
||||
--color-primary-200: rgb(var(--primary-200));
|
||||
--color-primary-300: rgb(var(--primary-300));
|
||||
--color-primary-400: rgb(var(--primary-400));
|
||||
--color-primary-500: rgb(var(--primary-500));
|
||||
--color-primary-600: rgb(var(--primary-600));
|
||||
--color-primary-700: rgb(var(--primary-700));
|
||||
--color-primary-800: rgb(var(--primary-800));
|
||||
--color-primary-900: rgb(var(--primary-900));
|
||||
--color-primary: rgb(var(--primary-500));
|
||||
--color-primary-foreground: rgb(var(--primary-foreground));
|
||||
|
||||
--color-secondary-50: rgb(var(--secondary-50));
|
||||
--color-secondary-100: rgb(var(--secondary-100));
|
||||
--color-secondary-200: rgb(var(--secondary-200));
|
||||
--color-secondary-300: rgb(var(--secondary-300));
|
||||
--color-secondary-400: rgb(var(--secondary-400));
|
||||
--color-secondary-500: rgb(var(--secondary-500));
|
||||
--color-secondary-600: rgb(var(--secondary-600));
|
||||
--color-secondary-700: rgb(var(--secondary-700));
|
||||
--color-secondary-800: rgb(var(--secondary-800));
|
||||
--color-secondary-900: rgb(var(--secondary-900));
|
||||
--color-secondary: rgb(var(--secondary-500));
|
||||
--color-secondary-foreground: rgb(var(--secondary-foreground));
|
||||
|
||||
--color-tertiary-50: rgb(var(--tertiary-50));
|
||||
--color-tertiary-100: rgb(var(--tertiary-100));
|
||||
--color-tertiary-200: rgb(var(--tertiary-200));
|
||||
--color-tertiary-300: rgb(var(--tertiary-300));
|
||||
--color-tertiary-400: rgb(var(--tertiary-400));
|
||||
--color-tertiary-500: rgb(var(--tertiary-500));
|
||||
--color-tertiary-600: rgb(var(--tertiary-600));
|
||||
--color-tertiary-700: rgb(var(--tertiary-700));
|
||||
--color-tertiary-800: rgb(var(--tertiary-800));
|
||||
--color-tertiary-900: rgb(var(--tertiary-900));
|
||||
--color-tertiary: rgb(var(--tertiary-500));
|
||||
--color-tertiary-foreground: rgb(var(--tertiary-foreground));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* Preserve v3 Default Border Color - v4 changed the unqualified `border` utility to currentColor. */
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: rgb(var(--border));
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
@@ -225,72 +356,6 @@ main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Button visibility toggle */
|
||||
.css-button:checked + div {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.css-button + div {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
#mobile-nav-button span {
|
||||
transform-origin: 5px 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;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:first-child {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:nth-last-child(2) {
|
||||
transform-origin: 0 100%;
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) translate(2px, -2px);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span:nth-last-child(3) {
|
||||
opacity: 0;
|
||||
transform: rotate(0deg) scale(0.2, 0.2);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span:nth-last-child(2) {
|
||||
transform: rotate(-45deg) translate(0, 6px);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ #menu {
|
||||
transform: translate(0, 0) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#mobile-nav-button ~ #menu {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#menu {
|
||||
top: 0;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transform-origin: 0 0;
|
||||
transform: translate(-100%, 0);
|
||||
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#menu {
|
||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton Wave Animation */
|
||||
@keyframes wave {
|
||||
0% {
|
||||
|
||||
@@ -2,125 +2,27 @@ import ePub from 'epubjs';
|
||||
import NoSleep from 'nosleep.js';
|
||||
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
|
||||
import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../../utils/localSettings';
|
||||
import {
|
||||
READER_COLOR_SCHEMES,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
} from '../../utils/localSettings';
|
||||
import type { EpubBook, EpubRendition, ReaderStats, ReaderTocItem } from './types';
|
||||
import {
|
||||
countWords,
|
||||
getBookWordPosition,
|
||||
getCFIFromXPath,
|
||||
getParsedTOC,
|
||||
getVisibleWordCount,
|
||||
getXPathFromCFI,
|
||||
} from './epubUtils';
|
||||
import { registerRenditionGestures } from './gestures';
|
||||
|
||||
export type { ReaderStats, ReaderTocItem } from './types';
|
||||
|
||||
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const THEME_FILE = '/assets/reader/themes.css';
|
||||
const FONT_FILE = '/assets/reader/fonts.css';
|
||||
|
||||
interface TocNode {
|
||||
href: string;
|
||||
label?: string;
|
||||
subitems?: TocNode[];
|
||||
}
|
||||
|
||||
interface EpubContents {
|
||||
document: Document;
|
||||
sectionIndex?: number;
|
||||
range: (cfi: string) => Range;
|
||||
}
|
||||
|
||||
interface EpubVisibleSection {
|
||||
index: number;
|
||||
layout: { width: number; divisor: number };
|
||||
width: () => number;
|
||||
expand: () => void;
|
||||
}
|
||||
|
||||
interface EpubLocation {
|
||||
start: {
|
||||
cfi: string;
|
||||
href?: string;
|
||||
};
|
||||
end: {
|
||||
cfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EpubNavigation {
|
||||
toc?: TocNode[];
|
||||
}
|
||||
|
||||
interface EpubSpineItem {
|
||||
cfiBase: string;
|
||||
index: number;
|
||||
document: Document;
|
||||
load: (_loader: unknown) => Promise<Document>;
|
||||
cfiFromElement: (element: Element) => string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
interface EpubBook {
|
||||
ready: Promise<void>;
|
||||
navigation?: EpubNavigation;
|
||||
loaded: { navigation: Promise<EpubNavigation> };
|
||||
spine: {
|
||||
spineItems: EpubSpineItem[];
|
||||
get: (index: number) => EpubSpineItem;
|
||||
hooks: {
|
||||
content: { register: (_callback: (output: Document) => void) => void };
|
||||
};
|
||||
};
|
||||
load: (...args: unknown[]) => unknown;
|
||||
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
|
||||
getRange: (cfiRange: string) => Promise<Range>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface EpubRendition {
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
display: (target?: string) => Promise<void>;
|
||||
currentLocation: () => Promise<EpubLocation>;
|
||||
getContents: () => EpubContents[];
|
||||
themes: {
|
||||
default: (styles: Record<string, unknown>) => void;
|
||||
register: (name: string, styles: Record<string, unknown> | string) => void;
|
||||
select: (name: string) => void;
|
||||
};
|
||||
hooks: {
|
||||
content: { register: (_callback: () => void) => void };
|
||||
render: { register: (_callback: (contents: EpubContents) => void) => void };
|
||||
};
|
||||
manager?: {
|
||||
visible?: () => EpubVisibleSection[];
|
||||
};
|
||||
views: () => { container: { scrollLeft: number } };
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface ParsedCfiPath {
|
||||
steps: unknown[];
|
||||
terminal: unknown;
|
||||
}
|
||||
|
||||
interface ParsedCfi {
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
}
|
||||
|
||||
interface EpubCfiHelper {
|
||||
parse: (_value: string) => ParsedCfi;
|
||||
equalStep: (_a: unknown, _b: unknown) => boolean;
|
||||
segmentString: (_value: unknown) => string;
|
||||
}
|
||||
|
||||
interface EpubWithCfiConstructor {
|
||||
CFI: new () => EpubCfiHelper;
|
||||
}
|
||||
|
||||
export interface ReaderStats {
|
||||
chapterName: string;
|
||||
sectionPage: number;
|
||||
sectionTotalPages: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ReaderTocItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface BookState {
|
||||
pages: number;
|
||||
percentage: number;
|
||||
@@ -174,6 +76,7 @@ export class EBookReader {
|
||||
private rendition: EpubRendition;
|
||||
private noSleep: NoSleep | null = null;
|
||||
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private gestureDispose: (() => void) | null = null;
|
||||
private destroyed = false;
|
||||
private onReady: () => void;
|
||||
private onLoading: (_loading: boolean) => void;
|
||||
@@ -187,7 +90,6 @@ export class EBookReader {
|
||||
private onSwipeUp: () => void;
|
||||
private onCenterTap: () => void;
|
||||
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: EBookReaderOptions) {
|
||||
this.container = options.container;
|
||||
@@ -217,7 +119,6 @@ export class EBookReader {
|
||||
pageStart: Date.now(),
|
||||
};
|
||||
|
||||
this.loadSettings();
|
||||
this.readerSettings.theme = {
|
||||
colorScheme: options.colorScheme,
|
||||
fontFamily: options.fontFamily,
|
||||
@@ -237,7 +138,16 @@ export class EBookReader {
|
||||
this.initCSP();
|
||||
this.initWakeLock();
|
||||
this.initThemes();
|
||||
this.initViewerListeners();
|
||||
|
||||
this.gestureDispose = registerRenditionGestures(this.rendition, {
|
||||
isPaginationDisabled: () => this.isPaginationDisabled(),
|
||||
nextPage: () => this.nextPage(),
|
||||
prevPage: () => this.prevPage(),
|
||||
onSwipeDown: () => this.onSwipeDown(),
|
||||
onSwipeUp: () => this.onSwipeUp(),
|
||||
onCenterTap: () => this.onCenterTap(),
|
||||
});
|
||||
|
||||
this.initDocumentListeners();
|
||||
|
||||
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
|
||||
@@ -249,12 +159,6 @@ export class EBookReader {
|
||||
});
|
||||
}
|
||||
|
||||
private loadSettings() {
|
||||
this.readerSettings = {
|
||||
theme: this.readerSettings.theme ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private initWakeLock() {
|
||||
this.noSleep = new NoSleep();
|
||||
document.addEventListener('wakelock', this.handleWakeLock);
|
||||
@@ -279,7 +183,7 @@ export class EBookReader {
|
||||
};
|
||||
|
||||
private initThemes() {
|
||||
THEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
|
||||
READER_COLOR_SCHEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
|
||||
|
||||
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
|
||||
if (!themeLinkEl) {
|
||||
@@ -335,143 +239,24 @@ export class EBookReader {
|
||||
});
|
||||
}
|
||||
|
||||
private initViewerListeners() {
|
||||
const nextPage = this.nextPage.bind(this);
|
||||
const prevPage = this.prevPage.bind(this);
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
const handleSwipeDown = () => {
|
||||
this.resetWheelCooldown();
|
||||
this.onSwipeDown();
|
||||
};
|
||||
|
||||
const handleSwipeUp = () => {
|
||||
this.resetWheelCooldown();
|
||||
this.onSwipeUp();
|
||||
};
|
||||
|
||||
const handleGesture = () => {
|
||||
const drasticity = 50;
|
||||
|
||||
if (touchEndY - drasticity > touchStartY) {
|
||||
return handleSwipeDown();
|
||||
}
|
||||
|
||||
if (touchEndY + drasticity < touchStartY) {
|
||||
return handleSwipeUp();
|
||||
}
|
||||
|
||||
if (!this.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
|
||||
void nextPage();
|
||||
}
|
||||
|
||||
if (!this.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
|
||||
void prevPage();
|
||||
}
|
||||
};
|
||||
|
||||
this.rendition.hooks.render.register((contents: EpubContents) => {
|
||||
const renderDoc = contents.document;
|
||||
|
||||
const wakeLockListener = () => {
|
||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||
};
|
||||
renderDoc.addEventListener('click', wakeLockListener);
|
||||
renderDoc.addEventListener('gesturechange', wakeLockListener);
|
||||
renderDoc.addEventListener('touchstart', wakeLockListener);
|
||||
|
||||
renderDoc.addEventListener('click', (event: MouseEvent) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
const pagePixels = windowWidth * 0.2;
|
||||
const top = barPixels;
|
||||
const bottom = window.innerHeight - top;
|
||||
const left = pagePixels;
|
||||
const right = windowWidth - left;
|
||||
const leftOffset = this.rendition.views().container.scrollLeft;
|
||||
const yCoord = event.clientY;
|
||||
const xCoord = event.clientX - leftOffset;
|
||||
|
||||
if (yCoord < top) {
|
||||
handleSwipeDown();
|
||||
} else if (yCoord > bottom) {
|
||||
handleSwipeUp();
|
||||
} else if (!this.isPaginationDisabled() && xCoord < left) {
|
||||
void prevPage();
|
||||
} else if (!this.isPaginationDisabled() && xCoord > right) {
|
||||
void nextPage();
|
||||
} else {
|
||||
this.onCenterTap();
|
||||
}
|
||||
});
|
||||
|
||||
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
|
||||
if (this.wheelTimeoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaY > 25) {
|
||||
handleSwipeUp();
|
||||
return;
|
||||
}
|
||||
if (event.deltaY < -25) {
|
||||
handleSwipeDown();
|
||||
}
|
||||
});
|
||||
|
||||
renderDoc.addEventListener(
|
||||
'touchstart',
|
||||
(event: TouchEvent) => {
|
||||
touchStartX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchStartY = event.changedTouches[0]?.screenY ?? 0;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
renderDoc.addEventListener(
|
||||
'touchend',
|
||||
(event: TouchEvent) => {
|
||||
touchEndX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchEndY = event.changedTouches[0]?.screenY ?? 0;
|
||||
handleGesture();
|
||||
},
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private resetWheelCooldown() {
|
||||
if (this.wheelTimeoutId) {
|
||||
clearTimeout(this.wheelTimeoutId);
|
||||
this.wheelTimeoutId = null;
|
||||
}
|
||||
|
||||
this.wheelTimeoutId = setTimeout(() => {
|
||||
this.wheelTimeoutId = null;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
private initDocumentListeners() {
|
||||
const nextPage = this.nextPage.bind(this);
|
||||
const prevPage = this.prevPage.bind(this);
|
||||
|
||||
this.keyupHandler = (event: KeyboardEvent) => {
|
||||
if ((event.keyCode || event.which) === 37) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
void prevPage();
|
||||
}
|
||||
if ((event.keyCode || event.which) === 39) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
void nextPage();
|
||||
}
|
||||
if ((event.keyCode || event.which) === 84) {
|
||||
if (event.key === 't' || event.key === 'T') {
|
||||
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
||||
const currentThemeIdx = THEMES.indexOf(currentTheme);
|
||||
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
||||
const colorScheme =
|
||||
THEMES.length === currentThemeIdx + 1 ? THEMES[0] : THEMES[currentThemeIdx + 1];
|
||||
READER_COLOR_SCHEMES.length === currentThemeIdx + 1
|
||||
? READER_COLOR_SCHEMES[0]
|
||||
: READER_COLOR_SCHEMES[currentThemeIdx + 1];
|
||||
if (colorScheme) {
|
||||
this.setTheme({ colorScheme });
|
||||
}
|
||||
@@ -482,44 +267,20 @@ export class EBookReader {
|
||||
}
|
||||
|
||||
private async setupReader() {
|
||||
this.bookState.words = await this.countWords();
|
||||
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
||||
await this.setPosition(cfi);
|
||||
const { element } = await this.getCFIFromXPath(this.bookState.progress);
|
||||
this.bookState.progressElement = element ?? null;
|
||||
this.bookState.words = await countWords(this.book);
|
||||
const cfiResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||
await this.setPosition(cfiResult?.cfi);
|
||||
const elementResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||
this.bookState.progressElement = elementResult?.element ?? null;
|
||||
this.highlightPositionMarker();
|
||||
const stats = await this.getBookStats();
|
||||
this.onStats(stats);
|
||||
this.bookState.pageStart = Date.now();
|
||||
this.onToc(this.getParsedTOC());
|
||||
this.onToc(getParsedTOC(this.book));
|
||||
this.onLoading(false);
|
||||
this.onReady();
|
||||
}
|
||||
|
||||
private getParsedTOC(): ReaderTocItem[] {
|
||||
if (!this.book.navigation?.toc) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
|
||||
const sectionTitle = item.label?.trim() ?? '';
|
||||
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
|
||||
if (!item.subitems || item.subitems.length === 0) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const allSubSections = item.subitems.map(subitem => {
|
||||
let itemTitle = subitem.label?.trim() ?? 'Untitled';
|
||||
if (sectionTitle !== '') {
|
||||
itemTitle = `${sectionTitle} - ${itemTitle}`;
|
||||
}
|
||||
return { title: itemTitle, href: subitem.href };
|
||||
});
|
||||
agg.push(...allSubSections);
|
||||
return agg;
|
||||
}, []);
|
||||
}
|
||||
|
||||
setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
|
||||
this.readerSettings.theme =
|
||||
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
|
||||
@@ -615,6 +376,8 @@ export class EBookReader {
|
||||
return;
|
||||
}
|
||||
|
||||
// Triple Display - epubjs occasionally renders at the wrong position on a single display()
|
||||
// when restoring a CFI; calling it three times is a known workaround to land on the exact page.
|
||||
await this.rendition.display(cfi);
|
||||
await this.rendition.display(cfi);
|
||||
await this.rendition.display(cfi);
|
||||
@@ -627,11 +390,11 @@ export class EBookReader {
|
||||
fontSize?: number;
|
||||
}) {
|
||||
const currentProgress = this.bookState.progress;
|
||||
const { cfi } = await this.getCFIFromXPath(currentProgress);
|
||||
const cfiResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||
this.setTheme(newTheme);
|
||||
await this.setPosition(cfi);
|
||||
const { element } = await this.getCFIFromXPath(currentProgress);
|
||||
this.bookState.progressElement = element ?? null;
|
||||
await this.setPosition(cfiResult?.cfi);
|
||||
const elementResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||
this.bookState.progressElement = elementResult?.element ?? null;
|
||||
this.highlightPositionMarker();
|
||||
}
|
||||
|
||||
@@ -641,8 +404,8 @@ export class EBookReader {
|
||||
|
||||
const pageStart = this.bookState.pageStart;
|
||||
let elapsedTime = Date.now() - pageStart;
|
||||
const pageWords = await this.getVisibleWordCount();
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const pageWords = await getVisibleWordCount(this.book, this.rendition);
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
const percentRead = pageWords / this.bookState.words;
|
||||
const pageWPM = pageWords / (elapsedTime / 60000);
|
||||
|
||||
@@ -686,10 +449,10 @@ export class EBookReader {
|
||||
|
||||
async createProgress() {
|
||||
const currentCFI = await this.rendition.currentLocation();
|
||||
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
this.bookState.progress = xpath ?? '';
|
||||
this.bookState.progressElement = element ?? null;
|
||||
const xpathResult = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
this.bookState.progress = xpathResult?.xpath ?? '';
|
||||
this.bookState.progressElement = xpathResult?.element ?? null;
|
||||
|
||||
const percentage =
|
||||
this.bookState.words > 0
|
||||
@@ -744,7 +507,7 @@ export class EBookReader {
|
||||
}
|
||||
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
const currentTOC = this.book.navigation?.toc?.find(
|
||||
item => item.href === currentLocation.start.href
|
||||
);
|
||||
@@ -760,228 +523,6 @@ export class EBookReader {
|
||||
};
|
||||
}
|
||||
|
||||
async getXPathFromCFI(cfi: string) {
|
||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||
if (!cfiBaseMatch?.[1]) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
const startCFI = cfiBaseMatch[1];
|
||||
|
||||
const docFragmentIndex =
|
||||
(this.book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||
if (docFragmentIndex <= 0) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||
const contents = this.rendition.getContents()[0];
|
||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||
if (!currentNodeStart) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
let currentNode: Node | null = currentNodeStart;
|
||||
const element =
|
||||
currentNode.nodeType === Node.ELEMENT_NODE
|
||||
? (currentNode as Element)
|
||||
: currentNode.parentElement;
|
||||
|
||||
let allPos = '';
|
||||
while (currentNode && currentNode.nodeName !== 'BODY') {
|
||||
let parentElement: Element | null = currentNode.parentElement;
|
||||
if (!parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
const currentElement = currentNode as Element;
|
||||
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
|
||||
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
|
||||
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
|
||||
currentNode = parentElement;
|
||||
allPos = `/${nodePos}${allPos}`;
|
||||
}
|
||||
|
||||
return { xpath: `${basePos}${allPos}`, element };
|
||||
}
|
||||
|
||||
async getCFIFromXPath(xpath?: string) {
|
||||
if (!xpath) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||
if (!fragMatch?.[1]) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||
const sectionItem = this.book.spine.get(spinePosition);
|
||||
await sectionItem.load(this.book.load.bind(this.book));
|
||||
|
||||
const renderedContent = this.rendition
|
||||
.getContents()
|
||||
.find(item => item.sectionIndex == spinePosition);
|
||||
const docItem = renderedContent?.document || sectionItem.document;
|
||||
|
||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||
let remainingXPath = xpath
|
||||
.replace(fragMatch[0], '/html')
|
||||
.replace(/\.(\d+)$/, '')
|
||||
.replace(/\/text\(\)(\[\d+\])?$/, '');
|
||||
|
||||
const derivedSelectorElement = remainingXPath
|
||||
.replace(/^\/html\/body/, 'body')
|
||||
.split('/')
|
||||
.reduce(
|
||||
(element: ParentNode | null, item: string) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
|
||||
if (!indexMatch) {
|
||||
return element.querySelector(item);
|
||||
}
|
||||
|
||||
const [, tag, rawIndex] = indexMatch;
|
||||
if (!tag || !rawIndex) {
|
||||
return null;
|
||||
}
|
||||
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
|
||||
},
|
||||
docItem as ParentNode | null
|
||||
);
|
||||
|
||||
if (namespaceURI) {
|
||||
remainingXPath = remainingXPath.split('/').join('/ns:');
|
||||
}
|
||||
|
||||
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
|
||||
if (prefix === 'ns') {
|
||||
return namespaceURI;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const xpathElement = docSearch.iterateNext();
|
||||
const element = xpathElement || derivedSelectorElement;
|
||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||
if (!isElementNode) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const resolvedElement = element as Element;
|
||||
|
||||
let cfi = sectionItem.cfiFromElement(resolvedElement);
|
||||
if (cfi.endsWith('!/)')) {
|
||||
cfi = `${cfi.slice(0, -1)}0)`;
|
||||
}
|
||||
|
||||
return { cfi, element: resolvedElement };
|
||||
}
|
||||
|
||||
async getVisibleWordCount() {
|
||||
const visibleText = await this.getVisibleText();
|
||||
return visibleText.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
async getBookWordPosition() {
|
||||
const contents = this.rendition.getContents()[0];
|
||||
if (!contents) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spineItem = this.book.spine.get(contents.sectionIndex ?? 0);
|
||||
const firstElement = spineItem.document.body.children[0];
|
||||
if (!firstElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstCFI = spineItem.cfiFromElement(firstElement);
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||
const textRange = await this.book.getRange(cfiRange);
|
||||
const chapterText = textRange.toString();
|
||||
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
||||
const preChapterWordPosition = this.book.spine.spineItems
|
||||
.slice(0, contents.sectionIndex ?? 0)
|
||||
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
|
||||
|
||||
return chapterWordPosition + preChapterWordPosition;
|
||||
}
|
||||
|
||||
async getVisibleText() {
|
||||
this.rendition.manager?.visible?.()?.forEach(item => item.expand());
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const cfiRange = this.getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
|
||||
const textRange = await this.book.getRange(cfiRange);
|
||||
return textRange.toString();
|
||||
}
|
||||
|
||||
getCFIRange(a: string, b: string) {
|
||||
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
|
||||
const start = CFI.parse(a);
|
||||
const end = CFI.parse(b);
|
||||
const cfi: {
|
||||
range: boolean;
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
start: ParsedCfiPath;
|
||||
end: ParsedCfiPath;
|
||||
} = {
|
||||
range: true,
|
||||
base: start.base,
|
||||
path: { steps: [], terminal: null },
|
||||
start: start.path,
|
||||
end: end.path,
|
||||
};
|
||||
|
||||
const len = cfi.start.steps.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||
if (i === len - 1) {
|
||||
if (cfi.start.terminal === cfi.end.terminal) {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
cfi.range = false;
|
||||
}
|
||||
} else {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||
|
||||
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
|
||||
}
|
||||
|
||||
async countWords() {
|
||||
const spineWC = await Promise.all(
|
||||
this.book.spine.spineItems.map(async item => {
|
||||
const newDoc = await item.load(this.book.load.bind(this.book));
|
||||
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
|
||||
.trim()
|
||||
.split(/\s+/).length;
|
||||
item.wordCount = spineWords;
|
||||
return spineWords;
|
||||
})
|
||||
);
|
||||
|
||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
if (this.keyupHandler) {
|
||||
@@ -991,9 +532,7 @@ export class EBookReader {
|
||||
if (this.wakeTimeoutId) {
|
||||
clearTimeout(this.wakeTimeoutId);
|
||||
}
|
||||
if (this.wheelTimeoutId) {
|
||||
clearTimeout(this.wheelTimeoutId);
|
||||
}
|
||||
this.gestureDispose?.();
|
||||
void this.noSleep?.disable();
|
||||
this.rendition.destroy?.();
|
||||
this.book.destroy?.();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import ePub from 'epubjs';
|
||||
import type {
|
||||
EpubBook,
|
||||
EpubRendition,
|
||||
EpubWithCfiConstructor,
|
||||
ParsedCfiPath,
|
||||
ReaderTocItem,
|
||||
} from './types';
|
||||
|
||||
export function getParsedTOC(book: EpubBook): ReaderTocItem[] {
|
||||
if (!book.navigation?.toc) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
|
||||
const sectionTitle = item.label?.trim() ?? '';
|
||||
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
|
||||
if (!item.subitems || item.subitems.length === 0) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const allSubSections = item.subitems.map(subitem => {
|
||||
let itemTitle = subitem.label?.trim() ?? 'Untitled';
|
||||
if (sectionTitle !== '') {
|
||||
itemTitle = `${sectionTitle} - ${itemTitle}`;
|
||||
}
|
||||
return { title: itemTitle, href: subitem.href };
|
||||
});
|
||||
agg.push(...allSubSections);
|
||||
return agg;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function countWords(book: EpubBook) {
|
||||
const spineWC = await Promise.all(
|
||||
book.spine.spineItems.map(async item => {
|
||||
const newDoc = await item.load(book.load.bind(book));
|
||||
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
|
||||
.trim()
|
||||
.split(/\s+/).length;
|
||||
item.wordCount = spineWords;
|
||||
return spineWords;
|
||||
})
|
||||
);
|
||||
|
||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||
}
|
||||
|
||||
export function getCFIRange(a: string, b: string) {
|
||||
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
|
||||
const start = CFI.parse(a);
|
||||
const end = CFI.parse(b);
|
||||
const cfi: {
|
||||
range: boolean;
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
start: ParsedCfiPath;
|
||||
end: ParsedCfiPath;
|
||||
} = {
|
||||
range: true,
|
||||
base: start.base,
|
||||
path: { steps: [], terminal: null },
|
||||
start: start.path,
|
||||
end: end.path,
|
||||
};
|
||||
|
||||
const len = cfi.start.steps.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||
if (i === len - 1) {
|
||||
if (cfi.start.terminal === cfi.end.terminal) {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
cfi.range = false;
|
||||
}
|
||||
} else {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||
|
||||
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
|
||||
}
|
||||
|
||||
export async function getVisibleText(book: EpubBook, rendition: EpubRendition) {
|
||||
rendition.manager?.visible?.()?.forEach(item => item.expand());
|
||||
const currentLocation = await rendition.currentLocation();
|
||||
const cfiRange = getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
|
||||
const textRange = await book.getRange(cfiRange);
|
||||
return textRange.toString();
|
||||
}
|
||||
|
||||
export async function getVisibleWordCount(book: EpubBook, rendition: EpubRendition) {
|
||||
const visibleText = await getVisibleText(book, rendition);
|
||||
return visibleText.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
export async function getBookWordPosition(book: EpubBook, rendition: EpubRendition) {
|
||||
const contents = rendition.getContents()[0];
|
||||
if (!contents) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spineItem = book.spine.get(contents.sectionIndex ?? 0);
|
||||
const firstElement = spineItem.document.body.children[0];
|
||||
if (!firstElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstCFI = spineItem.cfiFromElement(firstElement);
|
||||
const currentLocation = await rendition.currentLocation();
|
||||
const cfiRange = getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||
const textRange = await book.getRange(cfiRange);
|
||||
const chapterText = textRange.toString();
|
||||
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
||||
const preChapterWordPosition = book.spine.spineItems
|
||||
.slice(0, contents.sectionIndex ?? 0)
|
||||
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
|
||||
|
||||
return chapterWordPosition + preChapterWordPosition;
|
||||
}
|
||||
|
||||
export async function getXPathFromCFI(
|
||||
book: EpubBook,
|
||||
rendition: EpubRendition,
|
||||
cfi: string
|
||||
): Promise<{ xpath: string; element: Element | null } | null> {
|
||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||
if (!cfiBaseMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
const startCFI = cfiBaseMatch[1];
|
||||
|
||||
const docFragmentIndex =
|
||||
(book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||
if (docFragmentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||
const contents = rendition.getContents()[0];
|
||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||
if (!currentNodeStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentNode: Node | null = currentNodeStart;
|
||||
const element =
|
||||
currentNode.nodeType === Node.ELEMENT_NODE
|
||||
? (currentNode as Element)
|
||||
: currentNode.parentElement;
|
||||
|
||||
let allPos = '';
|
||||
while (currentNode && currentNode.nodeName !== 'BODY') {
|
||||
let parentElement: Element | null = currentNode.parentElement;
|
||||
if (!parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
const currentElement = currentNode as Element;
|
||||
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
|
||||
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
|
||||
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
|
||||
currentNode = parentElement;
|
||||
allPos = `/${nodePos}${allPos}`;
|
||||
}
|
||||
|
||||
return { xpath: `${basePos}${allPos}`, element };
|
||||
}
|
||||
|
||||
export async function getCFIFromXPath(
|
||||
book: EpubBook,
|
||||
rendition: EpubRendition,
|
||||
xpath?: string
|
||||
): Promise<{ cfi: string; element: Element } | null> {
|
||||
if (!xpath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||
if (!fragMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||
const sectionItem = book.spine.get(spinePosition);
|
||||
await sectionItem.load(book.load.bind(book));
|
||||
|
||||
const renderedContent = rendition.getContents().find(item => item.sectionIndex == spinePosition);
|
||||
const docItem = renderedContent?.document || sectionItem.document;
|
||||
|
||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||
let remainingXPath = xpath
|
||||
.replace(fragMatch[0], '/html')
|
||||
.replace(/\.(\d+)$/, '')
|
||||
.replace(/\/text\(\)(\[\d+\])?$/, '');
|
||||
|
||||
const derivedSelectorElement = remainingXPath
|
||||
.replace(/^\/html\/body/, 'body')
|
||||
.split('/')
|
||||
.reduce(
|
||||
(element: ParentNode | null, item: string) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
|
||||
if (!indexMatch) {
|
||||
return element.querySelector(item);
|
||||
}
|
||||
|
||||
const [, tag, rawIndex] = indexMatch;
|
||||
if (!tag || !rawIndex) {
|
||||
return null;
|
||||
}
|
||||
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
|
||||
},
|
||||
docItem as ParentNode | null
|
||||
);
|
||||
|
||||
if (namespaceURI) {
|
||||
remainingXPath = remainingXPath.split('/').join('/ns:');
|
||||
}
|
||||
|
||||
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
|
||||
if (prefix === 'ns') {
|
||||
return namespaceURI;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const xpathElement = docSearch.iterateNext();
|
||||
const element = xpathElement || derivedSelectorElement;
|
||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||
if (!isElementNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedElement = element as Element;
|
||||
|
||||
let cfi = sectionItem.cfiFromElement(resolvedElement);
|
||||
if (cfi.endsWith('!/)')) {
|
||||
cfi = `${cfi.slice(0, -1)}0)`;
|
||||
}
|
||||
|
||||
return { cfi, element: resolvedElement };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { EpubContents, EpubRendition } from './types';
|
||||
|
||||
export interface GestureHandlers {
|
||||
isPaginationDisabled: () => boolean;
|
||||
nextPage: () => Promise<void>;
|
||||
prevPage: () => Promise<void>;
|
||||
onSwipeDown: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onCenterTap: () => void;
|
||||
}
|
||||
|
||||
const WHEEL_COOLDOWN_MS = 400;
|
||||
const SWIPE_THRESHOLD = 25;
|
||||
|
||||
/**
|
||||
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose
|
||||
* (called from EBookReader.destroy) that clears the pending wheel-cooldown timeout and removes
|
||||
* every listener added across rendered sections, so they don't accumulate on re-render.
|
||||
*/
|
||||
export function registerRenditionGestures(
|
||||
rendition: EpubRendition,
|
||||
handlers: GestureHandlers
|
||||
): () => void {
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const listenerCleanups: Array<() => void> = [];
|
||||
|
||||
const resetWheelCooldown = () => {
|
||||
if (wheelTimeoutId) {
|
||||
clearTimeout(wheelTimeoutId);
|
||||
}
|
||||
wheelTimeoutId = setTimeout(() => {
|
||||
wheelTimeoutId = null;
|
||||
}, WHEEL_COOLDOWN_MS);
|
||||
};
|
||||
|
||||
const handleSwipeDown = () => {
|
||||
resetWheelCooldown();
|
||||
handlers.onSwipeDown();
|
||||
};
|
||||
|
||||
const handleSwipeUp = () => {
|
||||
resetWheelCooldown();
|
||||
handlers.onSwipeUp();
|
||||
};
|
||||
|
||||
const handleGesture = () => {
|
||||
const drasticity = 50;
|
||||
|
||||
if (touchEndY - drasticity > touchStartY) {
|
||||
return handleSwipeDown();
|
||||
}
|
||||
|
||||
if (touchEndY + drasticity < touchStartY) {
|
||||
return handleSwipeUp();
|
||||
}
|
||||
|
||||
if (!handlers.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
|
||||
void handlers.nextPage();
|
||||
}
|
||||
|
||||
if (!handlers.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
|
||||
void handlers.prevPage();
|
||||
}
|
||||
};
|
||||
|
||||
rendition.hooks.render.register((contents: EpubContents) => {
|
||||
const renderDoc = contents.document;
|
||||
|
||||
const onWakeLock = () => {
|
||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
const pagePixels = windowWidth * 0.2;
|
||||
const top = barPixels;
|
||||
const bottom = window.innerHeight - top;
|
||||
const left = pagePixels;
|
||||
const right = windowWidth - left;
|
||||
const leftOffset = rendition.views().container.scrollLeft;
|
||||
const yCoord = event.clientY;
|
||||
const xCoord = event.clientX - leftOffset;
|
||||
|
||||
if (yCoord < top) {
|
||||
handleSwipeDown();
|
||||
} else if (yCoord > bottom) {
|
||||
handleSwipeUp();
|
||||
} else if (!handlers.isPaginationDisabled() && xCoord < left) {
|
||||
void handlers.prevPage();
|
||||
} else if (!handlers.isPaginationDisabled() && xCoord > right) {
|
||||
void handlers.nextPage();
|
||||
} else {
|
||||
handlers.onCenterTap();
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (wheelTimeoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaY > SWIPE_THRESHOLD) {
|
||||
handleSwipeUp();
|
||||
return;
|
||||
}
|
||||
if (event.deltaY < -SWIPE_THRESHOLD) {
|
||||
handleSwipeDown();
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
touchStartX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchStartY = event.changedTouches[0]?.screenY ?? 0;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
touchEndX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchEndY = event.changedTouches[0]?.screenY ?? 0;
|
||||
handleGesture();
|
||||
};
|
||||
|
||||
renderDoc.addEventListener('click', onWakeLock);
|
||||
renderDoc.addEventListener('gesturechange', onWakeLock);
|
||||
renderDoc.addEventListener('touchstart', onWakeLock);
|
||||
renderDoc.addEventListener('click', onClick);
|
||||
renderDoc.addEventListener('wheel', onWheel);
|
||||
renderDoc.addEventListener('touchstart', onTouchStart);
|
||||
renderDoc.addEventListener('touchend', onTouchEnd);
|
||||
|
||||
listenerCleanups.push(() => {
|
||||
renderDoc.removeEventListener('click', onWakeLock);
|
||||
renderDoc.removeEventListener('gesturechange', onWakeLock);
|
||||
renderDoc.removeEventListener('touchstart', onWakeLock);
|
||||
renderDoc.removeEventListener('click', onClick);
|
||||
renderDoc.removeEventListener('wheel', onWheel);
|
||||
renderDoc.removeEventListener('touchstart', onTouchStart);
|
||||
renderDoc.removeEventListener('touchend', onTouchEnd);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (wheelTimeoutId) {
|
||||
clearTimeout(wheelTimeoutId);
|
||||
wheelTimeoutId = null;
|
||||
}
|
||||
listenerCleanups.forEach(cleanup => cleanup());
|
||||
listenerCleanups.length = 0;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export interface TocNode {
|
||||
href: string;
|
||||
label?: string;
|
||||
subitems?: TocNode[];
|
||||
}
|
||||
|
||||
export interface EpubContents {
|
||||
document: Document;
|
||||
sectionIndex?: number;
|
||||
range: (cfi: string) => Range;
|
||||
}
|
||||
|
||||
export interface EpubVisibleSection {
|
||||
index: number;
|
||||
layout: { width: number; divisor: number };
|
||||
width: () => number;
|
||||
expand: () => void;
|
||||
}
|
||||
|
||||
export interface EpubLocation {
|
||||
start: {
|
||||
cfi: string;
|
||||
href?: string;
|
||||
};
|
||||
end: {
|
||||
cfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EpubNavigation {
|
||||
toc?: TocNode[];
|
||||
}
|
||||
|
||||
export interface EpubSpineItem {
|
||||
cfiBase: string;
|
||||
index: number;
|
||||
document: Document;
|
||||
load: (_loader: unknown) => Promise<Document>;
|
||||
cfiFromElement: (element: Element) => string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
export interface EpubBook {
|
||||
ready: Promise<void>;
|
||||
navigation?: EpubNavigation;
|
||||
loaded: { navigation: Promise<EpubNavigation> };
|
||||
spine: {
|
||||
spineItems: EpubSpineItem[];
|
||||
get: (index: number) => EpubSpineItem;
|
||||
hooks: {
|
||||
content: { register: (_callback: (output: Document) => void) => void };
|
||||
};
|
||||
};
|
||||
load: (...args: unknown[]) => unknown;
|
||||
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
|
||||
getRange: (cfiRange: string) => Promise<Range>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface EpubRendition {
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
display: (target?: string) => Promise<void>;
|
||||
currentLocation: () => Promise<EpubLocation>;
|
||||
getContents: () => EpubContents[];
|
||||
themes: {
|
||||
default: (styles: Record<string, unknown>) => void;
|
||||
register: (name: string, styles: Record<string, unknown> | string) => void;
|
||||
select: (name: string) => void;
|
||||
};
|
||||
hooks: {
|
||||
content: { register: (_callback: () => void) => void };
|
||||
render: { register: (_callback: (contents: EpubContents) => void) => void };
|
||||
};
|
||||
manager?: {
|
||||
visible?: () => EpubVisibleSection[];
|
||||
};
|
||||
views: () => { container: { scrollLeft: number } };
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface ParsedCfiPath {
|
||||
steps: unknown[];
|
||||
terminal: unknown;
|
||||
}
|
||||
|
||||
export interface ParsedCfi {
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
}
|
||||
|
||||
export interface EpubCfiHelper {
|
||||
parse: (_value: string) => ParsedCfi;
|
||||
equalStep: (_a: unknown, _b: unknown) => boolean;
|
||||
segmentString: (_value: unknown) => string;
|
||||
}
|
||||
|
||||
export interface EpubWithCfiConstructor {
|
||||
CFI: new () => EpubCfiHelper;
|
||||
}
|
||||
|
||||
export interface ReaderStats {
|
||||
chapterName: string;
|
||||
sectionPage: number;
|
||||
sectionTotalPages: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ReaderTocItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
||||
import App from './App';
|
||||
import { ApiError } from './utils/apiFetch';
|
||||
import './index.css';
|
||||
|
||||
initializeThemeMode();
|
||||
@@ -13,7 +14,14 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
// 4xx responses are deterministic (e.g. /auth/me 401 when logged out); only retry transient
|
||||
// network/5xx failures.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status < 500) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { documentColumn } from '../components/documentColumn';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { formatDuration, formatDateTime } from '../utils/formatters';
|
||||
|
||||
const ACTIVITY_PAGE_SIZE = 25;
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const documentID = searchParams.get('document') || undefined;
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 25;
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [documentID]);
|
||||
const { page, setPage } = usePaginatedList(documentID);
|
||||
const limit = ACTIVITY_PAGE_SIZE;
|
||||
|
||||
const { data, isLoading } = useGetActivity({
|
||||
doc_filter: Boolean(documentID),
|
||||
@@ -22,33 +21,25 @@ export default function ActivityPage() {
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
const response = data?.status === 200 ? data.data : undefined;
|
||||
const response = data;
|
||||
const activities = response?.activities ?? [];
|
||||
|
||||
const columns: Column<Activity>[] = [
|
||||
documentColumn,
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_value, row) => (
|
||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'start_time' as const,
|
||||
id: 'start_time',
|
||||
header: 'Time',
|
||||
render: value => String(value || 'N/A'),
|
||||
render: row => formatDateTime(row.start_time),
|
||||
},
|
||||
{
|
||||
key: 'duration' as const,
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
render: value => formatDuration(typeof value === 'number' ? value : 0),
|
||||
render: row => formatDuration(row.duration ?? 0),
|
||||
},
|
||||
{
|
||||
key: 'end_percentage' as const,
|
||||
id: 'end_percentage',
|
||||
header: 'Percent',
|
||||
render: value => (typeof value === 'number' ? `${value}%` : '0%'),
|
||||
render: row => (typeof row.end_percentage === 'number' ? `${row.end_percentage}%` : '0%'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type SyntheticEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LoadingState, Table, type Column } from '../components';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import type { DirectoryItem, DirectoryListResponse } from '../generated/model';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import type { DirectoryItem } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||
const { showInfo, showError } = useToasts();
|
||||
const navigate = useNavigate();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||
currentPath ? { directory: currentPath } : {}
|
||||
@@ -18,8 +20,7 @@ export default function AdminImportPage() {
|
||||
|
||||
const postImport = usePostImport();
|
||||
|
||||
const directoryResponse =
|
||||
directoryData?.status === 200 ? (directoryData.data as DirectoryListResponse) : null;
|
||||
const directoryResponse = directoryData;
|
||||
const directories = directoryResponse?.items ?? [];
|
||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||
|
||||
@@ -35,7 +36,8 @@ export default function AdminImportPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
const handleImport = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
postImport.mutate(
|
||||
@@ -45,17 +47,11 @@ export default function AdminImportPage() {
|
||||
type: importType,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: _response => {
|
||||
showInfo('Import completed successfully');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/import-results';
|
||||
}, 1500);
|
||||
},
|
||||
onError: error => {
|
||||
showError('Import failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Import completed successfully',
|
||||
error: 'Import failed',
|
||||
onSuccess: () => navigate('/admin/import-results'),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -64,110 +60,88 @@ export default function AdminImportPage() {
|
||||
};
|
||||
|
||||
if (isLoading && !currentPath) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (selectedDirectory) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<div className="flex items-center gap-4 text-content">
|
||||
<FolderOpenIcon size={20} />
|
||||
<p className="break-all text-lg font-medium">{selectedDirectory}</p>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col justify-around gap-2 text-content">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="direct"
|
||||
checked={importType === 'DIRECT'}
|
||||
onChange={() => setImportType('DIRECT')}
|
||||
/>
|
||||
<label htmlFor="direct">Direct</label>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="copy"
|
||||
checked={importType === 'COPY'}
|
||||
onChange={() => setImportType('COPY')}
|
||||
/>
|
||||
<label htmlFor="copy">Copy</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<div className="flex items-center gap-4 text-content">
|
||||
<FolderOpenIcon size={20} />
|
||||
<p className="break-all text-lg font-medium">{selectedDirectory}</p>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col justify-around gap-2 text-content">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="direct"
|
||||
checked={importType === 'DIRECT'}
|
||||
onChange={() => setImportType('DIRECT')}
|
||||
/>
|
||||
<label htmlFor="direct">Direct</label>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="px-10 py-2 text-base">
|
||||
Import Directory
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="px-10 py-2 text-base"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="copy"
|
||||
checked={importType === 'COPY'}
|
||||
onChange={() => setImportType('COPY')}
|
||||
/>
|
||||
<label htmlFor="copy">Copy</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="px-10 py-2 text-base">
|
||||
Import Directory
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="px-10 py-2 text-base"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const directoryColumns: Column<DirectoryItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: item => (
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<FolderOpenIcon size={20} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: currentPathDisplay, render: item => item.name ?? '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal"></th>
|
||||
<th className="break-all border-b border-border p-3 text-left font-normal">
|
||||
{currentPath}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr>
|
||||
<td className="border-b border-border p-3 text-content-muted"></td>
|
||||
<td className="border-b border-border p-3">
|
||||
<button onClick={handleNavigateUp}>
|
||||
<p>../</p>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{directories.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={2}>
|
||||
No Folders
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
directories.map((item: DirectoryItem) => (
|
||||
<tr key={item.name}>
|
||||
<td className="border-b border-border p-3 text-content-muted">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<FolderOpenIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<p>{item.name ?? ''}</p>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
{currentPathDisplay !== '/' && (
|
||||
<button
|
||||
onClick={handleNavigateUp}
|
||||
className="self-start text-content hover:text-primary-600"
|
||||
>
|
||||
../
|
||||
</button>
|
||||
)}
|
||||
<Table
|
||||
columns={directoryColumns}
|
||||
data={directories}
|
||||
emptyMessage="No Folders"
|
||||
rowKey={item => item.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,41 @@
|
||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||
import type { ImportResult, ImportResultsResponse } from '../generated/model';
|
||||
import { Table, type Column } from '../components';
|
||||
import type { ImportResult } from '../generated/model';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function AdminImportResultsPage() {
|
||||
const { data: resultsData, isLoading } = useGetImportResults();
|
||||
const results =
|
||||
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
|
||||
const results = resultsData?.results ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
const columns: Column<ImportResult>[] = [
|
||||
{
|
||||
id: 'document',
|
||||
header: 'Document',
|
||||
render: result => (
|
||||
<div className="grid grid-cols-[4rem_auto] gap-y-1">
|
||||
<span className="text-content-muted">Name:</span>
|
||||
{result.id ? (
|
||||
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||
{result.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>N/A</span>
|
||||
)}
|
||||
<span className="text-content-muted">File:</span>
|
||||
<span>{result.path}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: 'status', header: 'Status', render: result => result.status },
|
||||
{ id: 'error', header: 'Error', render: result => result.error ?? '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Document
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Error
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={3}>
|
||||
No Results
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
results.map((result: ImportResult, index: number) => (
|
||||
<tr key={index}>
|
||||
<td
|
||||
className="grid border-b border-border p-3"
|
||||
style={{ gridTemplateColumns: '4rem auto' }}
|
||||
>
|
||||
<span className="text-content-muted">Name:</span>
|
||||
{result.id ? (
|
||||
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||
{result.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>N/A</span>
|
||||
)}
|
||||
<span className="text-content-muted">File:</span>
|
||||
<span>{result.path}</span>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{result.status}</p>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{result.error || ''}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={results}
|
||||
loading={isLoading}
|
||||
rowKey={result => result.path ?? result.name ?? ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { SyntheticEvent } from 'react';
|
||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||
import type { LogsResponse } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState } from '../components';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Button, LoadingState, TextInput, IconInput } from '../components';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon } from '../icons';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState('');
|
||||
const debouncedFilter = useDebounce(filter, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveFilter(debouncedFilter);
|
||||
}, [debouncedFilter]);
|
||||
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
|
||||
|
||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
||||
|
||||
const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : [];
|
||||
const logs = logsData?.logs ?? [];
|
||||
|
||||
const handleFilterSubmit = (e: FormEvent) => {
|
||||
const handleFilterSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
setActiveFilter(filter);
|
||||
flushFilter();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -29,34 +21,27 @@ export default function AdminLogsPage() {
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface p-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
className="p-2"
|
||||
placeholder="JQ Filter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Filter
|
||||
</Button>
|
||||
</IconInput>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Filter
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-full flex-col-reverse overflow-scroll text-content"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
<div className="flex w-full flex-col-reverse overflow-scroll font-mono text-content">
|
||||
{isLoading ? (
|
||||
<LoadingState className="min-h-40 w-full" />
|
||||
) : (
|
||||
// Key By Index - Log lines have no stable id and can repeat; this list is append-only and fully replaced on refetch.
|
||||
logs.map((log, index) => (
|
||||
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
||||
{typeof log === 'string' ? log : JSON.stringify(log)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { useState, SyntheticEvent } from 'react';
|
||||
import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||
|
||||
interface BackupTypes {
|
||||
covers: boolean;
|
||||
@@ -10,9 +12,9 @@ interface BackupTypes {
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { isLoading } = useGetAdmin();
|
||||
const postAdminAction = usePostAdminAction();
|
||||
const { showInfo, showError, removeToast } = useToasts();
|
||||
const { showInfo, showError, updateToast } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||
covers: false,
|
||||
@@ -20,7 +22,7 @@ export default function AdminPage() {
|
||||
});
|
||||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||
|
||||
const handleBackupSubmit = async (e: FormEvent) => {
|
||||
const handleBackupSubmit = async (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const backupTypesList: string[] = [];
|
||||
if (backupTypes.covers) backupTypesList.push('COVERS');
|
||||
@@ -31,6 +33,7 @@ export default function AdminPage() {
|
||||
formData.append('action', 'BACKUP');
|
||||
backupTypesList.forEach(value => formData.append('backup_types', value));
|
||||
|
||||
// Streaming Fetch - The generated client buffers; the backup can be large, so this endpoint intentionally uses a raw streaming download.
|
||||
const response = await fetch('/api/v1/admin', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -40,94 +43,65 @@ export default function AdminPage() {
|
||||
throw new Error('Backup failed: ' + response.statusText);
|
||||
}
|
||||
|
||||
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
|
||||
const completed = await streamResponseToFile(response, {
|
||||
suggestedName: backupFilename(),
|
||||
mimeType: 'application/zip',
|
||||
extension: '.zip',
|
||||
});
|
||||
|
||||
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
|
||||
const writable = await handle.createWritable();
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Unable to read response');
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
showInfo('Backup completed successfully');
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
showError('Backup failed: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showError(
|
||||
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
|
||||
);
|
||||
if (completed) {
|
||||
showInfo('Backup completed successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Backup failed: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreSubmit = async (e: FormEvent) => {
|
||||
const handleRestoreSubmit = async (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (!restoreFile) return;
|
||||
|
||||
const startedToastId = showInfo('Restore started', 0);
|
||||
// Progress Toast - Restore is long-running; a persistent 'started' toast resolves in place into the result toast on completion.
|
||||
const toastId = showInfo('Restore started', 0);
|
||||
|
||||
try {
|
||||
const response = await postAdminAction.mutateAsync({
|
||||
await postAdminAction.mutateAsync({
|
||||
data: {
|
||||
action: 'RESTORE',
|
||||
restore_file: restoreFile,
|
||||
},
|
||||
});
|
||||
|
||||
removeToast(startedToastId);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Restore completed successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Restore failed: ' + getErrorMessage(response.data));
|
||||
updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
|
||||
} catch (error) {
|
||||
removeToast(startedToastId);
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
updateToast(toastId, {
|
||||
type: 'error',
|
||||
message: `Restore failed: ${getErrorMessage(error)}`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataMatch = () => {
|
||||
postAdminAction.mutate(
|
||||
{ data: { action: 'METADATA_MATCH' } },
|
||||
{
|
||||
onSuccess: () => showInfo('Metadata matching started'),
|
||||
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Metadata matching started',
|
||||
error: 'Failed to start metadata matching',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCacheTables = () => {
|
||||
postAdminAction.mutate(
|
||||
{ data: { action: 'CACHE_TABLES' } },
|
||||
{
|
||||
onSuccess: () => showInfo('Cache tables started'),
|
||||
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Cache tables started',
|
||||
error: 'Failed to start cache tables',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full grow flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
@@ -154,8 +128,8 @@ export default function AdminPage() {
|
||||
<label htmlFor="backup_documents">Documents</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 w-40">
|
||||
<Button variant="secondary" type="submit">
|
||||
<div className="w-40">
|
||||
<Button variant="secondary" type="submit" className="w-full">
|
||||
Backup
|
||||
</Button>
|
||||
</div>
|
||||
@@ -170,8 +144,8 @@ export default function AdminPage() {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-10 w-40">
|
||||
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
||||
<div className="w-40">
|
||||
<Button variant="secondary" type="submit" className="w-full" disabled={!restoreFile}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
@@ -180,35 +154,21 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Tasks</p>
|
||||
<table className="min-w-full bg-surface text-sm text-content">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="pl-0">
|
||||
<p>Metadata Matching</p>
|
||||
</td>
|
||||
<td className="float-right py-2">
|
||||
<div className="h-10 w-40 text-base">
|
||||
<Button variant="secondary" onClick={handleMetadataMatch}>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Cache Tables</p>
|
||||
</td>
|
||||
<td className="float-right py-2">
|
||||
<div className="h-10 w-40 text-base">
|
||||
<Button variant="secondary" onClick={handleCacheTables}>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mb-4 text-lg font-semibold text-content">Tasks</p>
|
||||
<ul className="flex flex-col gap-3 text-sm text-content">
|
||||
<li className="flex items-center justify-between gap-4">
|
||||
<p>Metadata Matching</p>
|
||||
<Button variant="secondary" onClick={handleMetadataMatch}>
|
||||
Run
|
||||
</Button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4">
|
||||
<p>Cache Tables</p>
|
||||
<Button variant="secondary" onClick={handleCacheTables}>
|
||||
Run
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,51 +1,146 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, SyntheticEvent } from 'react';
|
||||
import { LoadingState, TextInput } from '../components';
|
||||
import { Button } from '../components/Button';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import type { User, UsersResponse } from '../generated/model';
|
||||
import type { User } from '../generated/model';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
interface AddUserFormProps {
|
||||
onCreate: (_username: string, _password: string, _isAdmin: boolean) => void;
|
||||
}
|
||||
|
||||
function AddUserForm({ onCreate }: AddUserFormProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const handleSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
onCreate(username, password, isAdmin);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 text-sm text-content">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="p-2"
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="p-2"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="new_is_admin"
|
||||
checked={isAdmin}
|
||||
onChange={e => setIsAdmin(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="new_is_admin">Admin</label>
|
||||
</div>
|
||||
<Button type="submit">Create</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ResetPasswordDialogProps {
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
onSave: (_userId: string, _password: string) => void;
|
||||
}
|
||||
|
||||
function ResetPasswordDialog({ userId, onClose, onSave }: ResetPasswordDialogProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<form
|
||||
className="w-80 rounded bg-surface p-4 shadow-lg"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!password) return;
|
||||
onSave(userId, password);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<p className="mb-3 text-content">Reset password for {userId}</p>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="New password"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!password}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const permissionButtonClass = (active: boolean) =>
|
||||
`rounded-md px-2 py-1 ${
|
||||
active
|
||||
? 'cursor-default bg-content text-content-inverse'
|
||||
: 'cursor-pointer bg-surface-strong text-content'
|
||||
}`;
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||
const updateUser = useUpdateUser();
|
||||
const { showInfo, showError } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
const { showError } = useToasts();
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
|
||||
const users = usersData?.status === 200 ? ((usersData.data as UsersResponse).users ?? []) : [];
|
||||
const users = usersData?.users ?? [];
|
||||
|
||||
const handleCreateUser = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newUsername || !newPassword) return;
|
||||
const handleCreateUser = (username: string, password: string, isAdmin: boolean) => {
|
||||
if (!username || !password) {
|
||||
showError('Please enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
updateUser.mutate(
|
||||
{
|
||||
data: {
|
||||
operation: 'CREATE',
|
||||
user: newUsername,
|
||||
password: newPassword,
|
||||
is_admin: newIsAdmin,
|
||||
user: username,
|
||||
password,
|
||||
is_admin: isAdmin,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to create user: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo('User created successfully');
|
||||
toastMutationOptions({
|
||||
success: 'User created successfully',
|
||||
error: 'Failed to create user',
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false);
|
||||
setNewUsername('');
|
||||
setNewPassword('');
|
||||
setNewIsAdmin(false);
|
||||
refetch();
|
||||
},
|
||||
onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,18 +149,11 @@ export default function AdminUsersPage() {
|
||||
{
|
||||
data: { operation: 'DELETE', user: userId },
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to delete user: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo('User deleted successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'User deleted successfully',
|
||||
error: 'Failed to delete user',
|
||||
onSuccess: refetch,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,18 +164,11 @@ export default function AdminUsersPage() {
|
||||
{
|
||||
data: { operation: 'UPDATE', user: userId, password },
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to update password: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo('Password updated successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Password updated successfully',
|
||||
error: 'Failed to update password',
|
||||
onSuccess: refetch,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,144 +177,92 @@ export default function AdminUsersPage() {
|
||||
{
|
||||
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError('Failed to update admin status: ' + getErrorMessage(response.data));
|
||||
return;
|
||||
}
|
||||
|
||||
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
|
||||
refetch();
|
||||
},
|
||||
onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: `User permissions updated to ${isAdmin ? 'admin' : 'user'}`,
|
||||
error: 'Failed to update admin status',
|
||||
onSuccess: refetch,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const userColumns: Column<User>[] = [
|
||||
{
|
||||
id: 'actions',
|
||||
className: 'w-12',
|
||||
header: (
|
||||
<button onClick={() => setShowAddForm(!showAddForm)} aria-label="Add user">
|
||||
<AddIcon size={20} />
|
||||
</button>
|
||||
),
|
||||
render: user => (
|
||||
<button onClick={() => handleDeleteUser(user.id)} aria-label="Delete user">
|
||||
<DeleteIcon size={20} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ id: 'user', header: 'User', render: user => user.id },
|
||||
{
|
||||
id: 'password',
|
||||
header: 'Password',
|
||||
render: user => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setResetUserId(user.id);
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
header: 'Permissions',
|
||||
className: 'text-center',
|
||||
render: user => (
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, true)}
|
||||
disabled={user.admin}
|
||||
className={permissionButtonClass(user.admin)}
|
||||
>
|
||||
admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, false)}
|
||||
disabled={!user.admin}
|
||||
className={permissionButtonClass(!user.admin)}
|
||||
>
|
||||
user
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'created',
|
||||
header: 'Created',
|
||||
className: 'w-48',
|
||||
render: user => formatDate(user.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-x-auto">
|
||||
{showAddForm && (
|
||||
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
|
||||
<form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="bg-surface p-2 text-content"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="bg-surface p-2 text-content"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="new_is_admin"
|
||||
checked={newIsAdmin}
|
||||
onChange={e => setNewIsAdmin(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="new_is_admin">Admin</label>
|
||||
</div>
|
||||
<button
|
||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showAddForm && <AddUserForm onCreate={handleCreateUser} />}
|
||||
|
||||
<div className="min-w-full overflow-scroll rounded shadow">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase">
|
||||
<button onClick={() => setShowAddForm(!showAddForm)}>
|
||||
<AddIcon size={20} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">User</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">Password</th>
|
||||
<th className="border-b border-border p-3 text-center font-normal uppercase">
|
||||
Permissions
|
||||
</th>
|
||||
<th className="w-48 border-b border-border p-3 text-left font-normal uppercase">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={5}>
|
||||
No Results
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user: User) => (
|
||||
<tr key={user.id}>
|
||||
<td className="relative cursor-pointer border-b border-border p-3 text-content-muted">
|
||||
<button onClick={() => handleDeleteUser(user.id)}>
|
||||
<DeleteIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{user.id}</p>
|
||||
</td>
|
||||
<td className="border-b border-border px-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const password = prompt(`Enter new password for ${user.id}`);
|
||||
if (password) handleUpdatePassword(user.id, password);
|
||||
}}
|
||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</td>
|
||||
<td className="flex min-w-40 justify-center gap-2 border-b border-border p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, true)}
|
||||
disabled={user.admin}
|
||||
className={`rounded-md px-2 py-1 ${
|
||||
user.admin
|
||||
? 'cursor-default bg-content text-content-inverse'
|
||||
: 'cursor-pointer bg-surface-strong text-content'
|
||||
}`}
|
||||
>
|
||||
admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user.id, false)}
|
||||
disabled={!user.admin}
|
||||
className={`rounded-md px-2 py-1 ${
|
||||
!user.admin
|
||||
? 'cursor-default bg-content text-content-inverse'
|
||||
: 'cursor-pointer bg-surface-strong text-content'
|
||||
}`}
|
||||
>
|
||||
user
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{user.created_at}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Table columns={userColumns} data={users} rowKey="id" />
|
||||
|
||||
{resetUserId && (
|
||||
<ResetPasswordDialog
|
||||
userId={resetUserId}
|
||||
onClose={() => setResetUserId(null)}
|
||||
onSave={handleUpdatePassword}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SyntheticEvent, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../components/Button';
|
||||
import { TextInput } from '../components';
|
||||
|
||||
interface AuthFormViewProps {
|
||||
username: string;
|
||||
password: string;
|
||||
isLoading: boolean;
|
||||
onUsernameChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
inputsDisabled?: boolean;
|
||||
footer: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthFormView({
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
onUsernameChange,
|
||||
onPasswordChange,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
inputsDisabled = false,
|
||||
footer,
|
||||
}: AuthFormViewProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-content">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
<p className="text-center text-3xl">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<TextInput
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => onUsernameChange(e.target.value)}
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading || inputsDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<TextInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => onPasswordChange(e.target.value)}
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading || inputsDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={isLoading || inputsDisabled}
|
||||
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-hidden focus:ring-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="py-12 text-center">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
|
||||
<span className="text-content-muted">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function authFormFooter(
|
||||
primaryLink: { to: string; text: string },
|
||||
showPrimary: boolean
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{showPrimary && (
|
||||
<p>
|
||||
<Link to={primaryLink.to} className="font-semibold underline">
|
||||
{primaryLink.text}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<p className={showPrimary ? 'mt-4' : ''}>
|
||||
<a href="/local" className="font-semibold underline">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonButton,
|
||||
PageLoader,
|
||||
InlineLoader,
|
||||
} from '../components/Skeleton';
|
||||
|
||||
export default function ComponentDemoPage() {
|
||||
const { showInfo, showWarning, showError, showToast } = useToasts();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDemoClick = () => {
|
||||
setIsLoading(true);
|
||||
showInfo('Starting demo operation...');
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
showInfo('Demo operation completed successfully!');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleErrorClick = () => {
|
||||
showError('This is a sample error message');
|
||||
};
|
||||
|
||||
const handleWarningClick = () => {
|
||||
showWarning('This is a sample warning message', 10000);
|
||||
};
|
||||
|
||||
const handleCustomToast = () => {
|
||||
showToast('Custom toast message', 'info', 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4 text-content">
|
||||
<h1 className="text-2xl font-bold">UI Components Demo</h1>
|
||||
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Toast Notifications</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<button
|
||||
onClick={handleDemoClick}
|
||||
disabled={isLoading}
|
||||
className="rounded bg-secondary-500 px-4 py-2 text-secondary-foreground hover:bg-secondary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWarningClick}
|
||||
className="rounded bg-yellow-500 px-4 py-2 text-white hover:bg-yellow-600"
|
||||
>
|
||||
Show Warning Toast (10s)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleErrorClick}
|
||||
className="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
|
||||
>
|
||||
Show Error Toast
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCustomToast}
|
||||
className="rounded bg-primary-500 px-4 py-2 text-primary-foreground hover:bg-primary-600"
|
||||
>
|
||||
Show Custom Toast
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Skeleton Loading Components</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-content-muted">Basic Skeletons</h3>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton variant="text" className="w-3/4" />
|
||||
<Skeleton variant="text" className="w-1/2" />
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<Skeleton variant="rectangular" width={100} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-content-muted">Skeleton Text</h3>
|
||||
<SkeletonText lines={3} />
|
||||
<SkeletonText lines={5} className="max-w-md" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-content-muted">Skeleton Avatar</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<SkeletonAvatar size="sm" />
|
||||
<SkeletonAvatar size="md" />
|
||||
<SkeletonAvatar size="lg" />
|
||||
<SkeletonAvatar size={72} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-content-muted">Skeleton Button</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SkeletonButton width={120} />
|
||||
<SkeletonButton className="w-full max-w-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Skeleton Cards</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard showAvatar />
|
||||
<SkeletonCard showAvatar showTitle showText textLines={4} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Skeleton Table</h2>
|
||||
<SkeletonTable rows={5} columns={4} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Page Loader</h2>
|
||||
<PageLoader message="Loading demo content..." />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg bg-surface p-6 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Inline Loader</h2>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-center">
|
||||
<InlineLoader size="sm" />
|
||||
<p className="mt-2 text-sm text-content-muted">Small</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<InlineLoader size="md" />
|
||||
<p className="mt-2 text-sm text-content-muted">Medium</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<InlineLoader size="lg" />
|
||||
<p className="mt-2 text-sm text-content-muted">Large</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+144
-334
@@ -6,123 +6,151 @@ import {
|
||||
useEditDocument,
|
||||
getGetDocumentQueryKey,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import { Document } from '../generated/model/document';
|
||||
import type { EditDocumentBody } from '../generated/model';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import {
|
||||
DeleteIcon,
|
||||
ActivityIcon,
|
||||
SearchIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
InfoIcon,
|
||||
CloseIcon,
|
||||
CheckIcon,
|
||||
} from '../icons';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
||||
import { useToastMutation } from '../hooks/useMutationWithToast';
|
||||
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||
|
||||
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
||||
const popupClassName = 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||
const popupInputClassName = 'rounded bg-surface p-2 text-content';
|
||||
const editInputClassName =
|
||||
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
||||
|
||||
interface EditableFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
multiline?: boolean;
|
||||
valueClassName?: string;
|
||||
onSave: (value: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function EditableField({
|
||||
label,
|
||||
value,
|
||||
multiline = false,
|
||||
valueClassName,
|
||||
onSave,
|
||||
}: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(value);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
// Keep Editor Open On Failure - Only close once the save actually succeeds so a failed edit isn't silently lost.
|
||||
const confirm = async () => {
|
||||
if (await onSave(draft)) setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEdit}
|
||||
className={iconButtonClassName}
|
||||
aria-label={`Edit ${label.toLowerCase()}`}
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
className="h-32 w-full grow rounded border border-border bg-surface-muted p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
|
||||
rows={5}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
className="w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue className={valueClassName}>{value || 'N/A'}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '', {
|
||||
query: { enabled: Boolean(id) },
|
||||
});
|
||||
const editMutation = useEditDocument();
|
||||
const runWithToast = useToastMutation();
|
||||
|
||||
const [showEditCover, setShowEditCover] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showIdentify, setShowIdentify] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isEditingAuthor, setIsEditingAuthor] = useState(false);
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editAuthor, setEditAuthor] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
if (docLoading) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!docData || docData.status !== 200) {
|
||||
const doc = docData?.document;
|
||||
|
||||
if (!doc) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const document = docData.data.document as Document;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const percentage = document.percentage ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const percentage = doc.percentage ?? 0;
|
||||
const secondsPerPercent = doc.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
const startEditing = (field: 'title' | 'author' | 'description') => {
|
||||
if (field === 'title') setEditTitle(document.title);
|
||||
if (field === 'author') setEditAuthor(document.author);
|
||||
if (field === 'description') setEditDescription(document.description || '');
|
||||
};
|
||||
|
||||
const saveTitle = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { title: editTitle } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingTitle(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingTitle(false),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveAuthor = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { author: editAuthor } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingAuthor(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingAuthor(false),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveDescription = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { description: editDescription } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingDescription(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingDescription(false),
|
||||
}
|
||||
);
|
||||
};
|
||||
const save = (data: EditDocumentBody): Promise<boolean> =>
|
||||
runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
|
||||
error: 'Failed to save',
|
||||
onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
|
||||
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
||||
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
</label>
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={`${doc.title} cover`}
|
||||
/>
|
||||
|
||||
{document.filepath && (
|
||||
{doc.filepath && (
|
||||
<a
|
||||
href={`/reader/${document.id}`}
|
||||
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-white hover:bg-secondary-800 focus:outline-none focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700"
|
||||
href={`/reader/${doc.id}`}
|
||||
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
|
||||
>
|
||||
Read
|
||||
</a>
|
||||
@@ -132,134 +160,26 @@ export default function DocumentPage() {
|
||||
<div className="min-w-[50%] md:mr-2">
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-content-muted">ISBN-10:</p>
|
||||
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
||||
<p className="font-medium">{doc.isbn10 || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-content-muted">ISBN-13:</p>
|
||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-cover-checkbox"
|
||||
className="hidden"
|
||||
checked={showEditCover}
|
||||
onChange={e => setShowEditCover(e.target.checked)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 ${popupClassName} ${
|
||||
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||
<input type="file" id="cover_file" name="cover_file" className={popupInputClassName} />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Upload Cover
|
||||
</button>
|
||||
</form>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||
<input type="checkbox" checked id="remove_cover" name="remove_cover" className="hidden" />
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Remove Cover
|
||||
</button>
|
||||
</form>
|
||||
<p className="font-medium">{doc.isbn13 || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative my-auto flex grow justify-between text-content-muted">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(!showDelete)}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<DeleteIcon size={28} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="w-24 text-sm">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/activity?document=${document.id}`}
|
||||
href={`/activity?document=${doc.id}`}
|
||||
aria-label="Activity"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<ActivityIcon size={28} />
|
||||
</a>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIdentify(!showIdentify)}
|
||||
aria-label="Identify"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<SearchIcon size={28} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="flex flex-col gap-2 text-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="Title"
|
||||
defaultValue={document.title}
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="author"
|
||||
name="author"
|
||||
placeholder="Author"
|
||||
defaultValue={document.author}
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="isbn"
|
||||
name="isbn"
|
||||
placeholder="ISBN 10 / ISBN 13"
|
||||
defaultValue={document.isbn13 || document.isbn10}
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Identify
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.filepath ? (
|
||||
{doc.filepath ? (
|
||||
<a
|
||||
href={`/api/v1/documents/${document.id}/file`}
|
||||
href={`/api/v1/documents/${doc.id}/file`}
|
||||
aria-label="Download"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
@@ -275,87 +195,13 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||
<Field
|
||||
isEditing={isEditingTitle}
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Title</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditingTitle ? (
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setIsEditingTitle(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button type="button" onClick={saveTitle} className={iconButtonClassName} aria-label="Confirm edit">
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startEditing('title');
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit title"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)} className={editInputClassName} />
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.title}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
|
||||
|
||||
<Field
|
||||
isEditing={isEditingAuthor}
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Author</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditingAuthor ? (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsEditingAuthor(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button type="button" onClick={saveAuthor} className={iconButtonClassName} aria-label="Confirm edit">
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startEditing('author');
|
||||
setIsEditingAuthor(true);
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit author"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditingAuthor ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input type="text" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} className={editInputClassName} />
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.author}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Author"
|
||||
value={doc.author}
|
||||
onSave={value => save({ author: value })}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={
|
||||
@@ -370,17 +216,19 @@ export default function DocumentPage() {
|
||||
<InfoIcon size={18} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute right-0 top-7 z-30 ${popupClassName} ${
|
||||
className={`absolute right-0 top-7 z-30 rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200 ${
|
||||
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Seconds / Percent</p>
|
||||
<p className="font-medium">{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}</p>
|
||||
<p className="font-medium">
|
||||
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||
<p className="font-medium">{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}</p>
|
||||
<p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||
@@ -393,8 +241,8 @@ export default function DocumentPage() {
|
||||
}
|
||||
>
|
||||
<FieldValue>
|
||||
{document.total_time_seconds && document.total_time_seconds > 0
|
||||
? formatDuration(document.total_time_seconds)
|
||||
{doc.total_time_seconds && doc.total_time_seconds > 0
|
||||
? formatDuration(doc.total_time_seconds)
|
||||
: 'N/A'}
|
||||
</FieldValue>
|
||||
</Field>
|
||||
@@ -404,51 +252,13 @@ export default function DocumentPage() {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
isEditing={isEditingDescription}
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsEditingDescription(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button type="button" onClick={saveDescription} className={iconButtonClassName} aria-label="Confirm edit">
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startEditing('description');
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit description"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditingDescription ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue className="hyphens-auto text-justify">{document.description || 'N/A'}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={doc.description || ''}
|
||||
multiline
|
||||
valueClassName="hyphens-auto text-justify"
|
||||
onSave={value => save({ description: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,178 +1,127 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import type { Document } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
import { LoadingState, Pagination } from '../components';
|
||||
import { LoadingState, Pagination, TextInput, IconInput, SegmentedControl } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import {
|
||||
getDocumentsViewMode,
|
||||
setDocumentsViewMode,
|
||||
type DocumentsViewMode,
|
||||
} from '../utils/localSettings';
|
||||
import { cn } from '../utils/cn';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||
|
||||
interface DocumentCardProps {
|
||||
const DOCUMENTS_PAGE_SIZE = 9;
|
||||
|
||||
interface DocumentItemProps {
|
||||
doc: Document;
|
||||
layout: 'grid' | 'list';
|
||||
}
|
||||
|
||||
function DocumentCard({ doc }: DocumentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
const documentPath = `/documents/${doc.id}`;
|
||||
const title = doc.title || 'Unknown';
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-none"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative my-auto h-48 min-w-fit">
|
||||
const actions = (
|
||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
||||
<Link to={`/activity?document=${doc.id}`} aria-label={`View activity for ${title}`}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} aria-label={`Download ${title}`}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const fields = [
|
||||
{ label: 'Title', value: title, link: true },
|
||||
{ label: 'Author', value: doc.author || 'Unknown' },
|
||||
{ label: 'Progress', value: `${percentage}%` },
|
||||
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
|
||||
];
|
||||
|
||||
const fieldList = (
|
||||
<div
|
||||
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
|
||||
>
|
||||
{fields.map(field => (
|
||||
<div key={field.label}>
|
||||
<p className="text-content-subtle">{field.label}</p>
|
||||
{field.link ? (
|
||||
<Link to={documentPath} className="font-medium hover:underline">
|
||||
{field.value}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">{field.value}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="flex size-full gap-4 rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||
<Link to={documentPath} className="my-auto h-48 min-w-fit" aria-label={`Open ${title}`}>
|
||||
<img
|
||||
className="h-full rounded object-cover"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
alt={title}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex w-full flex-col justify-between gap-4">
|
||||
{fieldList}
|
||||
{actions}
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-content-subtle">Title</p>
|
||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-content-subtle">Author</p>
|
||||
<p className="font-medium">{doc.author || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-content-subtle">Progress</p>
|
||||
<p className="font-medium">{percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-content-subtle">Time Read</p>
|
||||
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{fieldList}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentListItemProps {
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentListItem({ doc }: DocumentListItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
function EmptyDocuments({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-none"
|
||||
onClick={() => navigate(`/documents/${doc.id}`)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigate(`/documents/${doc.id}`);
|
||||
}
|
||||
}}
|
||||
className={cn('rounded bg-surface p-6 text-center text-content-muted shadow-lg', className)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-content-subtle">Title</p>
|
||||
<p className="font-medium">{doc.title || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-content-subtle">Author</p>
|
||||
<p className="font-medium">{doc.author || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-content-subtle">Progress</p>
|
||||
<p className="font-medium">{percentage}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-content-subtle">Time Read</p>
|
||||
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
||||
<ActivityIcon size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
||||
<DownloadIcon size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<DownloadIcon size={20} disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
No documents found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(9);
|
||||
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
||||
const { page, setPage } = usePaginatedList(debouncedSearch);
|
||||
const limit = DOCUMENTS_PAGE_SIZE;
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showInfo, showWarning, showError } = useToasts();
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setDocumentsViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
const { showWarning } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
||||
const createMutation = useCreateDocument();
|
||||
const docs = (data?.data as DocumentsResponse | undefined)?.documents;
|
||||
const previousPage = (data?.data as DocumentsResponse | undefined)?.previous_page;
|
||||
const nextPage = (data?.data as DocumentsResponse | undefined)?.next_page;
|
||||
const documentsResponse = data;
|
||||
const docs = documentsResponse?.documents;
|
||||
const previousPage = documentsResponse?.previous_page;
|
||||
const nextPage = documentsResponse?.next_page;
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
const uploadDocument = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith('.epub')) {
|
||||
@@ -180,18 +129,17 @@ export default function DocumentsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
data: {
|
||||
document_file: file,
|
||||
createMutation.mutate(
|
||||
{ data: { document_file: file } },
|
||||
toastMutationOptions({
|
||||
success: 'Document uploaded successfully!',
|
||||
error: 'Failed to upload document',
|
||||
onSuccess: () => {
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
showInfo('Document uploaded successfully!');
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showError('Failed to upload document: ' + getErrorMessage(error));
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
@@ -201,48 +149,30 @@ export default function DocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getViewModeButtonClasses = (mode: DocumentsViewMode) =>
|
||||
`rounded px-3 py-1 text-sm font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-content text-content-inverse'
|
||||
: 'text-content-muted hover:bg-surface-muted'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Search Author / Title"
|
||||
name="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex rounded border border-border bg-surface p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={getViewModeButtonClasses('grid')}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={getViewModeButtonClasses('list')}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</IconInput>
|
||||
</div>
|
||||
<SegmentedControl<DocumentsViewMode>
|
||||
ariaLabel="Document view mode"
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
options={[
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'list', label: 'List' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,11 +181,9 @@ export default function DocumentsPage() {
|
||||
{isLoading ? (
|
||||
<LoadingState className="col-span-full min-h-48" />
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
|
||||
) : (
|
||||
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||
No documents found.
|
||||
</div>
|
||||
<EmptyDocuments className="col-span-full" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -263,11 +191,9 @@ export default function DocumentsPage() {
|
||||
{isLoading ? (
|
||||
<LoadingState className="min-h-48" />
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
|
||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
|
||||
) : (
|
||||
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||
No documents found.
|
||||
</div>
|
||||
<EmptyDocuments />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -276,59 +202,47 @@ export default function DocumentsPage() {
|
||||
page={page}
|
||||
previousPage={previousPage}
|
||||
nextPage={nextPage}
|
||||
total={(data?.data as DocumentsResponse | undefined)?.total}
|
||||
total={documentsResponse?.total}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="upload-file-button"
|
||||
className="hidden"
|
||||
checked={uploadMode}
|
||||
onChange={() => setUploadMode(!uploadMode)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200 ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
|
||||
>
|
||||
<form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".epub"
|
||||
id="document_file"
|
||||
name="document_file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{uploadMode && (
|
||||
<div className="absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200">
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".epub"
|
||||
id="document_file"
|
||||
name="document_file"
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
<button
|
||||
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
|
||||
type="button"
|
||||
onClick={() => uploadDocument(fileInputRef.current?.files?.[0])}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
handleFileChange({
|
||||
target: { files: fileInputRef.current?.files },
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</form>
|
||||
<label htmlFor="upload-file-button">
|
||||
<div
|
||||
type="button"
|
||||
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface"
|
||||
onClick={handleCancelUpload}
|
||||
>
|
||||
Cancel Upload
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMode(!uploadMode)}
|
||||
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
|
||||
htmlFor="upload-file-button"
|
||||
aria-label="Upload document"
|
||||
>
|
||||
<UploadIcon size={34} className="text-content-inverse" />
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { LoadingState, SegmentedControl } from '../components';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||
import type {
|
||||
HomeResponse,
|
||||
LeaderboardData,
|
||||
LeaderboardEntry,
|
||||
UserStreak,
|
||||
} from '../generated/model';
|
||||
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
|
||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||
|
||||
@@ -38,51 +34,39 @@ function InfoCard({ title, size, link }: InfoCardProps) {
|
||||
}
|
||||
|
||||
interface StreakCardProps {
|
||||
window: 'DAY' | 'WEEK';
|
||||
currentStreak: number;
|
||||
currentStreakStartDate: string;
|
||||
currentStreakEndDate: string;
|
||||
maxStreak: number;
|
||||
maxStreakStartDate: string;
|
||||
maxStreakEndDate: string;
|
||||
streak: UserStreak;
|
||||
}
|
||||
|
||||
function StreakCard({
|
||||
window,
|
||||
currentStreak,
|
||||
currentStreakStartDate,
|
||||
currentStreakEndDate,
|
||||
maxStreak,
|
||||
maxStreakStartDate,
|
||||
maxStreakEndDate,
|
||||
}: StreakCardProps) {
|
||||
function StreakCard({ streak }: StreakCardProps) {
|
||||
const isWeekly = streak.window === 'WEEK';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
{isWeekly ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
</p>
|
||||
<div className="my-6 flex items-end space-x-2">
|
||||
<p className="text-5xl font-bold">{currentStreak}</p>
|
||||
<p className="text-5xl font-bold">{streak.current_streak}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||
<p>{isWeekly ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-content-subtle">
|
||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||
{streak.current_streak_start_date} ➞ {streak.current_streak_end_date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{currentStreak}</div>
|
||||
<div className="flex items-end font-bold">{streak.current_streak}</div>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||
<p>{isWeekly ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-content-subtle">
|
||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||
{streak.max_streak_start_date} ➞ {streak.max_streak_end_date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{maxStreak}</div>
|
||||
<div className="flex items-end font-bold">{streak.max_streak}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,9 +99,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
|
||||
const currentData = data[selectedPeriod];
|
||||
|
||||
const getPeriodClassName = (period: TimePeriod) =>
|
||||
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||
@@ -126,20 +107,22 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
{name} Leaderboard
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
|
||||
all
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
|
||||
year
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
|
||||
month
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
|
||||
week
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedControl<TimePeriod>
|
||||
variant="unstyled"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
ariaLabel={`${name} leaderboard period`}
|
||||
value={selectedPeriod}
|
||||
onChange={setSelectedPeriod}
|
||||
buttonClassName="cursor-pointer"
|
||||
activeClassName="text-content"
|
||||
inactiveClassName="text-content-subtle hover:text-content"
|
||||
options={[
|
||||
{ value: 'all', label: 'all' },
|
||||
{ value: 'year', label: 'year' },
|
||||
{ value: 'month', label: 'month' },
|
||||
{ value: 'week', label: 'week' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +137,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
<div>
|
||||
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
key={item.user_id}
|
||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
|
||||
>
|
||||
<div>
|
||||
@@ -172,14 +155,14 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
export default function HomePage() {
|
||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||
|
||||
const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null;
|
||||
const homeResponse = homeData;
|
||||
const dbInfo = homeResponse?.database_info;
|
||||
const streaks = homeResponse?.streaks?.streaks;
|
||||
const graphData = homeResponse?.graph_data?.graph_data;
|
||||
const userStats = homeResponse?.user_statistics;
|
||||
|
||||
if (homeLoading) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -201,17 +184,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{streaks?.map((streak: UserStreak, index: number) => (
|
||||
<StreakCard
|
||||
key={index}
|
||||
window={streak.window as 'DAY' | 'WEEK'}
|
||||
currentStreak={streak.current_streak}
|
||||
currentStreakStartDate={streak.current_streak_start_date}
|
||||
currentStreakEndDate={streak.current_streak_end_date}
|
||||
maxStreak={streak.max_streak}
|
||||
maxStreakStartDate={streak.max_streak_start_date}
|
||||
maxStreakEndDate={streak.max_streak_end_date}
|
||||
/>
|
||||
{streaks?.map((streak: UserStreak) => (
|
||||
<StreakCard key={streak.window} streak={streak} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,16 +51,13 @@ describe('LoginPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
@@ -112,8 +109,8 @@ describe('LoginPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -127,7 +124,7 @@ describe('LoginPage', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid credentials');
|
||||
expect(showErrorMock).toHaveBeenCalledWith('bad credentials');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,10 +152,7 @@ describe('LoginPage', () => {
|
||||
it('shows the registration link only when registration is enabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
registration_enabled: true,
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
@@ -172,10 +166,7 @@ describe('LoginPage', () => {
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
|
||||
@@ -1,132 +1,14 @@
|
||||
import { useState, FormEvent, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface LoginPageViewProps {
|
||||
username: string;
|
||||
password: string;
|
||||
isLoading: boolean;
|
||||
registrationEnabled: boolean;
|
||||
onUsernameChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function getRegistrationEnabled(infoData: unknown): boolean {
|
||||
if (!infoData || typeof infoData !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('data' in infoData) || !infoData.data || typeof infoData.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!('registration_enabled' in infoData.data) ||
|
||||
typeof infoData.data.registration_enabled !== 'boolean'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return infoData.data.registration_enabled;
|
||||
}
|
||||
|
||||
export function LoginPageView({
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
registrationEnabled,
|
||||
onUsernameChange,
|
||||
onPasswordChange,
|
||||
onSubmit,
|
||||
}: LoginPageViewProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-content">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
<p className="text-center text-3xl">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => onUsernameChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => onPasswordChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-none focus:ring-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="py-12 text-center">
|
||||
{registrationEnabled && (
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-semibold underline">
|
||||
Register here.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<p className={registrationEnabled ? 'mt-4' : ''}>
|
||||
<a href="/local" className="font-semibold underline">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
|
||||
<span className="text-content-muted">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useAuthForm } from '../hooks/useAuthForm';
|
||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
||||
const { username, password, isLoading, registrationEnabled, setUsername, setPassword, submit } =
|
||||
useAuthForm('login');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
@@ -134,28 +16,17 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (_err) {
|
||||
showError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginPageView
|
||||
<AuthFormView
|
||||
username={username}
|
||||
password={password}
|
||||
isLoading={isLoading}
|
||||
registrationEnabled={registrationEnabled}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={submit}
|
||||
submitLabel="Login"
|
||||
submittingLabel="Logging in..."
|
||||
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { documentColumn } from '../components/documentColumn';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
const PROGRESS_PAGE_SIZE = 15;
|
||||
|
||||
export default function ProgressPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 15;
|
||||
const { page, setPage } = usePaginatedList();
|
||||
const limit = PROGRESS_PAGE_SIZE;
|
||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||
const response = data?.status === 200 ? data.data : undefined;
|
||||
const response = data;
|
||||
const progress = response?.progress ?? [];
|
||||
|
||||
const columns: Column<Progress>[] = [
|
||||
documentColumn,
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_value, row) => (
|
||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'device_name' as const,
|
||||
id: 'device_name',
|
||||
header: 'Device Name',
|
||||
render: value => String(value || 'Unknown'),
|
||||
render: row => row.device_name || 'Unknown',
|
||||
},
|
||||
{
|
||||
key: 'percentage' as const,
|
||||
id: 'percentage',
|
||||
header: 'Percentage',
|
||||
render: value => (typeof value === 'number' ? `${Math.round(value)}%` : '0%'),
|
||||
render: row => `${Math.round(row.percentage)}%`,
|
||||
},
|
||||
{
|
||||
key: 'created_at' as const,
|
||||
id: 'created_at',
|
||||
header: 'Created At',
|
||||
render: value =>
|
||||
typeof value === 'string' && value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
render: row => formatDate(row.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,41 +2,45 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||
import { LoadingState } from '../components/LoadingState';
|
||||
import { SegmentedControl } from '../components/SegmentedControl';
|
||||
import { CloseIcon } from '../icons';
|
||||
import {
|
||||
getReaderColorScheme,
|
||||
getReaderDevice,
|
||||
getReaderFontFamily,
|
||||
getReaderFontSize,
|
||||
setReaderColorScheme,
|
||||
setReaderFontFamily,
|
||||
setReaderFontSize,
|
||||
READER_COLOR_SCHEMES,
|
||||
READER_FONT_FAMILIES,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
getReaderDevice,
|
||||
useLocalSetting,
|
||||
} from '../utils/localSettings';
|
||||
import { useEpubReader } from '../hooks/useEpubReader';
|
||||
|
||||
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
|
||||
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
|
||||
const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
|
||||
const READER_SEGMENT_INACTIVE =
|
||||
'border-border text-content-muted hover:bg-surface-muted hover:text-content';
|
||||
|
||||
export default function ReaderPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
||||
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
||||
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
|
||||
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
|
||||
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
|
||||
const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
|
||||
const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
|
||||
const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
|
||||
|
||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
||||
|
||||
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '');
|
||||
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '', {
|
||||
query: { enabled: Boolean(id) },
|
||||
});
|
||||
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
|
||||
query: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: Boolean(id),
|
||||
},
|
||||
});
|
||||
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
|
||||
const progress = progressResponse?.status === 200 ? progressResponse.data.progress : undefined;
|
||||
const doc = documentResponse?.document;
|
||||
const progress = progressResponse?.progress;
|
||||
|
||||
const deviceId = defaultDeviceId;
|
||||
const deviceName = defaultDeviceName;
|
||||
@@ -74,28 +78,27 @@ export default function ReaderPage() {
|
||||
colorScheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
isPaginationDisabled: useCallback(() => isTopBarOpen || isBottomBarOpen, [isTopBarOpen, isBottomBarOpen]),
|
||||
isPaginationDisabled: useCallback(
|
||||
() => isTopBarOpen || isBottomBarOpen,
|
||||
[isTopBarOpen, isBottomBarOpen]
|
||||
),
|
||||
onSwipeDown: handleSwipeDown,
|
||||
onSwipeUp: handleSwipeUp,
|
||||
onCenterTap: handleCenterTap,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (document?.title) {
|
||||
window.document.title = `AnthoLume - Reader - ${document.title}`;
|
||||
if (doc?.title) {
|
||||
document.title = `AnthoLume - Reader - ${doc.title}`;
|
||||
}
|
||||
}, [document?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
reader.setTheme({ colorScheme, fontFamily, fontSize });
|
||||
}, [colorScheme, fontFamily, fontSize, reader.setTheme]);
|
||||
}, [doc?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTopBarOpen || isBottomBarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = window.document.activeElement;
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export default function ReaderPage() {
|
||||
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
|
||||
}
|
||||
|
||||
if (!id || !document || documentResponse?.status !== 200) {
|
||||
if (!id || !doc) {
|
||||
return <div className="p-6 text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
@@ -120,24 +123,24 @@ export default function ReaderPage() {
|
||||
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Link to={`/documents/${document.id}`} className="block shrink-0">
|
||||
<Link to={`/documents/${doc.id}`} className="block shrink-0">
|
||||
<img
|
||||
className="h-28 w-20 rounded object-cover shadow"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
className="h-28 w-20 rounded object-cover shadow-sm"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={`${doc.title} cover`}
|
||||
/>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
|
||||
<p className="truncate text-lg font-semibold text-content">{document.title}</p>
|
||||
<p className="truncate text-lg font-semibold text-content">{doc.title}</p>
|
||||
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
|
||||
<p className="truncate text-sm text-content-muted">{document.author}</p>
|
||||
<p className="truncate text-sm text-content-muted">{doc.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/documents/${document.id}`}
|
||||
to={`/documents/${doc.id}`}
|
||||
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
Back
|
||||
@@ -216,49 +219,35 @@ export default function ReaderPage() {
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Theme</p>
|
||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{colorSchemes.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColorSchemeState(option);
|
||||
setReaderColorScheme(option);
|
||||
}}
|
||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
||||
colorScheme === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
||||
Theme
|
||||
</p>
|
||||
<SegmentedControl<ReaderColorScheme>
|
||||
variant="unstyled"
|
||||
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
||||
ariaLabel="Reader theme"
|
||||
value={colorScheme}
|
||||
onChange={setColorScheme}
|
||||
buttonClassName={`${READER_SEGMENT_BUTTON} capitalize`}
|
||||
activeClassName={READER_SEGMENT_ACTIVE}
|
||||
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||
options={READER_COLOR_SCHEMES.map(value => ({ value, label: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
||||
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{fontFamilies.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFontFamilyState(option);
|
||||
setReaderFontFamily(option);
|
||||
}}
|
||||
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
|
||||
fontFamily === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl<ReaderFontFamily>
|
||||
variant="unstyled"
|
||||
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
||||
ariaLabel="Reader font"
|
||||
value={fontFamily}
|
||||
onChange={setFontFamily}
|
||||
buttonClassName={READER_SEGMENT_BUTTON}
|
||||
activeClassName={READER_SEGMENT_ACTIVE}
|
||||
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||
options={READER_FONT_FAMILIES.map(value => ({ value, label: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -268,11 +257,7 @@ export default function ReaderPage() {
|
||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
onClick={() => setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
-
|
||||
@@ -282,11 +267,7 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
onClick={() => setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
+
|
||||
|
||||
@@ -51,16 +51,13 @@ describe('RegisterPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
registration_enabled: true,
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
@@ -113,8 +110,8 @@ describe('RegisterPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -128,7 +125,7 @@ describe('RegisterPage', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Registration failed');
|
||||
expect(showErrorMock).toHaveBeenCalledWith('failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,10 +153,7 @@ describe('RegisterPage', () => {
|
||||
it('redirects to login when registration is disabled', async () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
@@ -178,10 +172,7 @@ describe('RegisterPage', () => {
|
||||
it('disables the form when registration is disabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { useState, FormEvent, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuthForm } from '../hooks/useAuthForm';
|
||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { register, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled =
|
||||
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
|
||||
? infoData.data.registration_enabled
|
||||
: false;
|
||||
const {
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
isLoadingInfo,
|
||||
registrationEnabled,
|
||||
setUsername,
|
||||
setPassword,
|
||||
submit,
|
||||
} = useAuthForm('register');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
@@ -35,82 +29,18 @@ export default function RegisterPage() {
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await register(username, password);
|
||||
} catch (_err) {
|
||||
showError(registrationEnabled ? 'Registration failed' : 'Registration is disabled');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-content">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
<p className="text-center text-3xl">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-none focus:ring-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Registering...' : 'Register'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="py-12 text-center">
|
||||
<p>
|
||||
Trying to login?{' '}
|
||||
<Link to="/login" className="font-semibold underline">
|
||||
Login here.
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<a href="/local" className="font-semibold underline">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
|
||||
<span className="text-content-muted">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthFormView
|
||||
username={username}
|
||||
password={password}
|
||||
isLoading={isLoading}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={submit}
|
||||
submitLabel="Register"
|
||||
submittingLabel="Registering..."
|
||||
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
||||
footer={authFormFooter({ to: '/login', text: 'Login here.' }, true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,7 @@ describe('SearchPage', () => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [],
|
||||
},
|
||||
results: [],
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
@@ -50,9 +47,12 @@ describe('SearchPage', () => {
|
||||
isLoading: true,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
render(<SearchPage />);
|
||||
const { container } = render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
// Loading renders the Table skeleton (animated placeholder rows), and the search form stays mounted.
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
expect(screen.getByPlaceholderText('Query')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no results', () => {
|
||||
@@ -64,20 +64,16 @@ describe('SearchPage', () => {
|
||||
it('renders search results from the generated hook response', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
author: 'Ursula Le Guin',
|
||||
title: 'A Wizard of Earthsea',
|
||||
series: 'Earthsea',
|
||||
file_type: 'epub',
|
||||
file_size: '1 MB',
|
||||
upload_date: '2025-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
results: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
author: 'Ursula Le Guin',
|
||||
title: 'A Wizard of Earthsea',
|
||||
series: 'Earthsea',
|
||||
file_type: 'epub',
|
||||
file_size: '1 MB',
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useState, SyntheticEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import type { SearchItem } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState } from '../components';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Button, Table, type Column, TextInput, IconInput } from '../components';
|
||||
import { inputClassName } from '../components/TextInput';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon, BookIcon } from '../icons';
|
||||
|
||||
const searchColumns: Column<SearchItem>[] = [
|
||||
{
|
||||
id: 'document',
|
||||
header: 'Document',
|
||||
render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}`,
|
||||
},
|
||||
{ id: 'series', header: 'Series', render: item => item.series || 'N/A' },
|
||||
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
|
||||
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
|
||||
];
|
||||
|
||||
interface SearchPageViewProps {
|
||||
query: string;
|
||||
@@ -14,27 +25,7 @@ interface SearchPageViewProps {
|
||||
results: SearchItem[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSourceChange: (value: GetSearchSource) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
export function getSearchResults(data: unknown): SearchItem[] {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('status' in data) || data.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('data' in data) || !data.data || typeof data.data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('results' in data.data) || !Array.isArray(data.data.results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data.results as SearchItem[];
|
||||
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
export function SearchPageView({
|
||||
@@ -52,119 +43,40 @@ export function SearchPageView({
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<input
|
||||
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => onQueryChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Query"
|
||||
/>
|
||||
</div>
|
||||
</IconInput>
|
||||
</div>
|
||||
<div className="relative flex min-w-[12em]">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<BookIcon size={15} />
|
||||
</span>
|
||||
<IconInput className="min-w-[12em]" icon={<BookIcon size={15} />}>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</IconInput>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content md:text-sm">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase"></th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Document
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Series
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Size
|
||||
</th>
|
||||
<th className="hidden border-b border-border p-3 text-left font-normal uppercase md:table-cell">
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={6}>
|
||||
<LoadingState />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && results.length === 0 && (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={6}>
|
||||
No Results
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading &&
|
||||
results.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="border-b border-border p-3 text-content-muted">
|
||||
<button className="hover:text-primary-600" title="Download">
|
||||
<DownloadIcon size={15} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
{item.author || 'N/A'} - {item.title || 'N/A'}
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{item.series || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{item.file_type || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{item.file_size || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="hidden border-b border-border p-3 md:table-cell">
|
||||
<p>{item.upload_date || 'N/A'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Table columns={searchColumns} data={results} loading={isLoading} rowKey="id" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeQuery, setActiveQuery] = useState('');
|
||||
const [query, setQuery, activeQuery, flushQuery] = useDebouncedState('', 300);
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveQuery(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const { data, isLoading } = useGetSearch(
|
||||
{ query: activeQuery, source },
|
||||
@@ -174,11 +86,11 @@ export default function SearchPage() {
|
||||
},
|
||||
}
|
||||
);
|
||||
const results = getSearchResults(data);
|
||||
const results = data?.results ?? [];
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setActiveQuery(query.trim());
|
||||
flushQuery(query.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+203
-255
@@ -1,12 +1,29 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useState, useEffect, SyntheticEvent } from 'react';
|
||||
import { Button, LoadingState, Table, type Column, TextInput, IconInput } from '../components';
|
||||
import { inputClassName } from '../components/TextInput';
|
||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||
import type { Device, SettingsResponse } from '../generated/model';
|
||||
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
|
||||
const deviceColumns: Column<Device>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
className: 'pl-0',
|
||||
render: device => device.device_name || 'Unknown',
|
||||
},
|
||||
{
|
||||
id: 'last_synced',
|
||||
header: 'Last Sync',
|
||||
render: device => formatDateTime(device.last_synced),
|
||||
},
|
||||
{ id: 'created_at', header: 'Created', render: device => formatDateTime(device.created_at) },
|
||||
];
|
||||
|
||||
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
|
||||
{ value: 'light', label: 'Light', description: 'Always use the light palette.' },
|
||||
@@ -14,15 +31,171 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
|
||||
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
|
||||
];
|
||||
|
||||
function ProfileCard({ settings }: { settings?: SettingsResponse }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
||||
<UserIcon size={60} />
|
||||
<p className="text-lg text-content">{settings?.user.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordSection({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (password: string, next: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (await onSubmit(password, newPassword)) {
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex grow flex-col">
|
||||
<IconInput icon={<PasswordIcon size={15} />}>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
</IconInput>
|
||||
</div>
|
||||
<div className="flex grow flex-col">
|
||||
<IconInput icon={<PasswordIcon size={15} />}>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="New Password"
|
||||
/>
|
||||
</IconInput>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
|
||||
<p>
|
||||
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => {
|
||||
const isSelected = themeMode === mode.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode.value)}
|
||||
className={`rounded border p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
|
||||
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-content">{mode.label}</span>
|
||||
<span
|
||||
className={`inline-flex size-4 rounded-full border ${
|
||||
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">{mode.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimezoneSection({
|
||||
timezone,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
timezone: string;
|
||||
onChange: (timezone: string) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const handleSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<IconInput className="grow" icon={<ClockIcon size={15} />}>
|
||||
<select
|
||||
value={timezone || 'UTC'}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="America/Chicago">America/Chicago</option>
|
||||
<option value="America/Denver">America/Denver</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||
</select>
|
||||
</IconInput>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesSection({ devices }: { devices: Device[] }) {
|
||||
return (
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Devices</p>
|
||||
<Table columns={deviceColumns} data={devices} rowKey="id" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
|
||||
const { showInfo, showError } = useToasts();
|
||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
||||
const settingsData = data;
|
||||
const { showError } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
const runWithToast = useToastMutation();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [timezone, setTimezone] = useState('UTC');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,268 +204,43 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
const handlePasswordSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const updatePassword = async (password: string, newPassword: string) => {
|
||||
if (!password || !newPassword) {
|
||||
showError('Please enter both current and new password');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateSettings.mutateAsync({
|
||||
data: {
|
||||
password,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Password updated successfully');
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
return;
|
||||
return runWithToast(
|
||||
() => updateSettings.mutateAsync({ data: { password, new_password: newPassword } }),
|
||||
{
|
||||
success: 'Password updated successfully',
|
||||
error: 'Failed to update password',
|
||||
}
|
||||
|
||||
showError('Failed to update password: ' + getErrorMessage(response.data));
|
||||
} catch (error) {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleTimezoneSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await updateSettings.mutateAsync({
|
||||
data: {
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Timezone updated successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Failed to update timezone: ' + getErrorMessage(response.data));
|
||||
} catch (error) {
|
||||
showError('Failed to update timezone: ' + getErrorMessage(error));
|
||||
}
|
||||
const updateTimezone = () => {
|
||||
updateSettings.mutate(
|
||||
{ data: { timezone } },
|
||||
toastMutationOptions({
|
||||
success: 'Timezone updated successfully',
|
||||
error: 'Failed to update timezone',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
|
||||
<div className="mb-4 size-16 rounded-full bg-surface-strong" />
|
||||
<div className="h-6 w-32 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||
<div className="flex gap-4">
|
||||
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-10 w-40 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||
<div className="flex gap-4">
|
||||
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-10 w-40 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => (
|
||||
<div key={mode.value} className="h-24 rounded bg-surface-strong" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
|
||||
<div className="mb-4 h-6 w-24 rounded bg-surface-strong" />
|
||||
<div className="mb-4 flex gap-4">
|
||||
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||
</div>
|
||||
<div className="h-32 flex-1 rounded bg-surface-strong" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
||||
<UserIcon size={60} />
|
||||
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileCard settings={settingsData} />
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<PasswordIcon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<PasswordIcon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
placeholder="New Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
|
||||
<p>
|
||||
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => {
|
||||
const isSelected = themeMode === mode.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode.value)}
|
||||
className={`rounded border p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
|
||||
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-content">{mode.label}</span>
|
||||
<span
|
||||
className={`inline-flex size-4 rounded-full border ${
|
||||
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">{mode.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
||||
<div className="relative flex grow">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||
<ClockIcon size={15} />
|
||||
</span>
|
||||
<select
|
||||
value={timezone || 'UTC'}
|
||||
onChange={e => setTimezone(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="America/Chicago">America/Chicago</option>
|
||||
<option value="America/Denver">America/Denver</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<Button variant="secondary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Devices</p>
|
||||
<table className="min-w-full bg-surface text-sm">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="border-b border-border p-3 pl-0 text-left font-normal uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Last Sync
|
||||
</th>
|
||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-content">
|
||||
{!settingsData?.devices || settingsData.devices.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={3}>
|
||||
No Results
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
settingsData.devices.map((device: Device) => (
|
||||
<tr key={device.id}>
|
||||
<td className="p-3 pl-0">
|
||||
<p>{device.device_name || 'Unknown'}</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p>
|
||||
{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p>
|
||||
{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PasswordSection onSubmit={updatePassword} />
|
||||
<AppearanceSection />
|
||||
<TimezoneSection timezone={timezone} onChange={setTimezone} onSubmit={updateTimezone} />
|
||||
<DevicesSection devices={settingsData?.devices ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { getThemeMode, setThemeMode, type ThemeMode } from '../utils/localSettings';
|
||||
import {
|
||||
useLocalSetting,
|
||||
readLocalSetting,
|
||||
type ThemeMode,
|
||||
} from '../utils/localSettings';
|
||||
|
||||
export type ResolvedThemeMode = 'light' | 'dark';
|
||||
|
||||
@@ -44,72 +47,44 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
||||
}
|
||||
|
||||
export function initializeThemeMode(): ResolvedThemeMode {
|
||||
return applyThemeMode(getThemeMode());
|
||||
return applyThemeMode(readLocalSetting('themeMode', 'system'));
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
|
||||
const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
|
||||
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
|
||||
resolveThemeMode(getThemeMode())
|
||||
resolveThemeMode(themeMode)
|
||||
);
|
||||
|
||||
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just calls `setThemeMode`.
|
||||
useEffect(() => {
|
||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
||||
}, [themeModeState]);
|
||||
setResolvedThemeMode(applyThemeMode(themeMode));
|
||||
}, [themeMode]);
|
||||
|
||||
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeMode` is unchanged.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (typeof window === 'undefined' || themeMode !== 'system') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
if (themeModeState === 'system') {
|
||||
setResolvedThemeMode(applyThemeMode('system'));
|
||||
}
|
||||
setResolvedThemeMode(applyThemeMode('system'));
|
||||
};
|
||||
|
||||
mediaQueryList.addEventListener('change', handleSystemThemeChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
||||
};
|
||||
}, [themeModeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== 'antholume:settings') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextThemeMode = getThemeMode();
|
||||
setThemeModeState(nextThemeMode);
|
||||
setResolvedThemeMode(applyThemeMode(nextThemeMode));
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
|
||||
setThemeMode(nextThemeMode);
|
||||
setThemeModeState(nextThemeMode);
|
||||
setResolvedThemeMode(applyThemeMode(nextThemeMode));
|
||||
}, []);
|
||||
}, [themeMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode: themeModeState,
|
||||
themeMode,
|
||||
resolvedThemeMode,
|
||||
setThemeMode: updateThemeMode,
|
||||
setThemeMode,
|
||||
}),
|
||||
[resolvedThemeMode, themeModeState, updateThemeMode]
|
||||
[resolvedThemeMode, themeMode, setThemeMode]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ErrorResponse } from '../generated/model';
|
||||
|
||||
// Thrown by the generated API client for any non-2xx response. `body` is the parsed error payload
|
||||
// (the API's ErrorResponse for documented failures); `message` is the server-provided message when
|
||||
// present, so `error.message` is always display-ready.
|
||||
export class ApiError<TBody = ErrorResponse> extends Error {
|
||||
readonly status: number;
|
||||
readonly body: TBody;
|
||||
|
||||
constructor(status: number, body: TBody, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
|
||||
function messageFromBody(body: unknown, fallback: string): string {
|
||||
if (body && typeof body === 'object' && 'message' in body) {
|
||||
const { message } = body as { message?: unknown };
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<unknown> {
|
||||
if (EMPTY_BODY_STATUSES.has(response.status)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
return contentType.includes('application/json') ? JSON.parse(raw) : raw;
|
||||
}
|
||||
|
||||
// Orval mutator - The generated client calls this for every request. Returns the parsed success
|
||||
// body directly and throws ApiError on non-2xx, so React Query surfaces failures via isError/onError.
|
||||
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, options);
|
||||
const body = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body, messageFromBody(body, response.statusText || 'Request failed'));
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
interface StreamToFileOptions {
|
||||
suggestedName: string;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a response body to a user-chosen file. Uses the File System Access API when
|
||||
* available (large-file friendly, no memory buffering); otherwise falls back to a
|
||||
* blob/object-URL download. Returns whether the download completed (false if the user
|
||||
* cancelled or the browser lacks any download capability).
|
||||
*/
|
||||
export async function streamResponseToFile(
|
||||
response: Response,
|
||||
{ suggestedName, mimeType, extension }: StreamToFileOptions
|
||||
): Promise<boolean> {
|
||||
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName,
|
||||
types: [{ description: 'File', accept: { [mimeType]: [extension] } }],
|
||||
});
|
||||
|
||||
const writable = await handle.createWritable();
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Unable to read response');
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// User-cancelled picker resolves as AbortError; treat as "no download".
|
||||
if ((err as Error).name === 'AbortError') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = suggestedName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function backupFilename(): string {
|
||||
return `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
|
||||
}
|
||||
@@ -6,25 +6,8 @@ describe('getErrorMessage', () => {
|
||||
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
||||
});
|
||||
|
||||
it('prefers response.data.message over top-level message', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
response: {
|
||||
data: {
|
||||
message: 'Response message',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe('Response message');
|
||||
});
|
||||
|
||||
it('falls back to top-level message when response.data.message is unavailable', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
})
|
||||
).toBe('Top-level message');
|
||||
it('reads the top-level message from API error bodies', () => {
|
||||
expect(getErrorMessage({ code: 401, message: 'Unauthorized' })).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('uses the fallback for null, empty, and unknown values', () => {
|
||||
@@ -32,17 +15,5 @@ describe('getErrorMessage', () => {
|
||||
expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message');
|
||||
expect(
|
||||
getErrorMessage(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback message'
|
||||
)
|
||||
).toBe('Fallback message');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
// Extracts a display message from a caught error. The generated client throws ApiError (an Error
|
||||
// subclass whose `message` is the server-provided message), so this covers both API and unexpected
|
||||
// failures in a single catch path.
|
||||
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const errorWithResponse = error as {
|
||||
message?: unknown;
|
||||
response?: {
|
||||
data?: {
|
||||
message?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const responseMessage = errorWithResponse.response?.data?.message;
|
||||
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
if (typeof errorWithResponse.message === 'string' && errorWithResponse.message.trim() !== '') {
|
||||
return errorWithResponse.message;
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
const { message } = error as { message?: unknown };
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatNumber, formatDuration } from './formatters';
|
||||
import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('formats zero', () => {
|
||||
@@ -33,7 +33,6 @@ describe('formatNumber', () => {
|
||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
@@ -61,5 +60,21 @@ describe('formatDuration', () => {
|
||||
it('formats days, hours, minutes, and seconds', () => {
|
||||
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('formatDate / formatDateTime', () => {
|
||||
it('returns N/A for empty or unparseable values', () => {
|
||||
expect(formatDate(undefined)).toBe('N/A');
|
||||
expect(formatDate('')).toBe('N/A');
|
||||
expect(formatDate('not-a-date')).toBe('N/A');
|
||||
expect(formatDateTime(undefined)).toBe('N/A');
|
||||
expect(formatDateTime('not-a-date')).toBe('N/A');
|
||||
});
|
||||
|
||||
it('formats a valid ISO timestamp to a non-empty, non-N/A string', () => {
|
||||
const date = formatDate('2023-06-15T12:00:00Z');
|
||||
expect(date).not.toBe('N/A');
|
||||
expect(date.length).toBeGreaterThan(0);
|
||||
expect(formatDateTime('2023-06-15T12:00:00Z')).not.toBe('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,3 +70,33 @@ export function formatDuration(seconds: number): string {
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function toValidDate(value?: string): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// Local Display Dates - Shared formatters for user-facing timestamps so every list/table renders
|
||||
// them the same way, returning 'N/A' for empty or unparseable values.
|
||||
export function formatDate(value?: string): string {
|
||||
const date = toValidDate(value);
|
||||
return date ? date.toLocaleDateString() : 'N/A';
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string): string {
|
||||
const date = toValidDate(value);
|
||||
return date ? date.toLocaleString() : 'N/A';
|
||||
}
|
||||
|
||||
// UTC Date - Intentionally UTC (not local): the reading graph buckets activity by UTC day, so
|
||||
// converting to local time could shift a point to the wrong day.
|
||||
export function formatUtcDate(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}`;
|
||||
}
|
||||
|
||||
+102
-100
@@ -1,25 +1,38 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type DocumentsViewMode = 'grid' | 'list';
|
||||
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||
// Value arrays are the single source of truth; the union types derive from them so the
|
||||
// runtime iteration lists (reader theme picker, nav, etc.) and the schema can't drift.
|
||||
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
export type ThemeMode = (typeof THEME_MODES)[number];
|
||||
|
||||
interface LocalSettings {
|
||||
themeMode?: ThemeMode;
|
||||
documentsViewMode?: DocumentsViewMode;
|
||||
readerColorScheme?: ReaderColorScheme;
|
||||
readerFontFamily?: ReaderFontFamily;
|
||||
readerFontSize?: number;
|
||||
readerDeviceId?: string;
|
||||
readerDeviceName?: string;
|
||||
export const DOCUMENTS_VIEW_MODES = ['grid', 'list'] as const;
|
||||
export type DocumentsViewMode = (typeof DOCUMENTS_VIEW_MODES)[number];
|
||||
|
||||
export const READER_COLOR_SCHEMES = ['light', 'tan', 'blue', 'gray', 'black'] as const;
|
||||
export type ReaderColorScheme = (typeof READER_COLOR_SCHEMES)[number];
|
||||
|
||||
export const READER_FONT_FAMILIES = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'] as const;
|
||||
export type ReaderFontFamily = (typeof READER_FONT_FAMILIES)[number];
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||
|
||||
interface LocalSettingsMap {
|
||||
themeMode: ThemeMode;
|
||||
documentsViewMode: DocumentsViewMode;
|
||||
readerColorScheme: ReaderColorScheme;
|
||||
readerFontFamily: ReaderFontFamily;
|
||||
readerFontSize: number;
|
||||
readerDeviceId: string;
|
||||
readerDeviceName: string;
|
||||
}
|
||||
|
||||
type LocalSettingKey = keyof LocalSettingsMap;
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function readLocalSettings(): LocalSettings {
|
||||
function readRawSettings(): Record<string, unknown> {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
@@ -37,111 +50,100 @@ function readLocalSettings(): LocalSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalSettings(settings: LocalSettings): void {
|
||||
function writeRawSettings(settings: Record<string, unknown>): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function updateLocalSettings(partialSettings: LocalSettings): void {
|
||||
writeLocalSettings({
|
||||
...readLocalSettings(),
|
||||
...partialSettings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getThemeMode(): ThemeMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.themeMode === 'light' || settings.themeMode === 'dark'
|
||||
? settings.themeMode
|
||||
: 'system';
|
||||
}
|
||||
|
||||
export function setThemeMode(themeMode: ThemeMode): void {
|
||||
updateLocalSettings({ themeMode });
|
||||
}
|
||||
|
||||
export function getDocumentsViewMode(): DocumentsViewMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.documentsViewMode === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
|
||||
updateLocalSettings({ documentsViewMode });
|
||||
}
|
||||
|
||||
export function getReaderColorScheme(): ReaderColorScheme {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerColorScheme) {
|
||||
case 'light':
|
||||
case 'tan':
|
||||
case 'blue':
|
||||
case 'gray':
|
||||
case 'black':
|
||||
return settings.readerColorScheme;
|
||||
function isValidValue(key: LocalSettingKey, value: unknown): boolean {
|
||||
switch (key) {
|
||||
case 'themeMode':
|
||||
return (THEME_MODES as readonly unknown[]).includes(value);
|
||||
case 'documentsViewMode':
|
||||
return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
|
||||
case 'readerColorScheme':
|
||||
return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
|
||||
case 'readerFontFamily':
|
||||
return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
|
||||
case 'readerFontSize':
|
||||
return typeof value === 'number' && value > 0;
|
||||
case 'readerDeviceId':
|
||||
case 'readerDeviceName':
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
default:
|
||||
return 'tan';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
|
||||
updateLocalSettings({ readerColorScheme });
|
||||
export function readLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
): LocalSettingsMap[K] {
|
||||
const value = readRawSettings()[key];
|
||||
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
|
||||
}
|
||||
|
||||
export function getReaderFontFamily(): ReaderFontFamily {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerFontFamily) {
|
||||
case 'Serif':
|
||||
case 'Open Sans':
|
||||
case 'Arbutus Slab':
|
||||
case 'Lato':
|
||||
return settings.readerFontFamily;
|
||||
default:
|
||||
return 'Serif';
|
||||
}
|
||||
export function writeLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
value: LocalSettingsMap[K]
|
||||
): void {
|
||||
writeRawSettings({ ...readRawSettings(), [key]: value });
|
||||
}
|
||||
|
||||
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
|
||||
updateLocalSettings({ readerFontFamily });
|
||||
}
|
||||
|
||||
export function getReaderFontSize(): number {
|
||||
const settings = readLocalSettings();
|
||||
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
|
||||
? settings.readerFontSize
|
||||
: 1;
|
||||
}
|
||||
|
||||
export function setReaderFontSize(readerFontSize: number): void {
|
||||
updateLocalSettings({ readerFontSize });
|
||||
/**
|
||||
* Stateful accessor for a single localStorage-backed setting. Persists on change and re-reads
|
||||
* on cross-tab `storage` events. Validation rejects stale/invalid stored values in favor of
|
||||
* `defaultValue`, so callers never need their own get/set/validate pair.
|
||||
*/
|
||||
export function useLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
) {
|
||||
const [value, setValue] = useState<LocalSettingsMap[K]>(() =>
|
||||
readLocalSetting(key, defaultValue)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
|
||||
return;
|
||||
}
|
||||
setValue(readLocalSetting(key, defaultValue));
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, [key, defaultValue]);
|
||||
|
||||
const setValuePersisted = useCallback(
|
||||
(next: LocalSettingsMap[K]) => {
|
||||
writeLocalSetting(key, next);
|
||||
setValue(next);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
return [value, setValuePersisted] as const;
|
||||
}
|
||||
|
||||
// Reader Device - First-run UUID registration is a read side-effect that doesn't fit a value hook.
|
||||
export function getReaderDevice(): { id: string; name: string } {
|
||||
const settings = readLocalSettings();
|
||||
const id =
|
||||
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
|
||||
? settings.readerDeviceId
|
||||
: crypto.randomUUID();
|
||||
const name =
|
||||
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
|
||||
? settings.readerDeviceName
|
||||
: 'Web Reader';
|
||||
const raw = readRawSettings();
|
||||
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
|
||||
? (raw.readerDeviceId as string)
|
||||
: crypto.randomUUID();
|
||||
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
|
||||
? (raw.readerDeviceName as string)
|
||||
: 'Web Reader';
|
||||
|
||||
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id,
|
||||
readerDeviceName: name,
|
||||
});
|
||||
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
|
||||
writeLocalSetting('readerDeviceId', id);
|
||||
writeLocalSetting('readerDeviceName', name);
|
||||
}
|
||||
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export function setReaderDevice(name: string, id?: string): void {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id ?? crypto.randomUUID(),
|
||||
readerDeviceName: name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
const withOpacity = cssVariable => `rgb(var(${cssVariable}) / <alpha-value>)`;
|
||||
|
||||
const buildScale = scaleName => ({
|
||||
50: withOpacity(`--${scaleName}-50`),
|
||||
100: withOpacity(`--${scaleName}-100`),
|
||||
200: withOpacity(`--${scaleName}-200`),
|
||||
300: withOpacity(`--${scaleName}-300`),
|
||||
400: withOpacity(`--${scaleName}-400`),
|
||||
500: withOpacity(`--${scaleName}-500`),
|
||||
600: withOpacity(`--${scaleName}-600`),
|
||||
700: withOpacity(`--${scaleName}-700`),
|
||||
800: withOpacity(`--${scaleName}-800`),
|
||||
900: withOpacity(`--${scaleName}-900`),
|
||||
DEFAULT: withOpacity(`--${scaleName}-500`),
|
||||
foreground: withOpacity(`--${scaleName}-foreground`),
|
||||
});
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: withOpacity('--canvas'),
|
||||
surface: withOpacity('--surface'),
|
||||
'surface-muted': withOpacity('--surface-muted'),
|
||||
'surface-strong': withOpacity('--surface-strong'),
|
||||
overlay: withOpacity('--overlay'),
|
||||
content: withOpacity('--content'),
|
||||
'content-muted': withOpacity('--content-muted'),
|
||||
'content-subtle': withOpacity('--content-subtle'),
|
||||
'content-inverse': withOpacity('--content-inverse'),
|
||||
border: withOpacity('--border'),
|
||||
'border-muted': withOpacity('--border-muted'),
|
||||
'border-strong': withOpacity('--border-strong'),
|
||||
white: withOpacity('--white'),
|
||||
black: withOpacity('--black'),
|
||||
gray: buildScale('neutral'),
|
||||
purple: buildScale('primary'),
|
||||
blue: buildScale('secondary'),
|
||||
yellow: buildScale('warning'),
|
||||
red: buildScale('error'),
|
||||
primary: buildScale('primary'),
|
||||
secondary: buildScale('secondary'),
|
||||
tertiary: buildScale('tertiary'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
allowedHosts: ['lin-va-terminal'],
|
||||
proxy: {
|
||||
|
||||
@@ -37,7 +37,6 @@ type SearchItem struct {
|
||||
Series string
|
||||
FileType string
|
||||
FileSize string
|
||||
UploadDate string
|
||||
}
|
||||
|
||||
type searchFunc func(query string) (searchResults []SearchItem, err error)
|
||||
|
||||
Reference in New Issue
Block a user