Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09daf6d511 | |||
| 9158648f3f | |||
| 1ff96ee9c8 | |||
| 457eb550e4 | |||
| 5a8b773a0b | |||
| c9c9563a1f | |||
| 232a821dc0 | |||
| 6231befaa8 |
@@ -25,6 +25,8 @@ Regenerate:
|
|||||||
- `cd frontend && pnpm run generate:api`
|
- `cd frontend && pnpm run generate:api`
|
||||||
|
|
||||||
Notes:
|
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.
|
- 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:
|
Examples of generated files:
|
||||||
|
|||||||
+1
-9
@@ -68,18 +68,10 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
|||||||
|
|
||||||
apiActivities := make([]Activity, len(activities))
|
apiActivities := make([]Activity, len(activities))
|
||||||
for i, a := range 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{
|
apiActivities[i] = Activity{
|
||||||
DocumentId: a.DocumentID,
|
DocumentId: a.DocumentID,
|
||||||
DeviceId: a.DeviceID,
|
DeviceId: a.DeviceID,
|
||||||
StartTime: startTimeStr,
|
StartTime: parseTimeAny(a.StartTime),
|
||||||
Title: a.Title,
|
Title: a.Title,
|
||||||
Author: a.Author,
|
Author: a.Author,
|
||||||
Duration: a.Duration,
|
Duration: a.Duration,
|
||||||
|
|||||||
+10
-11
@@ -152,7 +152,7 @@ type Activity struct {
|
|||||||
EndPercentage float32 `json:"end_percentage"`
|
EndPercentage float32 `json:"end_percentage"`
|
||||||
ReadPercentage float32 `json:"read_percentage"`
|
ReadPercentage float32 `json:"read_percentage"`
|
||||||
StartPercentage float32 `json:"start_percentage"`
|
StartPercentage float32 `json:"start_percentage"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +200,10 @@ type DatabaseInfo struct {
|
|||||||
|
|
||||||
// Device defines model for Device.
|
// Device defines model for Device.
|
||||||
type Device struct {
|
type Device struct {
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeviceName *string `json:"device_name,omitempty"`
|
DeviceName string `json:"device_name"`
|
||||||
Id *string `json:"id,omitempty"`
|
Id string `json:"id"`
|
||||||
LastSynced *time.Time `json:"last_synced,omitempty"`
|
LastSynced time.Time `json:"last_synced"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DirectoryItem defines model for DirectoryItem.
|
// DirectoryItem defines model for DirectoryItem.
|
||||||
@@ -357,14 +357,14 @@ type OperationType string
|
|||||||
// Progress defines model for Progress.
|
// Progress defines model for Progress.
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
Author *string `json:"author,omitempty"`
|
Author *string `json:"author,omitempty"`
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeviceId *string `json:"device_id,omitempty"`
|
DeviceId *string `json:"device_id,omitempty"`
|
||||||
DeviceName *string `json:"device_name,omitempty"`
|
DeviceName string `json:"device_name"`
|
||||||
DocumentId *string `json:"document_id,omitempty"`
|
DocumentId string `json:"document_id"`
|
||||||
Percentage *float64 `json:"percentage,omitempty"`
|
Percentage float64 `json:"percentage"`
|
||||||
Progress *string `json:"progress,omitempty"`
|
Progress *string `json:"progress,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
UserId *string `json:"user_id,omitempty"`
|
UserId string `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressListResponse defines model for ProgressListResponse.
|
// ProgressListResponse defines model for ProgressListResponse.
|
||||||
@@ -391,7 +391,6 @@ type SearchItem struct {
|
|||||||
Language *string `json:"language,omitempty"`
|
Language *string `json:"language,omitempty"`
|
||||||
Series *string `json:"series,omitempty"`
|
Series *string `json:"series,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
UploadDate *string `json:"upload_date,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResponse defines model for SearchResponse.
|
// SearchResponse defines model for SearchResponse.
|
||||||
|
|||||||
+12
-2
@@ -106,6 +106,12 @@ components:
|
|||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
required:
|
||||||
|
- device_name
|
||||||
|
- percentage
|
||||||
|
- document_id
|
||||||
|
- user_id
|
||||||
|
- created_at
|
||||||
|
|
||||||
UpdateProgressRequest:
|
UpdateProgressRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -198,6 +204,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
start_time:
|
start_time:
|
||||||
type: string
|
type: string
|
||||||
|
format: date-time
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
author:
|
author:
|
||||||
@@ -240,8 +247,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
file_size:
|
file_size:
|
||||||
type: string
|
type: string
|
||||||
upload_date:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
SearchResponse:
|
SearchResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -384,6 +389,11 @@ components:
|
|||||||
last_synced:
|
last_synced:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- device_name
|
||||||
|
- created_at
|
||||||
|
- last_synced
|
||||||
|
|
||||||
SettingsResponse:
|
SettingsResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+10
-10
@@ -68,11 +68,11 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
|
|||||||
apiProgress[i] = Progress{
|
apiProgress[i] = Progress{
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Author: row.Author,
|
Author: row.Author,
|
||||||
DeviceName: &row.DeviceName,
|
DeviceName: row.DeviceName,
|
||||||
Percentage: &row.Percentage,
|
Percentage: row.Percentage,
|
||||||
DocumentId: &row.DocumentID,
|
DocumentId: row.DocumentID,
|
||||||
UserId: &row.UserID,
|
UserId: row.UserID,
|
||||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
CreatedAt: parseTimeAny(row.CreatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,13 +105,13 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiProgress := Progress{
|
apiProgress := Progress{
|
||||||
DeviceName: &row.DeviceName,
|
DeviceName: row.DeviceName,
|
||||||
DeviceId: &row.DeviceID,
|
DeviceId: &row.DeviceID,
|
||||||
Percentage: &row.Percentage,
|
Percentage: row.Percentage,
|
||||||
Progress: &row.Progress,
|
Progress: &row.Progress,
|
||||||
DocumentId: &row.DocumentID,
|
DocumentId: row.DocumentID,
|
||||||
UserId: &row.UserID,
|
UserId: row.UserID,
|
||||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
CreatedAt: parseTime(row.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
response := ProgressResponse{
|
response := ProgressResponse{
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject)
|
|||||||
Series: ptrOf(item.Series),
|
Series: ptrOf(item.Series),
|
||||||
FileType: ptrOf(item.FileType),
|
FileType: ptrOf(item.FileType),
|
||||||
FileSize: ptrOf(item.FileSize),
|
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))
|
apiDevices := make([]Device, len(devices))
|
||||||
for i, device := range devices {
|
for i, device := range devices {
|
||||||
apiDevices[i] = Device{
|
apiDevices[i] = Device{
|
||||||
Id: &device.ID,
|
Id: device.ID,
|
||||||
DeviceName: &device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
CreatedAt: parseTimeAny(device.CreatedAt),
|
||||||
LastSynced: parseTimePtr(device.LastSynced),
|
LastSynced: parseTimeAny(device.LastSynced),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +140,10 @@ func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsReque
|
|||||||
apiDevices := make([]Device, len(devices))
|
apiDevices := make([]Device, len(devices))
|
||||||
for i, device := range devices {
|
for i, device := range devices {
|
||||||
apiDevices[i] = Device{
|
apiDevices[i] = Device{
|
||||||
Id: &device.ID,
|
Id: device.ID,
|
||||||
DeviceName: &device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
CreatedAt: parseTimeAny(device.CreatedAt),
|
||||||
LastSynced: parseTimePtr(device.LastSynced),
|
LastSynced: parseTimeAny(device.LastSynced),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ func parseTime(s string) time.Time {
|
|||||||
return t
|
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
|
// parseTimePtr parses an interface{} (from SQL) to *time.Time
|
||||||
func parseTimePtr(v interface{}) *time.Time {
|
func parseTimePtr(v interface{}) *time.Time {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
|
|||||||
+10
-4
@@ -26,10 +26,14 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
- Nav items and page titles come from `src/components/navigation.ts` (`navItems`, `adminNavItems`, `getPageTitle`) — add routes there, not in `Layout`/`HamburgerMenu`.
|
- 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.
|
- 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.
|
- 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 (the single loading convention); avoid early returns that unmount search/filter forms during fetches.
|
- 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.
|
- 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`.
|
- 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.
|
- 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
|
## 3) Generated API client
|
||||||
|
|
||||||
@@ -39,9 +43,10 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
|
|
||||||
### Important behavior
|
### Important behavior
|
||||||
|
|
||||||
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
- The generated client returns the documented **success body directly** and throws `ApiError` (`src/utils/apiFetch.ts`) for non-2xx responses.
|
||||||
- Do not assume non-2xx responses throw.
|
- Use React Query's native error flow (`isError`, `error`, `onError`) instead of status narrowing.
|
||||||
- Check `response.status` and response shape before treating a request as successful.
|
- 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
|
## 4) Auth / Query State
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
- Run frontend tests with `pnpm run test`.
|
- 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.
|
- `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.
|
- 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
|
## 7) Live Dev Server Debugging
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ export default defineConfig({
|
|||||||
target: 'src/generated',
|
target: 'src/generated',
|
||||||
schemas: 'src/generated/model',
|
schemas: 'src/generated/model',
|
||||||
client: 'react-query',
|
client: 'react-query',
|
||||||
|
httpClient: 'fetch',
|
||||||
mock: false,
|
mock: false,
|
||||||
override: {
|
override: {
|
||||||
useQuery: true,
|
fetch: {
|
||||||
mutations: true,
|
includeHttpResponseReturnType: false,
|
||||||
|
},
|
||||||
|
mutator: {
|
||||||
|
path: './src/utils/apiFetch.ts',
|
||||||
|
name: 'apiFetch',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.16",
|
"@tanstack/react-query": "^5.62.16",
|
||||||
"ajv": "^8.18.0",
|
|
||||||
"axios": "^1.13.6",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
|
|||||||
+19
-94
@@ -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 Layout from './components/Layout';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import DocumentsPage from './pages/DocumentsPage';
|
import DocumentsPage from './pages/DocumentsPage';
|
||||||
@@ -16,108 +16,33 @@ import AdminUsersPage from './pages/AdminUsersPage';
|
|||||||
import AdminLogsPage from './pages/AdminLogsPage';
|
import AdminLogsPage from './pages/AdminLogsPage';
|
||||||
import ReaderPage from './pages/ReaderPage';
|
import ReaderPage from './pages/ReaderPage';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
|
import { AdminRoute } from './auth/AdminRoute';
|
||||||
|
|
||||||
export function Routes() {
|
export function Routes() {
|
||||||
return (
|
return (
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
<Route path="/" element={<Layout />}>
|
|
||||||
<Route
|
<Route
|
||||||
index
|
path="/"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<HomePage />
|
<Layout />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<Route
|
<Route index element={<HomePage />} />
|
||||||
path="documents"
|
<Route path="documents" element={<DocumentsPage />} />
|
||||||
element={
|
<Route path="documents/:id" element={<DocumentPage />} />
|
||||||
<ProtectedRoute>
|
<Route path="progress" element={<ProgressPage />} />
|
||||||
<DocumentsPage />
|
<Route path="activity" element={<ActivityPage />} />
|
||||||
</ProtectedRoute>
|
<Route path="search" element={<SearchPage />} />
|
||||||
}
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
/>
|
<Route path="admin" element={<AdminRoute><Outlet /></AdminRoute>}>
|
||||||
<Route
|
<Route index element={<AdminPage />} />
|
||||||
path="documents/:id"
|
<Route path="import" element={<AdminImportPage />} />
|
||||||
element={
|
<Route path="import-results" element={<AdminImportResultsPage />} />
|
||||||
<ProtectedRoute>
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<DocumentPage />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
</ProtectedRoute>
|
</Route>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="progress"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ProgressPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="activity"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ActivityPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="search"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SearchPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="settings"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SettingsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Admin routes */}
|
|
||||||
<Route
|
|
||||||
path="admin"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/import"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminImportPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/import-results"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminImportResultsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/users"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminUsersPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="admin/logs"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminLogsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="/reader/:id"
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -10,10 +10,8 @@ import {
|
|||||||
} from '../generated/anthoLumeAPIV1';
|
} from '../generated/anthoLumeAPIV1';
|
||||||
import {
|
import {
|
||||||
type AuthState,
|
type AuthState,
|
||||||
getAuthenticatedAuthState,
|
|
||||||
getUnauthenticatedAuthState,
|
getUnauthenticatedAuthState,
|
||||||
resolveAuthStateFromMe,
|
resolveAuthStateFromMe,
|
||||||
authUserFromMutation,
|
|
||||||
} from './authHelpers';
|
} from './authHelpers';
|
||||||
|
|
||||||
interface AuthContextType extends AuthState {
|
interface AuthContextType extends AuthState {
|
||||||
@@ -24,15 +22,9 @@ interface AuthContextType extends AuthState {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
const initialAuthState: AuthState = {
|
const unauthenticatedState = getUnauthenticatedAuthState();
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
isCheckingAuth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
|
||||||
|
|
||||||
const loginMutation = useLogin();
|
const loginMutation = useLogin();
|
||||||
const registerMutation = useRegister();
|
const registerMutation = useRegister();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
@@ -42,40 +34,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
const authState = useMemo(
|
||||||
setAuthState(prev =>
|
() =>
|
||||||
resolveAuthStateFromMe({
|
resolveAuthStateFromMe({
|
||||||
meData,
|
meData,
|
||||||
meError,
|
meError,
|
||||||
meLoading,
|
meLoading,
|
||||||
previousState: prev,
|
previousState: unauthenticatedState,
|
||||||
})
|
}),
|
||||||
|
[meData, meError, meLoading]
|
||||||
);
|
);
|
||||||
}, [meData, meError, meLoading]);
|
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (username: string, password: string) => {
|
async (username: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await loginMutation.mutateAsync({
|
const user = await loginMutation.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = authUserFromMutation(response);
|
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||||
if (!user) {
|
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
|
||||||
throw new Error('Login failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState(getAuthenticatedAuthState(user));
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
queryClient.setQueryData(getGetMeQueryKey(), undefined);
|
||||||
throw new Error('Login failed');
|
throw error instanceof Error ? error : new Error('Login failed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loginMutation, navigate, queryClient]
|
[loginMutation, navigate, queryClient]
|
||||||
@@ -84,26 +69,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (username: string, password: string) => {
|
async (username: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await registerMutation.mutateAsync({
|
const user = await registerMutation.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = authUserFromMutation(response);
|
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||||
if (!user) {
|
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
|
||||||
throw new Error('Registration failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState(getAuthenticatedAuthState(user));
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
queryClient.setQueryData(getGetMeQueryKey(), undefined);
|
||||||
throw new Error('Registration failed');
|
throw error instanceof Error ? error : new Error('Registration failed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, queryClient, registerMutation]
|
[navigate, queryClient, registerMutation]
|
||||||
@@ -111,9 +89,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
logoutMutation.mutate(undefined, {
|
logoutMutation.mutate(undefined, {
|
||||||
onSuccess: async () => {
|
onSettled: () => {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
queryClient.clear();
|
||||||
queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
getCheckingAuthState,
|
getCheckingAuthState,
|
||||||
getUnauthenticatedAuthState,
|
getUnauthenticatedAuthState,
|
||||||
resolveAuthStateFromMe,
|
resolveAuthStateFromMe,
|
||||||
authUserFromMutation,
|
|
||||||
type AuthState,
|
type AuthState,
|
||||||
} from './authHelpers';
|
} from './authHelpers';
|
||||||
|
|
||||||
@@ -31,11 +30,7 @@ describe('authHelpers', () => {
|
|||||||
it('resolves auth state from a successful /auth/me response', () => {
|
it('resolves auth state from a successful /auth/me response', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveAuthStateFromMe({
|
resolveAuthStateFromMe({
|
||||||
meData: {
|
meData: { username: 'evan', is_admin: false },
|
||||||
status: 200,
|
|
||||||
data: { username: 'evan', is_admin: false },
|
|
||||||
headers: new Headers(),
|
|
||||||
},
|
|
||||||
meError: undefined,
|
meError: undefined,
|
||||||
meLoading: false,
|
meLoading: false,
|
||||||
previousState,
|
previousState,
|
||||||
@@ -47,20 +42,7 @@ describe('authHelpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
it('resolves auth state to unauthenticated when the me query errors (e.g. 401)', () => {
|
||||||
expect(
|
|
||||||
resolveAuthStateFromMe({
|
|
||||||
meData: {
|
|
||||||
status: 401,
|
|
||||||
data: { code: 401, message: 'unauthorized' },
|
|
||||||
headers: new Headers(),
|
|
||||||
},
|
|
||||||
meError: undefined,
|
|
||||||
meLoading: false,
|
|
||||||
previousState,
|
|
||||||
})
|
|
||||||
).toEqual(getUnauthenticatedAuthState());
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveAuthStateFromMe({
|
resolveAuthStateFromMe({
|
||||||
meData: undefined,
|
meData: undefined,
|
||||||
@@ -108,32 +90,4 @@ describe('authHelpers', () => {
|
|||||||
isCheckingAuth: false,
|
isCheckingAuth: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts the user from successful login and register responses', () => {
|
|
||||||
expect(
|
|
||||||
authUserFromMutation({
|
|
||||||
status: 200,
|
|
||||||
data: { username: 'evan', is_admin: false },
|
|
||||||
headers: new Headers(),
|
|
||||||
})
|
|
||||||
).toEqual({ username: 'evan', is_admin: false });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
authUserFromMutation({
|
|
||||||
status: 201,
|
|
||||||
data: { username: 'evan', is_admin: true },
|
|
||||||
headers: new Headers(),
|
|
||||||
})
|
|
||||||
).toEqual({ username: 'evan', is_admin: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for unsuccessful auth mutation responses', () => {
|
|
||||||
expect(
|
|
||||||
authUserFromMutation({
|
|
||||||
status: 401,
|
|
||||||
data: { code: 401, message: 'unauthorized' },
|
|
||||||
headers: new Headers(),
|
|
||||||
})
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import type {
|
|
||||||
getMeResponse,
|
|
||||||
loginResponse,
|
|
||||||
registerResponse,
|
|
||||||
} from '../generated/anthoLumeAPIV1';
|
|
||||||
import type { LoginResponse } from '../generated/model';
|
import type { LoginResponse } from '../generated/model';
|
||||||
|
|
||||||
export type AuthUser = LoginResponse;
|
export type AuthUser = LoginResponse;
|
||||||
@@ -38,7 +33,7 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAuthStateFromMe(params: {
|
export function resolveAuthStateFromMe(params: {
|
||||||
meData?: getMeResponse;
|
meData?: LoginResponse;
|
||||||
meError?: unknown;
|
meError?: unknown;
|
||||||
meLoading: boolean;
|
meLoading: boolean;
|
||||||
previousState: AuthState;
|
previousState: AuthState;
|
||||||
@@ -49,11 +44,11 @@ export function resolveAuthStateFromMe(params: {
|
|||||||
return getCheckingAuthState(previousState);
|
return getCheckingAuthState(previousState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meData?.status === 200) {
|
if (meData) {
|
||||||
return getAuthenticatedAuthState(meData.data);
|
return getAuthenticatedAuthState(meData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meError || meData?.status === 401) {
|
if (meError) {
|
||||||
return getUnauthenticatedAuthState();
|
return getUnauthenticatedAuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +57,3 @@ export function resolveAuthStateFromMe(params: {
|
|||||||
isCheckingAuth: false,
|
isCheckingAuth: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authUserFromMutation(
|
|
||||||
response: loginResponse | registerResponse
|
|
||||||
): AuthUser | null {
|
|
||||||
return response.status === 200 || response.status === 201 ? response.data : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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, forwardRef } from 'react';
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
interface BaseButtonProps {
|
interface BaseButtonProps {
|
||||||
variant?: 'default' | 'secondary';
|
variant?: 'default' | 'secondary';
|
||||||
@@ -10,7 +11,7 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
|
|
||||||
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
|
||||||
const baseClass =
|
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') {
|
if (variant === 'secondary') {
|
||||||
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
|
||||||
@@ -20,9 +21,9 @@ const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
({ variant = 'default', children, className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
<button ref={ref} className={cn(getVariantClasses(variant), className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,37 @@ import { SettingsIcon, GitIcon } from '../icons';
|
|||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||||
import { navItems, adminNavItems } from './navigation';
|
import { navItems, adminNavItems } from './navigation';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
function hasPrefix(path: string, prefix: string): boolean {
|
function hasPrefix(path: string, prefix: string): boolean {
|
||||||
return path.startsWith(prefix);
|
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() {
|
export default function HamburgerMenu() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -20,82 +46,47 @@ export default function HamburgerMenu() {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const version =
|
const version = infoData?.version ?? 'v1.0.0';
|
||||||
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
const closeMenu = () => setIsOpen(false);
|
||||||
? infoData.data.version
|
|
||||||
: 'v1.0.0';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-40 ml-6 flex flex-col">
|
<div className="relative z-40 ml-6 flex flex-col">
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
type="button"
|
||||||
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
|
className="relative z-50 flex size-8 items-center justify-center lg:hidden"
|
||||||
id="mobile-nav-checkbox"
|
aria-label="Toggle navigation"
|
||||||
checked={isOpen}
|
aria-expanded={isOpen}
|
||||||
onChange={e => setIsOpen(e.target.checked)}
|
aria-controls="mobile-navigation"
|
||||||
/>
|
onClick={() => setIsOpen(open => !open)}
|
||||||
|
>
|
||||||
<span
|
<NavToggleIcon isOpen={isOpen} />
|
||||||
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
</button>
|
||||||
style={{
|
|
||||||
transformOrigin: '5px 0px',
|
|
||||||
transition:
|
|
||||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
|
||||||
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
|
||||||
style={{
|
|
||||||
transformOrigin: '0% 100%',
|
|
||||||
transition:
|
|
||||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
|
||||||
opacity: isOpen ? 0 : 1,
|
|
||||||
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
|
||||||
style={{
|
|
||||||
transformOrigin: '0% 0%',
|
|
||||||
transition:
|
|
||||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
|
||||||
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="menu"
|
id="mobile-navigation"
|
||||||
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48"
|
className={cn(
|
||||||
style={{
|
'fixed -ml-6 h-full w-56 bg-surface shadow-lg transition-transform duration-200 lg:w-48 lg:translate-x-0',
|
||||||
top: 0,
|
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
paddingTop: 'env(safe-area-inset-top)',
|
)}
|
||||||
transformOrigin: '0% 0%',
|
style={{ top: 0, paddingTop: 'env(safe-area-inset-top)' }}
|
||||||
transform: isOpen ? 'none' : 'translate(-100%, 0)',
|
|
||||||
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<style>{`
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
#menu {
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div className="flex h-16 justify-end lg:justify-around">
|
<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>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
{navItems.map(item => (
|
{navItems.map(item => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={closeMenu}
|
||||||
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
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
|
location.pathname === item.path
|
||||||
? 'border-primary-500 text-content'
|
? 'border-primary-500 text-content'
|
||||||
: 'border-transparent text-content-subtle hover:text-content'
|
: 'border-transparent text-content-subtle hover:text-content'
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon size={20} />
|
<item.icon size={20} />
|
||||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||||
@@ -104,20 +95,22 @@ export default function HamburgerMenu() {
|
|||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div
|
<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')
|
hasPrefix(location.pathname, '/admin')
|
||||||
? 'border-primary-500 text-content'
|
? 'border-primary-500 text-content'
|
||||||
: 'border-transparent text-content-subtle'
|
: 'border-transparent text-content-subtle'
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={closeMenu}
|
||||||
className={`flex w-full justify-start ${
|
className={cn(
|
||||||
|
'flex w-full justify-start',
|
||||||
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
||||||
? 'text-content'
|
? 'text-content'
|
||||||
: 'text-content-subtle hover:text-content'
|
: 'text-content-subtle hover:text-content'
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<SettingsIcon size={20} />
|
<SettingsIcon size={20} />
|
||||||
<span className="mx-4 text-sm font-normal">Admin</span>
|
<span className="mx-4 text-sm font-normal">Admin</span>
|
||||||
@@ -129,13 +122,13 @@ export default function HamburgerMenu() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={closeMenu}
|
||||||
className={`flex w-full justify-start ${
|
className={cn(
|
||||||
|
'flex w-full justify-start pl-7',
|
||||||
location.pathname === item.path
|
location.pathname === item.path
|
||||||
? 'text-content'
|
? 'text-content'
|
||||||
: 'text-content-subtle hover:text-content'
|
: 'text-content-subtle hover:text-content'
|
||||||
}`}
|
)}
|
||||||
style={{ paddingLeft: '1.75em' }}
|
|
||||||
>
|
>
|
||||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||||
</Link>
|
</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,23 +1,17 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
|
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { useGetMe } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { UserIcon, DropdownIcon } from '../icons';
|
import { UserIcon, DropdownIcon } from '../icons';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
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';
|
import HamburgerMenu from './HamburgerMenu';
|
||||||
import { getPageTitle } from './navigation';
|
import { getPageTitle } from './navigation';
|
||||||
|
|
||||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { themeMode, setThemeMode } = useTheme();
|
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 [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -45,14 +39,6 @@ export default function Layout() {
|
|||||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||||
}, [currentPageTitle]);
|
}, [currentPageTitle]);
|
||||||
|
|
||||||
if (isCheckingAuth) {
|
|
||||||
return <div className="text-content-muted">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="min-h-screen bg-canvas">
|
||||||
<div className="flex h-16 w-full items-center justify-between">
|
<div className="flex h-16 w-full items-center justify-between">
|
||||||
@@ -76,30 +62,17 @@ export default function Layout() {
|
|||||||
{isUserDropdownOpen && (
|
{isUserDropdownOpen && (
|
||||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
<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="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||||
<div
|
<div className="border-b border-border px-4 py-3">
|
||||||
className="border-b border-border px-4 py-3"
|
|
||||||
role="group"
|
|
||||||
aria-label="Theme mode"
|
|
||||||
>
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
<SegmentedControl
|
||||||
{themeModes.map(mode => (
|
className="w-full"
|
||||||
<button
|
ariaLabel="Theme mode"
|
||||||
key={mode}
|
value={themeMode}
|
||||||
type="button"
|
onChange={setThemeMode}
|
||||||
onClick={() => setThemeMode(mode)}
|
options={THEME_MODES.map(mode => ({ value: mode, label: mode }))}
|
||||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
|
/>
|
||||||
themeMode === mode
|
|
||||||
? 'bg-content text-content-inverse'
|
|
||||||
: 'text-content-muted hover:bg-surface hover:text-content'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mode}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="py-1"
|
className="py-1"
|
||||||
@@ -135,7 +108,7 @@ export default function Layout() {
|
|||||||
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
|
||||||
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
|
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
|
<span
|
||||||
className="text-content transition-transform duration-200"
|
className="text-content transition-transform duration-200"
|
||||||
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ function MyComponent() {
|
|||||||
- `removeToast(id: string): void`
|
- `removeToast(id: string): void`
|
||||||
- Manually remove a toast by ID
|
- Manually remove a toast by ID
|
||||||
|
|
||||||
- `clearToasts(): void`
|
|
||||||
- Clear all active toasts
|
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -76,22 +73,13 @@ Skeleton components provide placeholder content while data is loading. They auto
|
|||||||
|
|
||||||
#### `Skeleton`
|
#### `Skeleton`
|
||||||
|
|
||||||
Basic skeleton element with various variants:
|
A pulsing placeholder block. Size and shape are controlled with Tailwind classes via `className`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Skeleton } from './components/Skeleton';
|
import { Skeleton } from './components/Skeleton';
|
||||||
|
|
||||||
// Default (rounded rectangle)
|
<Skeleton className="h-8 w-full" />
|
||||||
<Skeleton className="w-full h-8" />
|
<Skeleton className="w-3/4" />
|
||||||
|
|
||||||
// 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} />
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `SkeletonTable`
|
#### `SkeletonTable`
|
||||||
|
|||||||
@@ -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,46 +2,10 @@ import { cn } from '../utils/cn';
|
|||||||
|
|
||||||
interface SkeletonProps {
|
interface SkeletonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
animation?: 'pulse' | 'wave' | 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Skeleton({
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
className = '',
|
return <div className={cn('h-4 animate-pulse rounded-md bg-surface-strong', 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 SkeletonTableProps {
|
interface SkeletonTableProps {
|
||||||
@@ -65,7 +29,7 @@ export function SkeletonTable({
|
|||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
{Array.from({ length: columns }).map((_, i) => (
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
<th key={i} className="p-3">
|
<th key={i} className="p-3">
|
||||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
<Skeleton className="h-5 w-3/4" />
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -76,10 +40,7 @@ export function SkeletonTable({
|
|||||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||||
<td key={colIndex} className="p-3">
|
<td key={colIndex} className="p-3">
|
||||||
<Skeleton
|
<Skeleton className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
|
||||||
variant="text"
|
|
||||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { SkeletonTable } from './Skeleton';
|
import { SkeletonTable } from './Skeleton';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
export interface Column<T> {
|
export interface Column<T> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +47,7 @@ export function Table<T>({
|
|||||||
{columns.map(column => (
|
{columns.map(column => (
|
||||||
<th
|
<th
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
className={cn('p-3 text-left text-content-muted', column.className)}
|
||||||
>
|
>
|
||||||
{column.header}
|
{column.header}
|
||||||
</th>
|
</th>
|
||||||
@@ -64,10 +65,7 @@ export function Table<T>({
|
|||||||
data.map((row, index) => (
|
data.map((row, index) => (
|
||||||
<tr key={getRowKey(row, index)} className="border-b border-border">
|
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||||
{columns.map(column => (
|
{columns.map(column => (
|
||||||
<td
|
<td key={column.id} className={cn('p-3 text-content', column.className)}>
|
||||||
key={column.id}
|
|
||||||
className={`p-3 text-content ${column.className || ''}`}
|
|
||||||
>
|
|
||||||
{column.render(row, index)}
|
{column.render(row, index)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
import { Toast, ToastType, ToastProps } from './Toast';
|
import { Toast, ToastType, ToastProps } from './Toast';
|
||||||
|
|
||||||
|
interface ToastUpdate {
|
||||||
|
message?: string;
|
||||||
|
type?: ToastType;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToastContextType {
|
interface ToastContextType {
|
||||||
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
||||||
showInfo: (message: string, duration?: number) => string;
|
showInfo: (message: string, duration?: number) => string;
|
||||||
showWarning: (message: string, duration?: number) => string;
|
showWarning: (message: string, duration?: number) => string;
|
||||||
showError: (message: string, duration?: number) => string;
|
showError: (message: string, duration?: number) => string;
|
||||||
|
updateToast: (id: string, update: ToastUpdate) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
clearToasts: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
@@ -49,13 +55,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
[showToast]
|
[showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearToasts = useCallback(() => {
|
// 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.
|
||||||
setToasts([]);
|
const updateToast = useCallback((id: string, update: ToastUpdate) => {
|
||||||
|
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider
|
<ToastContext.Provider
|
||||||
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ToastContainer toasts={toasts} />
|
<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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -12,3 +12,9 @@ export { TextInput } from './TextInput';
|
|||||||
|
|
||||||
// Field components
|
// Field components
|
||||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
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';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ElementType } from 'react';
|
import type { ElementType } from 'react';
|
||||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
|
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -10,16 +10,16 @@ export interface NavItem {
|
|||||||
export const navItems: NavItem[] = [
|
export const navItems: NavItem[] = [
|
||||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||||
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
|
{ path: '/documents', label: 'Documents', icon: DocumentsIcon },
|
||||||
{ path: '/progress', label: 'Progress', icon: ActivityIcon },
|
{ path: '/progress', label: 'Progress', icon: ClockIcon },
|
||||||
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
|
{ path: '/activity', label: 'Activity', icon: ActivityIcon },
|
||||||
{ path: '/search', label: 'Search', icon: SearchIcon },
|
{ path: '/search', label: 'Search', icon: SearchIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const adminNavItems: NavItem[] = [
|
export const adminNavItems: { path: string; label: string }[] = [
|
||||||
{ path: '/admin', label: 'General', icon: SettingsIcon },
|
{ path: '/admin', label: 'General' },
|
||||||
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
|
{ path: '/admin/import', label: 'Import' },
|
||||||
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
|
{ path: '/admin/users', label: 'Users' },
|
||||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
|
{ path: '/admin/logs', label: 'Logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
|
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id?: string;
|
id: string;
|
||||||
device_name?: string;
|
device_name: string;
|
||||||
created_at?: string;
|
created_at: string;
|
||||||
last_synced?: string;
|
last_synced: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
export interface Progress {
|
export interface Progress {
|
||||||
title?: string;
|
title?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
device_name?: string;
|
device_name: string;
|
||||||
device_id?: string;
|
device_id?: string;
|
||||||
percentage?: number;
|
percentage: number;
|
||||||
progress?: string;
|
progress?: string;
|
||||||
document_id?: string;
|
document_id: string;
|
||||||
user_id?: string;
|
user_id: string;
|
||||||
created_at?: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ export interface SearchItem {
|
|||||||
series?: string;
|
series?: string;
|
||||||
file_type?: string;
|
file_type?: string;
|
||||||
file_size?: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import type { CreateActivityRequest } from '../generated/model/createActivityReq
|
|||||||
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
|
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
|
||||||
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
||||||
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
||||||
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { getErrorMessage } from '../utils/errors';
|
||||||
|
|
||||||
interface UseEpubReaderOptions {
|
interface UseEpubReaderOptions {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -29,11 +31,6 @@ interface UseEpubReaderResult {
|
|||||||
nextPage: () => Promise<void>;
|
nextPage: () => Promise<void>;
|
||||||
prevPage: () => Promise<void>;
|
prevPage: () => Promise<void>;
|
||||||
goToHref: (href: string) => Promise<void>;
|
goToHref: (href: string) => Promise<void>;
|
||||||
setTheme: (theme: {
|
|
||||||
colorScheme?: ReaderColorScheme;
|
|
||||||
fontFamily?: ReaderFontFamily;
|
|
||||||
fontSize?: number;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEpubReader({
|
export function useEpubReader({
|
||||||
@@ -65,6 +62,7 @@ export function useEpubReader({
|
|||||||
sectionTotalPages: 0,
|
sectionTotalPages: 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
});
|
});
|
||||||
|
const { showError } = useToasts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isPaginationDisabledRef.current = isPaginationDisabled;
|
isPaginationDisabledRef.current = isPaginationDisabled;
|
||||||
@@ -95,20 +93,20 @@ export function useEpubReader({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveProgress = async (payload: UpdateProgressRequest) => {
|
const saveProgress = async (payload: UpdateProgressRequest) => {
|
||||||
const response = await updateProgress(payload);
|
// Swallow Save Failures - Transient progress-save errors must not take down the reader
|
||||||
if (response.status >= 400) {
|
// (they previously routed to onError, which hides the whole book behind an error overlay).
|
||||||
throw new Error(
|
try {
|
||||||
'message' in response.data ? response.data.message : 'Unable to save reader progress'
|
await updateProgress(payload);
|
||||||
);
|
} catch (err) {
|
||||||
|
showError(`Failed to save progress: ${getErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveActivity = async (payload: CreateActivityRequest) => {
|
const saveActivity = async (payload: CreateActivityRequest) => {
|
||||||
const response = await createActivity(payload);
|
try {
|
||||||
if (response.status >= 400) {
|
await createActivity(payload);
|
||||||
throw new Error(
|
} catch (err) {
|
||||||
'message' in response.data ? response.data.message : 'Unable to save reader activity'
|
showError(`Failed to save activity: ${getErrorMessage(err)}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,17 +208,6 @@ export function useEpubReader({
|
|||||||
await readerRef.current?.displayHref(href);
|
await readerRef.current?.displayHref(href);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTheme = useCallback(
|
|
||||||
async (theme: {
|
|
||||||
colorScheme?: ReaderColorScheme;
|
|
||||||
fontFamily?: ReaderFontFamily;
|
|
||||||
fontSize?: number;
|
|
||||||
}) => {
|
|
||||||
await readerRef.current?.applyThemeChange(theme);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
viewerRef: setViewerNode,
|
viewerRef: setViewerNode,
|
||||||
@@ -232,8 +219,7 @@ export function useEpubReader({
|
|||||||
nextPage,
|
nextPage,
|
||||||
prevPage,
|
prevPage,
|
||||||
goToHref,
|
goToHref,
|
||||||
setTheme,
|
|
||||||
}),
|
}),
|
||||||
[error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc]
|
[error, goToHref, isLoading, isReady, nextPage, prevPage, stats, toc]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
|
|
||||||
interface ApiResponse {
|
|
||||||
status: number;
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastMutationOptions {
|
interface ToastMutationOptions {
|
||||||
success: string;
|
success: string;
|
||||||
error: string;
|
error: string;
|
||||||
@@ -14,18 +9,15 @@ interface ToastMutationOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call,
|
* Builds `{ onSuccess, onError }` for a generated mutation's `.mutate(vars, options)` call,
|
||||||
* centralizing the shared "toast success / toast error / treat non-2xx as failure" pattern.
|
* 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() {
|
export function useMutationWithToast() {
|
||||||
const { showInfo, showError } = useToasts();
|
const { showInfo, showError } = useToasts();
|
||||||
|
|
||||||
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
||||||
return {
|
return {
|
||||||
onSuccess: (response: ApiResponse) => {
|
onSuccess: () => {
|
||||||
if (response.status < 200 || response.status >= 300) {
|
|
||||||
showError(`${error}: ${getErrorMessage(response.data)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
showInfo(success);
|
showInfo(success);
|
||||||
},
|
},
|
||||||
@@ -33,3 +25,35 @@ export function useMutationWithToast() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -356,72 +356,6 @@ main {
|
|||||||
display: none;
|
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 */
|
/* Skeleton Wave Animation */
|
||||||
@keyframes wave {
|
@keyframes wave {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -2,125 +2,27 @@ import ePub from 'epubjs';
|
|||||||
import NoSleep from 'nosleep.js';
|
import NoSleep from 'nosleep.js';
|
||||||
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
|
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
|
||||||
import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest';
|
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 THEME_FILE = '/assets/reader/themes.css';
|
||||||
const FONT_FILE = '/assets/reader/fonts.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 {
|
interface BookState {
|
||||||
pages: number;
|
pages: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
@@ -174,6 +76,7 @@ export class EBookReader {
|
|||||||
private rendition: EpubRendition;
|
private rendition: EpubRendition;
|
||||||
private noSleep: NoSleep | null = null;
|
private noSleep: NoSleep | null = null;
|
||||||
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private gestureDispose: (() => void) | null = null;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
private onReady: () => void;
|
private onReady: () => void;
|
||||||
private onLoading: (_loading: boolean) => void;
|
private onLoading: (_loading: boolean) => void;
|
||||||
@@ -187,7 +90,6 @@ export class EBookReader {
|
|||||||
private onSwipeUp: () => void;
|
private onSwipeUp: () => void;
|
||||||
private onCenterTap: () => void;
|
private onCenterTap: () => void;
|
||||||
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
|
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||||
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
constructor(options: EBookReaderOptions) {
|
constructor(options: EBookReaderOptions) {
|
||||||
this.container = options.container;
|
this.container = options.container;
|
||||||
@@ -217,7 +119,6 @@ export class EBookReader {
|
|||||||
pageStart: Date.now(),
|
pageStart: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.loadSettings();
|
|
||||||
this.readerSettings.theme = {
|
this.readerSettings.theme = {
|
||||||
colorScheme: options.colorScheme,
|
colorScheme: options.colorScheme,
|
||||||
fontFamily: options.fontFamily,
|
fontFamily: options.fontFamily,
|
||||||
@@ -237,7 +138,16 @@ export class EBookReader {
|
|||||||
this.initCSP();
|
this.initCSP();
|
||||||
this.initWakeLock();
|
this.initWakeLock();
|
||||||
this.initThemes();
|
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.initDocumentListeners();
|
||||||
|
|
||||||
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
|
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() {
|
private initWakeLock() {
|
||||||
this.noSleep = new NoSleep();
|
this.noSleep = new NoSleep();
|
||||||
document.addEventListener('wakelock', this.handleWakeLock);
|
document.addEventListener('wakelock', this.handleWakeLock);
|
||||||
@@ -279,7 +183,7 @@ export class EBookReader {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private initThemes() {
|
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;
|
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
|
||||||
if (!themeLinkEl) {
|
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() {
|
private initDocumentListeners() {
|
||||||
const nextPage = this.nextPage.bind(this);
|
const nextPage = this.nextPage.bind(this);
|
||||||
const prevPage = this.prevPage.bind(this);
|
const prevPage = this.prevPage.bind(this);
|
||||||
|
|
||||||
this.keyupHandler = (event: KeyboardEvent) => {
|
this.keyupHandler = (event: KeyboardEvent) => {
|
||||||
if ((event.keyCode || event.which) === 37) {
|
if (event.key === 'ArrowLeft') {
|
||||||
void prevPage();
|
void prevPage();
|
||||||
}
|
}
|
||||||
if ((event.keyCode || event.which) === 39) {
|
if (event.key === 'ArrowRight') {
|
||||||
void nextPage();
|
void nextPage();
|
||||||
}
|
}
|
||||||
if ((event.keyCode || event.which) === 84) {
|
if (event.key === 't' || event.key === 'T') {
|
||||||
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
||||||
const currentThemeIdx = THEMES.indexOf(currentTheme);
|
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
||||||
const colorScheme =
|
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) {
|
if (colorScheme) {
|
||||||
this.setTheme({ colorScheme });
|
this.setTheme({ colorScheme });
|
||||||
}
|
}
|
||||||
@@ -482,44 +267,20 @@ export class EBookReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setupReader() {
|
private async setupReader() {
|
||||||
this.bookState.words = await this.countWords();
|
this.bookState.words = await countWords(this.book);
|
||||||
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
const cfiResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||||
await this.setPosition(cfi);
|
await this.setPosition(cfiResult?.cfi);
|
||||||
const { element } = await this.getCFIFromXPath(this.bookState.progress);
|
const elementResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = elementResult?.element ?? null;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
const stats = await this.getBookStats();
|
const stats = await this.getBookStats();
|
||||||
this.onStats(stats);
|
this.onStats(stats);
|
||||||
this.bookState.pageStart = Date.now();
|
this.bookState.pageStart = Date.now();
|
||||||
this.onToc(this.getParsedTOC());
|
this.onToc(getParsedTOC(this.book));
|
||||||
this.onLoading(false);
|
this.onLoading(false);
|
||||||
this.onReady();
|
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 }) {
|
setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
|
||||||
this.readerSettings.theme =
|
this.readerSettings.theme =
|
||||||
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
|
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
|
||||||
@@ -615,6 +376,8 @@ export class EBookReader {
|
|||||||
return;
|
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);
|
await this.rendition.display(cfi);
|
||||||
await this.rendition.display(cfi);
|
await this.rendition.display(cfi);
|
||||||
@@ -627,11 +390,11 @@ export class EBookReader {
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
}) {
|
}) {
|
||||||
const currentProgress = this.bookState.progress;
|
const currentProgress = this.bookState.progress;
|
||||||
const { cfi } = await this.getCFIFromXPath(currentProgress);
|
const cfiResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||||
this.setTheme(newTheme);
|
this.setTheme(newTheme);
|
||||||
await this.setPosition(cfi);
|
await this.setPosition(cfiResult?.cfi);
|
||||||
const { element } = await this.getCFIFromXPath(currentProgress);
|
const elementResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = elementResult?.element ?? null;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,8 +404,8 @@ export class EBookReader {
|
|||||||
|
|
||||||
const pageStart = this.bookState.pageStart;
|
const pageStart = this.bookState.pageStart;
|
||||||
let elapsedTime = Date.now() - pageStart;
|
let elapsedTime = Date.now() - pageStart;
|
||||||
const pageWords = await this.getVisibleWordCount();
|
const pageWords = await getVisibleWordCount(this.book, this.rendition);
|
||||||
const currentWord = await this.getBookWordPosition();
|
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||||
const percentRead = pageWords / this.bookState.words;
|
const percentRead = pageWords / this.bookState.words;
|
||||||
const pageWPM = pageWords / (elapsedTime / 60000);
|
const pageWPM = pageWords / (elapsedTime / 60000);
|
||||||
|
|
||||||
@@ -686,10 +449,10 @@ export class EBookReader {
|
|||||||
|
|
||||||
async createProgress() {
|
async createProgress() {
|
||||||
const currentCFI = await this.rendition.currentLocation();
|
const currentCFI = await this.rendition.currentLocation();
|
||||||
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
const xpathResult = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
||||||
const currentWord = await this.getBookWordPosition();
|
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||||
this.bookState.progress = xpath ?? '';
|
this.bookState.progress = xpathResult?.xpath ?? '';
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = xpathResult?.element ?? null;
|
||||||
|
|
||||||
const percentage =
|
const percentage =
|
||||||
this.bookState.words > 0
|
this.bookState.words > 0
|
||||||
@@ -744,7 +507,7 @@ export class EBookReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentLocation = await this.rendition.currentLocation();
|
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(
|
const currentTOC = this.book.navigation?.toc?.find(
|
||||||
item => item.href === currentLocation.start.href
|
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() {
|
destroy() {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
if (this.keyupHandler) {
|
if (this.keyupHandler) {
|
||||||
@@ -991,9 +532,7 @@ export class EBookReader {
|
|||||||
if (this.wakeTimeoutId) {
|
if (this.wakeTimeoutId) {
|
||||||
clearTimeout(this.wakeTimeoutId);
|
clearTimeout(this.wakeTimeoutId);
|
||||||
}
|
}
|
||||||
if (this.wheelTimeoutId) {
|
this.gestureDispose?.();
|
||||||
clearTimeout(this.wheelTimeoutId);
|
|
||||||
}
|
|
||||||
void this.noSleep?.disable();
|
void this.noSleep?.disable();
|
||||||
this.rendition.destroy?.();
|
this.rendition.destroy?.();
|
||||||
this.book.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 { ToastProvider } from './components/ToastContext';
|
||||||
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { ApiError } from './utils/apiFetch';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
initializeThemeMode();
|
initializeThemeMode();
|
||||||
@@ -13,7 +14,14 @@ const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 1000 * 60 * 5,
|
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: {
|
mutations: {
|
||||||
retry: 0,
|
retry: 0,
|
||||||
|
|||||||
@@ -1,46 +1,35 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
|
||||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Activity } from '../generated/model';
|
import type { Activity } from '../generated/model';
|
||||||
import { Pagination } from '../components';
|
import { Pagination } from '../components';
|
||||||
import { Table, type Column } from '../components/Table';
|
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;
|
const ACTIVITY_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const documentID = searchParams.get('document') || undefined;
|
const documentID = searchParams.get('document') || undefined;
|
||||||
const [page, setPage] = useState(1);
|
const { page, setPage } = usePaginatedList(documentID);
|
||||||
const limit = ACTIVITY_PAGE_SIZE;
|
const limit = ACTIVITY_PAGE_SIZE;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [documentID]);
|
|
||||||
|
|
||||||
const { data, isLoading } = useGetActivity({
|
const { data, isLoading } = useGetActivity({
|
||||||
doc_filter: Boolean(documentID),
|
doc_filter: Boolean(documentID),
|
||||||
document_id: documentID,
|
document_id: documentID,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
const response = data?.status === 200 ? data.data : undefined;
|
const response = data;
|
||||||
const activities = response?.activities ?? [];
|
const activities = response?.activities ?? [];
|
||||||
|
|
||||||
const columns: Column<Activity>[] = [
|
const columns: Column<Activity>[] = [
|
||||||
{
|
documentColumn,
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'start_time',
|
id: 'start_time',
|
||||||
header: 'Time',
|
header: 'Time',
|
||||||
render: row => row.start_time || 'N/A',
|
render: row => formatDateTime(row.start_time),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type SyntheticEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState, Table, type Column } from '../components';
|
||||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||||
import type { DirectoryItem } from '../generated/model';
|
import type { DirectoryItem } from '../generated/model';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { FolderOpenIcon } from '../icons';
|
import { FolderOpenIcon } from '../icons';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
|
|
||||||
export default function AdminImportPage() {
|
export default function AdminImportPage() {
|
||||||
const [currentPath, setCurrentPath] = useState<string>('');
|
const [currentPath, setCurrentPath] = useState<string>('');
|
||||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||||
const { showInfo, showError } = useToasts();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||||
currentPath ? { directory: currentPath } : {}
|
currentPath ? { directory: currentPath } : {}
|
||||||
@@ -21,8 +20,7 @@ export default function AdminImportPage() {
|
|||||||
|
|
||||||
const postImport = usePostImport();
|
const postImport = usePostImport();
|
||||||
|
|
||||||
const directoryResponse =
|
const directoryResponse = directoryData;
|
||||||
directoryData?.status === 200 ? directoryData.data : null;
|
|
||||||
const directories = directoryResponse?.items ?? [];
|
const directories = directoryResponse?.items ?? [];
|
||||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||||
|
|
||||||
@@ -38,7 +36,8 @@ export default function AdminImportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = (e: SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
if (!selectedDirectory) return;
|
if (!selectedDirectory) return;
|
||||||
|
|
||||||
postImport.mutate(
|
postImport.mutate(
|
||||||
@@ -48,15 +47,11 @@ export default function AdminImportPage() {
|
|||||||
type: importType,
|
type: importType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: _response => {
|
success: 'Import completed successfully',
|
||||||
showInfo('Import completed successfully');
|
error: 'Import failed',
|
||||||
navigate('/admin/import-results');
|
onSuccess: () => navigate('/admin/import-results'),
|
||||||
},
|
})
|
||||||
onError: error => {
|
|
||||||
showError('Import failed: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,8 +65,6 @@ export default function AdminImportPage() {
|
|||||||
|
|
||||||
if (selectedDirectory) {
|
if (selectedDirectory) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
|
||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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>
|
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||||
@@ -116,59 +109,39 @@ export default function AdminImportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const directoryColumns: Column<DirectoryItem>[] = [
|
||||||
<div className="overflow-x-auto">
|
{
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
id: 'select',
|
||||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
header: '',
|
||||||
<thead className="text-content-muted">
|
className: 'w-12',
|
||||||
<tr>
|
render: item => (
|
||||||
<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)}>
|
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||||
<FolderOpenIcon size={20} />
|
<FolderOpenIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
),
|
||||||
<td className="border-b border-border p-3">
|
},
|
||||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
{ id: 'name', header: currentPathDisplay, render: item => item.name ?? '' },
|
||||||
<p>{item.name ?? ''}</p>
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
</button>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
<Table
|
||||||
</table>
|
columns={directoryColumns}
|
||||||
</div>
|
data={directories}
|
||||||
|
emptyMessage="No Folders"
|
||||||
|
rowKey={item => item.name ?? ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,21 @@
|
|||||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||||
import { LoadingState } from '../components';
|
import { Table, type Column } from '../components';
|
||||||
import type { ImportResult } from '../generated/model';
|
import type { ImportResult } from '../generated/model';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export default function AdminImportResultsPage() {
|
export default function AdminImportResultsPage() {
|
||||||
const { data: resultsData, isLoading } = useGetImportResults();
|
const { data: resultsData, isLoading } = useGetImportResults();
|
||||||
const results =
|
const results = resultsData?.results ?? [];
|
||||||
resultsData?.status === 200 ? resultsData.data.results || [] : [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
const columns: Column<ImportResult>[] = [
|
||||||
return <LoadingState />;
|
{
|
||||||
}
|
id: 'document',
|
||||||
|
header: 'Document',
|
||||||
return (
|
render: result => (
|
||||||
<div className="overflow-x-auto">
|
<div className="grid grid-cols-[4rem_auto] gap-y-1">
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
|
||||||
<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={result.path ?? index}>
|
|
||||||
<td className="grid grid-cols-[4rem_auto] border-b border-border p-3">
|
|
||||||
<span className="text-content-muted">Name:</span>
|
<span className="text-content-muted">Name:</span>
|
||||||
{result.id ? (
|
{result.id ? (
|
||||||
<Link
|
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||||
to={`/documents/${result.id}`}
|
|
||||||
className="text-secondary-600 hover:underline"
|
|
||||||
>
|
|
||||||
{result.name}
|
{result.name}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -49,19 +23,19 @@ export default function AdminImportResultsPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-content-muted">File:</span>
|
<span className="text-content-muted">File:</span>
|
||||||
<span>{result.path}</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>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ id: 'status', header: 'Status', render: result => result.status },
|
||||||
|
{ id: 'error', header: 'Error', render: result => result.error ?? '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={results}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey={result => result.path ?? result.name ?? ''}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { SyntheticEvent } from 'react';
|
import { SyntheticEvent } from 'react';
|
||||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||||
import { Button } from '../components/Button';
|
import { Button, LoadingState, TextInput, IconInput } from '../components';
|
||||||
import { LoadingState, TextInput } from '../components';
|
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon } from '../icons';
|
import { Search2Icon } from '../icons';
|
||||||
|
|
||||||
@@ -10,7 +9,7 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
||||||
|
|
||||||
const logs = logsData?.status === 200 ? (logsData.data.logs ?? []) : [];
|
const logs = logsData?.logs ?? [];
|
||||||
|
|
||||||
const handleFilterSubmit = (e: SyntheticEvent) => {
|
const handleFilterSubmit = (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -22,10 +21,7 @@ export default function AdminLogsPage() {
|
|||||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<Search2Icon size={15} hoverable={false} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={filter}
|
value={filter}
|
||||||
@@ -33,13 +29,11 @@ export default function AdminLogsPage() {
|
|||||||
className="p-2"
|
className="p-2"
|
||||||
placeholder="JQ Filter"
|
placeholder="JQ Filter"
|
||||||
/>
|
/>
|
||||||
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<div className="lg:w-60">
|
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Filter
|
Filter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, SyntheticEvent } from 'react';
|
import { useState, SyntheticEvent } from 'react';
|
||||||
import { LoadingState } from '../components';
|
import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import { streamResponseToFile, backupFilename } from '../utils/download';
|
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ interface BackupTypes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { isLoading } = useGetAdmin();
|
|
||||||
const postAdminAction = usePostAdminAction();
|
const postAdminAction = usePostAdminAction();
|
||||||
const { showInfo, showError, removeToast } = useToasts();
|
const { showInfo, showError, updateToast } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
|
||||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||||
covers: false,
|
covers: false,
|
||||||
@@ -61,54 +61,47 @@ export default function AdminPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!restoreFile) return;
|
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 {
|
try {
|
||||||
const response = await postAdminAction.mutateAsync({
|
await postAdminAction.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
action: 'RESTORE',
|
action: 'RESTORE',
|
||||||
restore_file: restoreFile,
|
restore_file: restoreFile,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
removeToast(startedToastId);
|
updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
|
||||||
|
|
||||||
if (response.status >= 200 && response.status < 300) {
|
|
||||||
showInfo('Restore completed successfully');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showError('Restore failed: ' + getErrorMessage(response.data));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
removeToast(startedToastId);
|
updateToast(toastId, {
|
||||||
showError('Restore failed: ' + getErrorMessage(error));
|
type: 'error',
|
||||||
|
message: `Restore failed: ${getErrorMessage(error)}`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetadataMatch = () => {
|
const handleMetadataMatch = () => {
|
||||||
postAdminAction.mutate(
|
postAdminAction.mutate(
|
||||||
{ data: { action: 'METADATA_MATCH' } },
|
{ data: { action: 'METADATA_MATCH' } },
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: () => showInfo('Metadata matching started'),
|
success: 'Metadata matching started',
|
||||||
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
|
error: 'Failed to start metadata matching',
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCacheTables = () => {
|
const handleCacheTables = () => {
|
||||||
postAdminAction.mutate(
|
postAdminAction.mutate(
|
||||||
{ data: { action: 'CACHE_TABLES' } },
|
{ data: { action: 'CACHE_TABLES' } },
|
||||||
{
|
toastMutationOptions({
|
||||||
onSuccess: () => showInfo('Cache tables started'),
|
success: 'Cache tables started',
|
||||||
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
|
error: 'Failed to start cache tables',
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full grow flex-col gap-4">
|
<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">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
@@ -135,8 +128,8 @@ export default function AdminPage() {
|
|||||||
<label htmlFor="backup_documents">Documents</label>
|
<label htmlFor="backup_documents">Documents</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 w-40">
|
<div className="w-40">
|
||||||
<Button variant="secondary" type="submit">
|
<Button variant="secondary" type="submit" className="w-full">
|
||||||
Backup
|
Backup
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,8 +144,8 @@ export default function AdminPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 w-40">
|
<div className="w-40">
|
||||||
<Button variant="secondary" type="submit" disabled={!restoreFile}>
|
<Button variant="secondary" type="submit" className="w-full" disabled={!restoreFile}>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,35 +154,21 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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>
|
<p className="mb-4 text-lg font-semibold text-content">Tasks</p>
|
||||||
<table className="min-w-full bg-surface text-sm text-content">
|
<ul className="flex flex-col gap-3 text-sm text-content">
|
||||||
<tbody>
|
<li className="flex items-center justify-between gap-4">
|
||||||
<tr>
|
|
||||||
<td className="pl-0">
|
|
||||||
<p>Metadata Matching</p>
|
<p>Metadata Matching</p>
|
||||||
</td>
|
|
||||||
<td className="float-right py-2">
|
|
||||||
<div className="h-10 w-40 text-base">
|
|
||||||
<Button variant="secondary" onClick={handleMetadataMatch}>
|
<Button variant="secondary" onClick={handleMetadataMatch}>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</li>
|
||||||
</td>
|
<li className="flex items-center justify-between gap-4">
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<p>Cache Tables</p>
|
<p>Cache Tables</p>
|
||||||
</td>
|
|
||||||
<td className="float-right py-2">
|
|
||||||
<div className="h-10 w-40 text-base">
|
|
||||||
<Button variant="secondary" onClick={handleCacheTables}>
|
<Button variant="secondary" onClick={handleCacheTables}>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</li>
|
||||||
</td>
|
</ul>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,32 +6,131 @@ import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
|||||||
import type { User } from '../generated/model';
|
import type { User } from '../generated/model';
|
||||||
import { AddIcon, DeleteIcon } from '../icons';
|
import { AddIcon, DeleteIcon } from '../icons';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
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() {
|
export default function AdminUsersPage() {
|
||||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const toastMutationOptions = useMutationWithToast();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
const { showError } = useToasts();
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
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 [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
const [resetPassword, setResetPassword] = useState('');
|
|
||||||
|
|
||||||
const users = usersData?.status === 200 ? (usersData.data.users ?? []) : [];
|
const users = usersData?.users ?? [];
|
||||||
|
|
||||||
const handleCreateUser = (e: SyntheticEvent) => {
|
const handleCreateUser = (username: string, password: string, isAdmin: boolean) => {
|
||||||
e.preventDefault();
|
if (!username || !password) {
|
||||||
if (!newUsername || !newPassword) return;
|
showError('Please enter username and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateUser.mutate(
|
updateUser.mutate(
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
operation: 'CREATE',
|
operation: 'CREATE',
|
||||||
user: newUsername,
|
user: username,
|
||||||
password: newPassword,
|
password,
|
||||||
is_admin: newIsAdmin,
|
is_admin: isAdmin,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toastMutationOptions({
|
toastMutationOptions({
|
||||||
@@ -39,9 +138,6 @@ export default function AdminUsersPage() {
|
|||||||
error: 'Failed to create user',
|
error: 'Failed to create user',
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setNewUsername('');
|
|
||||||
setNewPassword('');
|
|
||||||
setNewIsAdmin(false);
|
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -89,13 +185,6 @@ export default function AdminUsersPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const userColumns: Column<User>[] = [
|
const userColumns: Column<User>[] = [
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -116,15 +205,14 @@ export default function AdminUsersPage() {
|
|||||||
id: 'password',
|
id: 'password',
|
||||||
header: 'Password',
|
header: 'Password',
|
||||||
render: user => (
|
render: user => (
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setResetUserId(user.id);
|
setResetUserId(user.id);
|
||||||
setResetPassword('');
|
|
||||||
}}
|
}}
|
||||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
className="px-2 py-1"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,7 +238,12 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'created', header: 'Created', className: 'w-48', render: user => user.created_at },
|
{
|
||||||
|
id: 'created',
|
||||||
|
header: 'Created',
|
||||||
|
className: 'w-48',
|
||||||
|
render: user => formatDate(user.created_at),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -159,77 +252,16 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full overflow-x-auto">
|
<div className="relative h-full overflow-x-auto">
|
||||||
{showAddForm && (
|
{showAddForm && <AddUserForm onCreate={handleCreateUser} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Table columns={userColumns} data={users} rowKey="id" />
|
<Table columns={userColumns} data={users} rowKey="id" />
|
||||||
|
|
||||||
{resetUserId && (
|
{resetUserId && (
|
||||||
<div
|
<ResetPasswordDialog
|
||||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50"
|
userId={resetUserId}
|
||||||
onClick={() => setResetUserId(null)}
|
onClose={() => setResetUserId(null)}
|
||||||
>
|
onSave={handleUpdatePassword}
|
||||||
<form
|
|
||||||
className="w-80 rounded bg-surface p-4 shadow-lg"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
onSubmit={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!resetPassword) return;
|
|
||||||
handleUpdatePassword(resetUserId, resetPassword);
|
|
||||||
setResetUserId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="mb-3 text-content">Reset password for {resetUserId}</p>
|
|
||||||
<TextInput
|
|
||||||
type="password"
|
|
||||||
value={resetPassword}
|
|
||||||
onChange={e => setResetPassword(e.target.value)}
|
|
||||||
placeholder="New password"
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setResetUserId(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!resetPassword}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ import {
|
|||||||
} from '../generated/anthoLumeAPIV1';
|
} from '../generated/anthoLumeAPIV1';
|
||||||
import type { EditDocumentBody } from '../generated/model';
|
import type { EditDocumentBody } from '../generated/model';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { useToastMutation } from '../hooks/useMutationWithToast';
|
||||||
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
|
||||||
|
|
||||||
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
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 editInputClassName =
|
|
||||||
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
|
||||||
|
|
||||||
interface EditableFieldProps {
|
interface EditableFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -92,7 +87,7 @@ function EditableField({
|
|||||||
<textarea
|
<textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={e => setDraft(e.target.value)}
|
onChange={e => setDraft(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-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
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}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -100,7 +95,7 @@ function EditableField({
|
|||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={e => setDraft(e.target.value)}
|
onChange={e => setDraft(e.target.value)}
|
||||||
className={editInputClassName}
|
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>
|
</div>
|
||||||
@@ -114,9 +109,11 @@ function EditableField({
|
|||||||
export default function DocumentPage() {
|
export default function DocumentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const queryClient = useQueryClient();
|
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 editMutation = useEditDocument();
|
||||||
const { showError } = useToasts();
|
const runWithToast = useToastMutation();
|
||||||
|
|
||||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||||
|
|
||||||
@@ -124,30 +121,21 @@ export default function DocumentPage() {
|
|||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!docData || docData.status !== 200) {
|
const doc = docData?.document;
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
return <div className="text-content-muted">Document not found</div>;
|
return <div className="text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = docData.data.document;
|
const percentage = doc.percentage ?? 0;
|
||||||
|
const secondsPerPercent = doc.seconds_per_percent || 0;
|
||||||
const percentage = document.percentage ?? 0;
|
|
||||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
|
||||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||||
|
|
||||||
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
const save = (data: EditDocumentBody): Promise<boolean> =>
|
||||||
try {
|
runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
|
||||||
const response = await editMutation.mutateAsync({ id: document.id, data });
|
error: 'Failed to save',
|
||||||
if (response.status !== 200) {
|
onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
|
||||||
showError('Failed to save: ' + getErrorMessage(response.data));
|
});
|
||||||
return false;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
showError('Failed to save: ' + getErrorMessage(err));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative size-full">
|
<div className="relative size-full">
|
||||||
@@ -155,14 +143,14 @@ export default function DocumentPage() {
|
|||||||
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
||||||
<img
|
<img
|
||||||
className="w-full rounded object-fill"
|
className="w-full rounded object-fill"
|
||||||
src={`/api/v1/documents/${document.id}/cover`}
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
alt={`${document.title} cover`}
|
alt={`${doc.title} cover`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{document.filepath && (
|
{doc.filepath && (
|
||||||
<a
|
<a
|
||||||
href={`/reader/${document.id}`}
|
href={`/reader/${doc.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-hidden focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700"
|
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
|
Read
|
||||||
</a>
|
</a>
|
||||||
@@ -172,26 +160,26 @@ export default function DocumentPage() {
|
|||||||
<div className="min-w-[50%] md:mr-2">
|
<div className="min-w-[50%] md:mr-2">
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-content-muted">ISBN-10:</p>
|
<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>
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-content-muted">ISBN-13:</p>
|
<p className="text-content-muted">ISBN-13:</p>
|
||||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
<p className="font-medium">{doc.isbn13 || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative my-auto flex grow justify-between text-content-muted">
|
<div className="relative my-auto flex grow justify-between text-content-muted">
|
||||||
<a
|
<a
|
||||||
href={`/activity?document=${document.id}`}
|
href={`/activity?document=${doc.id}`}
|
||||||
aria-label="Activity"
|
aria-label="Activity"
|
||||||
className={iconButtonClassName}
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
<ActivityIcon size={28} />
|
<ActivityIcon size={28} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{document.filepath ? (
|
{doc.filepath ? (
|
||||||
<a
|
<a
|
||||||
href={`/api/v1/documents/${document.id}/file`}
|
href={`/api/v1/documents/${doc.id}/file`}
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
className={iconButtonClassName}
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
@@ -207,15 +195,11 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||||
<EditableField
|
<EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
|
||||||
label="Title"
|
|
||||||
value={document.title}
|
|
||||||
onSave={value => save({ title: value })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditableField
|
<EditableField
|
||||||
label="Author"
|
label="Author"
|
||||||
value={document.author}
|
value={doc.author}
|
||||||
onSave={value => save({ author: value })}
|
onSave={value => save({ author: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -232,7 +216,7 @@ export default function DocumentPage() {
|
|||||||
<InfoIcon size={18} />
|
<InfoIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<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'
|
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -244,9 +228,7 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-content-subtle">Words / Minute</p>
|
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
|
||||||
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||||
@@ -259,8 +241,8 @@ export default function DocumentPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FieldValue>
|
<FieldValue>
|
||||||
{document.total_time_seconds && document.total_time_seconds > 0
|
{doc.total_time_seconds && doc.total_time_seconds > 0
|
||||||
? formatDuration(document.total_time_seconds)
|
? formatDuration(doc.total_time_seconds)
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</FieldValue>
|
</FieldValue>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -272,7 +254,7 @@ export default function DocumentPage() {
|
|||||||
|
|
||||||
<EditableField
|
<EditableField
|
||||||
label="Description"
|
label="Description"
|
||||||
value={document.description || ''}
|
value={doc.description || ''}
|
||||||
multiline
|
multiline
|
||||||
valueClassName="hyphens-auto text-justify"
|
valueClassName="hyphens-auto text-justify"
|
||||||
onSave={value => save({ description: value })}
|
onSave={value => save({ description: value })}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Document } from '../generated/model';
|
import type { Document } from '../generated/model';
|
||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { LoadingState, Pagination, TextInput } from '../components';
|
import { LoadingState, Pagination, TextInput, IconInput, SegmentedControl } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||||
import {
|
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||||
getDocumentsViewMode,
|
|
||||||
setDocumentsViewMode,
|
|
||||||
type DocumentsViewMode,
|
|
||||||
} from '../utils/localSettings';
|
|
||||||
|
|
||||||
const DOCUMENTS_PAGE_SIZE = 9;
|
const DOCUMENTS_PAGE_SIZE = 9;
|
||||||
|
|
||||||
@@ -22,25 +20,18 @@ interface DocumentItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const percentage = doc.percentage || 0;
|
const percentage = doc.percentage || 0;
|
||||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||||
|
const documentPath = `/documents/${doc.id}`;
|
||||||
|
const title = doc.title || 'Unknown';
|
||||||
|
|
||||||
const open = () => navigate(`/documents/${doc.id}`);
|
const actions = (
|
||||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const icons = (
|
|
||||||
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
|
||||||
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
|
<Link to={`/activity?document=${doc.id}`} aria-label={`View activity for ${title}`}>
|
||||||
<ActivityIcon size={20} />
|
<ActivityIcon size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
{doc.filepath ? (
|
{doc.filepath ? (
|
||||||
<a href={`/api/v1/documents/${doc.id}/file`} onClick={e => e.stopPropagation()}>
|
<a href={`/api/v1/documents/${doc.id}/file`} aria-label={`Download ${title}`}>
|
||||||
<DownloadIcon size={20} />
|
<DownloadIcon size={20} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
@@ -50,95 +41,87 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{ label: 'Title', value: doc.title || 'Unknown' },
|
{ label: 'Title', value: title, link: true },
|
||||||
{ label: 'Author', value: doc.author || 'Unknown' },
|
{ label: 'Author', value: doc.author || 'Unknown' },
|
||||||
{ label: 'Progress', value: `${percentage}%` },
|
{ label: 'Progress', value: `${percentage}%` },
|
||||||
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
|
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (layout === 'grid') {
|
const fieldList = (
|
||||||
return (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<div
|
<div
|
||||||
role="link"
|
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
|
||||||
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-hidden"
|
|
||||||
onClick={open}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
>
|
>
|
||||||
<div className="relative my-auto h-48 min-w-fit">
|
{fields.map(field => (
|
||||||
<img
|
<div key={field.label}>
|
||||||
className="h-full rounded object-cover"
|
<p className="text-content-subtle">{field.label}</p>
|
||||||
src={`/api/v1/documents/${doc.id}/cover`}
|
{field.link ? (
|
||||||
alt={doc.title}
|
<Link to={documentPath} className="font-medium hover:underline">
|
||||||
/>
|
{field.value}
|
||||||
</div>
|
</Link>
|
||||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
) : (
|
||||||
{fields.map(f => (
|
<p className="font-medium">{field.value}</p>
|
||||||
<div key={f.label} className="inline-flex shrink-0 items-center">
|
)}
|
||||||
<div>
|
|
||||||
<p className="text-content-subtle">{f.label}</p>
|
|
||||||
<p className="font-medium">{f.value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
);
|
||||||
{icons}
|
|
||||||
</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={title}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex w-full flex-col justify-between gap-4">
|
||||||
|
{fieldList}
|
||||||
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||||
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-hidden"
|
|
||||||
onClick={open}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<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">
|
{fieldList}
|
||||||
{fields.map(f => (
|
{actions}
|
||||||
<div key={f.label}>
|
|
||||||
<p className="text-content-subtle">{f.label}</p>
|
|
||||||
<p className="font-medium">{f.value}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{icons}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDocuments({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('rounded bg-surface p-6 text-center text-content-muted shadow-lg', className)}
|
||||||
|
>
|
||||||
|
No documents found.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
||||||
const [page, setPage] = useState(1);
|
const { page, setPage } = usePaginatedList(debouncedSearch);
|
||||||
const limit = DOCUMENTS_PAGE_SIZE;
|
const limit = DOCUMENTS_PAGE_SIZE;
|
||||||
const [uploadMode, setUploadMode] = useState(false);
|
const [uploadMode, setUploadMode] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showInfo, showWarning, showError } = useToasts();
|
const { showWarning } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
useEffect(() => {
|
|
||||||
setDocumentsViewMode(viewMode);
|
|
||||||
}, [viewMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
||||||
const createMutation = useCreateDocument();
|
const createMutation = useCreateDocument();
|
||||||
const documentsResponse = data?.status === 200 ? data.data : undefined;
|
const documentsResponse = data;
|
||||||
const docs = documentsResponse?.documents;
|
const docs = documentsResponse?.documents;
|
||||||
const previousPage = documentsResponse?.previous_page;
|
const previousPage = documentsResponse?.previous_page;
|
||||||
const nextPage = documentsResponse?.next_page;
|
const nextPage = documentsResponse?.next_page;
|
||||||
|
|
||||||
const uploadDocument = async (file: File | undefined) => {
|
const uploadDocument = (file: File | undefined) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!file.name.endsWith('.epub')) {
|
if (!file.name.endsWith('.epub')) {
|
||||||
@@ -146,18 +129,17 @@ export default function DocumentsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
createMutation.mutate(
|
||||||
await createMutation.mutateAsync({
|
{ data: { document_file: file } },
|
||||||
data: {
|
toastMutationOptions({
|
||||||
document_file: file,
|
success: 'Document uploaded successfully!',
|
||||||
},
|
error: 'Failed to upload document',
|
||||||
});
|
onSuccess: () => {
|
||||||
showInfo('Document uploaded successfully!');
|
|
||||||
setUploadMode(false);
|
setUploadMode(false);
|
||||||
refetch();
|
refetch();
|
||||||
} catch (error) {
|
},
|
||||||
showError('Failed to upload document: ' + getErrorMessage(error));
|
})
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelUpload = () => {
|
const handleCancelUpload = () => {
|
||||||
@@ -167,22 +149,12 @@ 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 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 flex-col gap-4 lg:flex-row">
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<Search2Icon size={15} hoverable={false} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -190,24 +162,17 @@ export default function DocumentsPage() {
|
|||||||
placeholder="Search Author / Title"
|
placeholder="Search Author / Title"
|
||||||
name="search"
|
name="search"
|
||||||
/>
|
/>
|
||||||
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<SegmentedControl<DocumentsViewMode>
|
||||||
<div className="inline-flex rounded border border-border bg-surface p-1">
|
ariaLabel="Document view mode"
|
||||||
<button
|
value={viewMode}
|
||||||
type="button"
|
onChange={setViewMode}
|
||||||
onClick={() => setViewMode('grid')}
|
options={[
|
||||||
className={getViewModeButtonClasses('grid')}
|
{ value: 'grid', label: 'Grid' },
|
||||||
>
|
{ value: 'list', label: 'List' },
|
||||||
Grid
|
]}
|
||||||
</button>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={getViewModeButtonClasses('list')}
|
|
||||||
>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,9 +183,7 @@ export default function DocumentsPage() {
|
|||||||
) : docs && docs.length > 0 ? (
|
) : docs && docs.length > 0 ? (
|
||||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
|
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">
|
<EmptyDocuments className="col-span-full" />
|
||||||
No documents found.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -230,9 +193,7 @@ export default function DocumentsPage() {
|
|||||||
) : docs && docs.length > 0 ? (
|
) : docs && docs.length > 0 ? (
|
||||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
|
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">
|
<EmptyDocuments />
|
||||||
No documents found.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState, SegmentedControl } from '../components';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||||
import type {
|
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
|
||||||
LeaderboardData,
|
|
||||||
LeaderboardEntry,
|
|
||||||
UserStreak,
|
|
||||||
} from '../generated/model';
|
|
||||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
@@ -38,51 +34,39 @@ function InfoCard({ title, size, link }: InfoCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface StreakCardProps {
|
interface StreakCardProps {
|
||||||
window: 'DAY' | 'WEEK';
|
streak: UserStreak;
|
||||||
currentStreak: number;
|
|
||||||
currentStreakStartDate: string;
|
|
||||||
currentStreakEndDate: string;
|
|
||||||
maxStreak: number;
|
|
||||||
maxStreakStartDate: string;
|
|
||||||
maxStreakEndDate: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreakCard({
|
function StreakCard({ streak }: StreakCardProps) {
|
||||||
window,
|
const isWeekly = streak.window === 'WEEK';
|
||||||
currentStreak,
|
|
||||||
currentStreakStartDate,
|
|
||||||
currentStreakEndDate,
|
|
||||||
maxStreak,
|
|
||||||
maxStreakStartDate,
|
|
||||||
maxStreakEndDate,
|
|
||||||
}: StreakCardProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
|
<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">
|
<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>
|
</p>
|
||||||
<div className="my-6 flex items-end space-x-2">
|
<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>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||||
<div>
|
<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">
|
<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>
|
</div>
|
||||||
<div className="flex items-end font-bold">{currentStreak}</div>
|
<div className="flex items-end font-bold">{streak.current_streak}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||||
<div>
|
<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">
|
<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>
|
</div>
|
||||||
<div className="flex items-end font-bold">{maxStreak}</div>
|
<div className="flex items-end font-bold">{streak.max_streak}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,9 +99,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
|
|
||||||
const currentData = data[selectedPeriod];
|
const currentData = data[selectedPeriod];
|
||||||
|
|
||||||
const getPeriodClassName = (period: TimePeriod) =>
|
|
||||||
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
<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">
|
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||||
{name} Leaderboard
|
{name} Leaderboard
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<SegmentedControl<TimePeriod>
|
||||||
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
|
variant="unstyled"
|
||||||
all
|
className="flex items-center gap-2 text-xs"
|
||||||
</button>
|
ariaLabel={`${name} leaderboard period`}
|
||||||
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
|
value={selectedPeriod}
|
||||||
year
|
onChange={setSelectedPeriod}
|
||||||
</button>
|
buttonClassName="cursor-pointer"
|
||||||
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
|
activeClassName="text-content"
|
||||||
month
|
inactiveClassName="text-content-subtle hover:text-content"
|
||||||
</button>
|
options={[
|
||||||
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
|
{ value: 'all', label: 'all' },
|
||||||
week
|
{ value: 'year', label: 'year' },
|
||||||
</button>
|
{ value: 'month', label: 'month' },
|
||||||
</div>
|
{ value: 'week', label: 'week' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,7 +155,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||||
|
|
||||||
const homeResponse = homeData?.status === 200 ? homeData.data : null;
|
const homeResponse = homeData;
|
||||||
const dbInfo = homeResponse?.database_info;
|
const dbInfo = homeResponse?.database_info;
|
||||||
const streaks = homeResponse?.streaks?.streaks;
|
const streaks = homeResponse?.streaks?.streaks;
|
||||||
const graphData = homeResponse?.graph_data?.graph_data;
|
const graphData = homeResponse?.graph_data?.graph_data;
|
||||||
@@ -202,16 +185,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{streaks?.map((streak: UserStreak) => (
|
{streaks?.map((streak: UserStreak) => (
|
||||||
<StreakCard
|
<StreakCard key={streak.window} streak={streak} />
|
||||||
key={streak.window}
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,17 +51,14 @@ describe('LoginPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
registration_enabled: false,
|
registration_enabled: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
} as ReturnType<typeof useGetInfo>);
|
} as ReturnType<typeof useGetInfo>);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,8 +109,8 @@ describe('LoginPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -127,7 +124,7 @@ describe('LoginPage', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid credentials');
|
expect(showErrorMock).toHaveBeenCalledWith('bad credentials');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,12 +151,9 @@ describe('LoginPage', () => {
|
|||||||
|
|
||||||
it('shows the registration link only when registration is enabled', () => {
|
it('shows the registration link only when registration is enabled', () => {
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
registration_enabled: true,
|
registration_enabled: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
} as ReturnType<typeof useGetInfo>);
|
} as ReturnType<typeof useGetInfo>);
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
@@ -171,12 +165,9 @@ describe('LoginPage', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
|
||||||
|
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
registration_enabled: false,
|
registration_enabled: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
} as ReturnType<typeof useGetInfo>);
|
} as ReturnType<typeof useGetInfo>);
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
import { useState, SyntheticEvent, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useAuthForm } from '../hooks/useAuthForm';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||||
|
|
||||||
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 default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showError } = useToasts();
|
const { username, password, isLoading, registrationEnabled, setUsername, setPassword, submit } =
|
||||||
const { data: infoData } = useGetInfo({
|
useAuthForm('login');
|
||||||
query: {
|
|
||||||
staleTime: Infinity,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCheckingAuth && isAuthenticated) {
|
if (!isCheckingAuth && isAuthenticated) {
|
||||||
@@ -46,19 +16,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(username, password);
|
|
||||||
} catch (_err) {
|
|
||||||
showError('Invalid credentials');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFormView
|
<AuthFormView
|
||||||
username={username}
|
username={username}
|
||||||
@@ -66,7 +23,7 @@ export default function LoginPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onUsernameChange={setUsername}
|
onUsernameChange={setUsername}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={setPassword}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={submit}
|
||||||
submitLabel="Login"
|
submitLabel="Login"
|
||||||
submittingLabel="Logging in..."
|
submittingLabel="Logging in..."
|
||||||
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
|
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Progress } from '../generated/model';
|
import type { Progress } from '../generated/model';
|
||||||
import { Pagination } from '../components';
|
import { Pagination } from '../components';
|
||||||
import { Table, type Column } from '../components/Table';
|
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;
|
const PROGRESS_PAGE_SIZE = 15;
|
||||||
|
|
||||||
export default function ProgressPage() {
|
export default function ProgressPage() {
|
||||||
const [page, setPage] = useState(1);
|
const { page, setPage } = usePaginatedList();
|
||||||
const limit = PROGRESS_PAGE_SIZE;
|
const limit = PROGRESS_PAGE_SIZE;
|
||||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||||
const response = data?.status === 200 ? data.data : undefined;
|
const response = data;
|
||||||
const progress = response?.progress ?? [];
|
const progress = response?.progress ?? [];
|
||||||
|
|
||||||
const columns: Column<Progress>[] = [
|
const columns: Column<Progress>[] = [
|
||||||
{
|
documentColumn,
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'device_name',
|
id: 'device_name',
|
||||||
header: 'Device Name',
|
header: 'Device Name',
|
||||||
@@ -32,12 +25,12 @@ export default function ProgressPage() {
|
|||||||
{
|
{
|
||||||
id: 'percentage',
|
id: 'percentage',
|
||||||
header: 'Percentage',
|
header: 'Percentage',
|
||||||
render: row => (typeof row.percentage === 'number' ? `${Math.round(row.percentage)}%` : '0%'),
|
render: row => `${Math.round(row.percentage)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'created_at',
|
id: 'created_at',
|
||||||
header: 'Created At',
|
header: 'Created At',
|
||||||
render: row => (row.created_at ? new Date(row.created_at).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 { Link, useParams } from 'react-router-dom';
|
||||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||||
import { LoadingState } from '../components/LoadingState';
|
import { LoadingState } from '../components/LoadingState';
|
||||||
|
import { SegmentedControl } from '../components/SegmentedControl';
|
||||||
import { CloseIcon } from '../icons';
|
import { CloseIcon } from '../icons';
|
||||||
import {
|
import {
|
||||||
getReaderColorScheme,
|
READER_COLOR_SCHEMES,
|
||||||
getReaderDevice,
|
READER_FONT_FAMILIES,
|
||||||
getReaderFontFamily,
|
|
||||||
getReaderFontSize,
|
|
||||||
setReaderColorScheme,
|
|
||||||
setReaderFontFamily,
|
|
||||||
setReaderFontSize,
|
|
||||||
type ReaderColorScheme,
|
type ReaderColorScheme,
|
||||||
type ReaderFontFamily,
|
type ReaderFontFamily,
|
||||||
|
getReaderDevice,
|
||||||
|
useLocalSetting,
|
||||||
} from '../utils/localSettings';
|
} from '../utils/localSettings';
|
||||||
import { useEpubReader } from '../hooks/useEpubReader';
|
import { useEpubReader } from '../hooks/useEpubReader';
|
||||||
|
|
||||||
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
|
||||||
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
|
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() {
|
export default function ReaderPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
||||||
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
||||||
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
|
const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
|
||||||
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
|
const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
|
||||||
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
|
const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
|
||||||
|
|
||||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
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 || '', {
|
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
|
||||||
query: {
|
query: {
|
||||||
retry: false,
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: Boolean(id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
|
const doc = documentResponse?.document;
|
||||||
const progress = progressResponse?.status === 200 ? progressResponse.data.progress : undefined;
|
const progress = progressResponse?.progress;
|
||||||
|
|
||||||
const deviceId = defaultDeviceId;
|
const deviceId = defaultDeviceId;
|
||||||
const deviceName = defaultDeviceName;
|
const deviceName = defaultDeviceName;
|
||||||
@@ -84,22 +88,17 @@ export default function ReaderPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document?.title) {
|
if (doc?.title) {
|
||||||
window.document.title = `AnthoLume - Reader - ${document.title}`;
|
document.title = `AnthoLume - Reader - ${doc.title}`;
|
||||||
}
|
}
|
||||||
}, [document?.title]);
|
}, [doc?.title]);
|
||||||
|
|
||||||
const { setTheme } = reader;
|
|
||||||
useEffect(() => {
|
|
||||||
setTheme({ colorScheme, fontFamily, fontSize });
|
|
||||||
}, [colorScheme, fontFamily, fontSize, setTheme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTopBarOpen || isBottomBarOpen) {
|
if (isTopBarOpen || isBottomBarOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeElement = window.document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
if (activeElement instanceof HTMLElement) {
|
if (activeElement instanceof HTMLElement) {
|
||||||
activeElement.blur();
|
activeElement.blur();
|
||||||
}
|
}
|
||||||
@@ -109,7 +108,7 @@ export default function ReaderPage() {
|
|||||||
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
|
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>;
|
return <div className="p-6 text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,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="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 items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-start 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
|
<img
|
||||||
className="h-28 w-20 rounded object-cover shadow-sm"
|
className="h-28 w-20 rounded object-cover shadow-sm"
|
||||||
src={`/api/v1/documents/${document.id}/cover`}
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
alt={`${document.title} cover`}
|
alt={`${doc.title} cover`}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
|
<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="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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<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"
|
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
@@ -223,48 +222,32 @@ export default function ReaderPage() {
|
|||||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
||||||
Theme
|
Theme
|
||||||
</p>
|
</p>
|
||||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
<SegmentedControl<ReaderColorScheme>
|
||||||
{colorSchemes.map(option => (
|
variant="unstyled"
|
||||||
<button
|
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
key={option}
|
ariaLabel="Reader theme"
|
||||||
type="button"
|
value={colorScheme}
|
||||||
onClick={() => {
|
onChange={setColorScheme}
|
||||||
setColorSchemeState(option);
|
buttonClassName={`${READER_SEGMENT_BUTTON} capitalize`}
|
||||||
setReaderColorScheme(option);
|
activeClassName={READER_SEGMENT_ACTIVE}
|
||||||
}}
|
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
options={READER_COLOR_SCHEMES.map(value => ({ value, label: value }))}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
<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">
|
<SegmentedControl<ReaderFontFamily>
|
||||||
{fontFamilies.map(option => (
|
variant="unstyled"
|
||||||
<button
|
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
key={option}
|
ariaLabel="Reader font"
|
||||||
type="button"
|
value={fontFamily}
|
||||||
onClick={() => {
|
onChange={setFontFamily}
|
||||||
setFontFamilyState(option);
|
buttonClassName={READER_SEGMENT_BUTTON}
|
||||||
setReaderFontFamily(option);
|
activeClassName={READER_SEGMENT_ACTIVE}
|
||||||
}}
|
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||||
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
|
options={READER_FONT_FAMILIES.map(value => ({ value, label: value }))}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -274,11 +257,7 @@ export default function ReaderPage() {
|
|||||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))}
|
||||||
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
|
|
||||||
setFontSizeState(nextSize);
|
|
||||||
setReaderFontSize(nextSize);
|
|
||||||
}}
|
|
||||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
@@ -288,11 +267,7 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))}
|
||||||
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
|
|
||||||
setFontSizeState(nextSize);
|
|
||||||
setReaderFontSize(nextSize);
|
|
||||||
}}
|
|
||||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -51,17 +51,14 @@ describe('RegisterPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: vi.fn(),
|
showError: vi.fn(),
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
registration_enabled: true,
|
registration_enabled: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as ReturnType<typeof useGetInfo>);
|
} as ReturnType<typeof useGetInfo>);
|
||||||
});
|
});
|
||||||
@@ -113,8 +110,8 @@ describe('RegisterPage', () => {
|
|||||||
showInfo: vi.fn(),
|
showInfo: vi.fn(),
|
||||||
showWarning: vi.fn(),
|
showWarning: vi.fn(),
|
||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
|
updateToast: vi.fn(),
|
||||||
removeToast: vi.fn(),
|
removeToast: vi.fn(),
|
||||||
clearToasts: vi.fn(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -128,7 +125,7 @@ describe('RegisterPage', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(showErrorMock).toHaveBeenCalledWith('Registration failed');
|
expect(showErrorMock).toHaveBeenCalledWith('failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,12 +152,9 @@ describe('RegisterPage', () => {
|
|||||||
|
|
||||||
it('redirects to login when registration is disabled', async () => {
|
it('redirects to login when registration is disabled', async () => {
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
registration_enabled: false,
|
registration_enabled: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as ReturnType<typeof useGetInfo>);
|
} as ReturnType<typeof useGetInfo>);
|
||||||
|
|
||||||
@@ -177,12 +171,9 @@ describe('RegisterPage', () => {
|
|||||||
|
|
||||||
it('disables the form when registration is disabled', () => {
|
it('disables the form when registration is disabled', () => {
|
||||||
mockedUseGetInfo.mockReturnValue({
|
mockedUseGetInfo.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
registration_enabled: false,
|
registration_enabled: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as ReturnType<typeof useGetInfo>);
|
} as ReturnType<typeof useGetInfo>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import { useState, SyntheticEvent, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useAuthForm } from '../hooks/useAuthForm';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||||
import { getRegistrationEnabled } from './LoginPage';
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState('');
|
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { register, isAuthenticated, isCheckingAuth } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showError } = useToasts();
|
const {
|
||||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
username,
|
||||||
query: {
|
password,
|
||||||
staleTime: Infinity,
|
isLoading,
|
||||||
},
|
isLoadingInfo,
|
||||||
});
|
registrationEnabled,
|
||||||
|
setUsername,
|
||||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
setPassword,
|
||||||
|
submit,
|
||||||
|
} = useAuthForm('register');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCheckingAuth && isAuthenticated) {
|
if (!isCheckingAuth && isAuthenticated) {
|
||||||
@@ -33,19 +29,6 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
||||||
|
|
||||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await register(username, password);
|
|
||||||
} catch (_err) {
|
|
||||||
showError(registrationEnabled ? 'Registration failed' : 'Registration is disabled');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFormView
|
<AuthFormView
|
||||||
username={username}
|
username={username}
|
||||||
@@ -53,7 +36,7 @@ export default function RegisterPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onUsernameChange={setUsername}
|
onUsernameChange={setUsername}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={setPassword}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={submit}
|
||||||
submitLabel="Register"
|
submitLabel="Register"
|
||||||
submittingLabel="Registering..."
|
submittingLabel="Registering..."
|
||||||
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ describe('SearchPage', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedUseGetSearch.mockReturnValue({
|
mockedUseGetSearch.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
results: [],
|
results: [],
|
||||||
},
|
},
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as ReturnType<typeof useGetSearch>);
|
} as ReturnType<typeof useGetSearch>);
|
||||||
});
|
});
|
||||||
@@ -66,8 +63,6 @@ describe('SearchPage', () => {
|
|||||||
|
|
||||||
it('renders search results from the generated hook response', () => {
|
it('renders search results from the generated hook response', () => {
|
||||||
mockedUseGetSearch.mockReturnValue({
|
mockedUseGetSearch.mockReturnValue({
|
||||||
data: {
|
|
||||||
status: 200,
|
|
||||||
data: {
|
data: {
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
@@ -77,11 +72,9 @@ describe('SearchPage', () => {
|
|||||||
series: 'Earthsea',
|
series: 'Earthsea',
|
||||||
file_type: 'epub',
|
file_type: 'epub',
|
||||||
file_size: '1 MB',
|
file_size: '1 MB',
|
||||||
upload_date: '2025-01-01',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as ReturnType<typeof useGetSearch>);
|
} as ReturnType<typeof useGetSearch>);
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,20 @@ import { useState, SyntheticEvent } from 'react';
|
|||||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||||
import type { SearchItem } from '../generated/model';
|
import type { SearchItem } from '../generated/model';
|
||||||
import { Button } from '../components/Button';
|
import { Button, Table, type Column, TextInput, IconInput } from '../components';
|
||||||
import { TextInput } from '../components';
|
|
||||||
import { inputClassName } from '../components/TextInput';
|
import { inputClassName } from '../components/TextInput';
|
||||||
import { Table, type Column } from '../components/Table';
|
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
import { Search2Icon, BookIcon } from '../icons';
|
||||||
|
|
||||||
const searchColumns: Column<SearchItem>[] = [
|
const searchColumns: Column<SearchItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'download',
|
id: 'document',
|
||||||
header: '',
|
header: 'Document',
|
||||||
className: 'w-12 text-content-muted',
|
render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}`,
|
||||||
render: () => (
|
|
||||||
<button className="hover:text-primary-600" title="Download">
|
|
||||||
<DownloadIcon size={15} />
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ 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: 'series', header: 'Series', render: item => item.series || 'N/A' },
|
||||||
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
|
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
|
||||||
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
|
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
|
||||||
{
|
|
||||||
id: 'date',
|
|
||||||
header: 'Date',
|
|
||||||
className: 'hidden md:table-cell',
|
|
||||||
render: item => item.upload_date || 'N/A',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SearchPageViewProps {
|
interface SearchPageViewProps {
|
||||||
@@ -42,26 +28,6 @@ interface SearchPageViewProps {
|
|||||||
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void;
|
onSubmit: (e: SyntheticEvent<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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchPageView({
|
export function SearchPageView({
|
||||||
query,
|
query,
|
||||||
source,
|
source,
|
||||||
@@ -77,22 +43,16 @@ export function SearchPageView({
|
|||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<Search2Icon size={15} hoverable={false} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => onQueryChange(e.target.value)}
|
onChange={e => onQueryChange(e.target.value)}
|
||||||
placeholder="Query"
|
placeholder="Query"
|
||||||
/>
|
/>
|
||||||
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<IconInput className="min-w-[12em]" icon={<BookIcon size={15} />}>
|
||||||
<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-xs">
|
|
||||||
<BookIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
||||||
@@ -101,12 +61,10 @@ export function SearchPageView({
|
|||||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</IconInput>
|
||||||
<div className="lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,7 +86,7 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const results = getSearchResults(data);
|
const results = data?.results ?? [];
|
||||||
|
|
||||||
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { useState, useEffect, SyntheticEvent } from 'react';
|
import { useState, useEffect, SyntheticEvent } from 'react';
|
||||||
import { LoadingState, TextInput } from '../components';
|
import { Button, LoadingState, Table, type Column, TextInput, IconInput } from '../components';
|
||||||
import { inputClassName } from '../components/TextInput';
|
import { inputClassName } from '../components/TextInput';
|
||||||
import { Table, type Column } from '../components/Table';
|
|
||||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Device } from '../generated/model';
|
import type { Device, SettingsResponse } from '../generated/model';
|
||||||
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
import type { ThemeMode } from '../utils/localSettings';
|
import type { ThemeMode } from '../utils/localSettings';
|
||||||
|
import { formatDateTime } from '../utils/formatters';
|
||||||
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
|
|
||||||
|
|
||||||
const deviceColumns: Column<Device>[] = [
|
const deviceColumns: Column<Device>[] = [
|
||||||
{
|
{
|
||||||
@@ -20,8 +17,12 @@ const deviceColumns: Column<Device>[] = [
|
|||||||
className: 'pl-0',
|
className: 'pl-0',
|
||||||
render: device => device.device_name || 'Unknown',
|
render: device => device.device_name || 'Unknown',
|
||||||
},
|
},
|
||||||
{ id: 'last_synced', header: 'Last Sync', render: device => formatDeviceDate(device.last_synced) },
|
{
|
||||||
{ id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) },
|
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 }> = [
|
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
|
||||||
@@ -30,108 +31,69 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
|
|||||||
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
|
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
function ProfileCard({ settings }: { settings?: SettingsResponse }) {
|
||||||
const { data, isLoading } = useGetSettings();
|
|
||||||
const updateSettings = useUpdateSettings();
|
|
||||||
const settingsData = data?.status === 200 ? data.data : null;
|
|
||||||
const { showError } = useToasts();
|
|
||||||
const toastMutationOptions = useMutationWithToast();
|
|
||||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [timezone, setTimezone] = useState('UTC');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
|
|
||||||
setTimezone(settingsData.timezone);
|
|
||||||
}
|
|
||||||
}, [settingsData]);
|
|
||||||
|
|
||||||
const handlePasswordSubmit = (e: SyntheticEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!password || !newPassword) {
|
|
||||||
showError('Please enter both current and new password');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings.mutate(
|
|
||||||
{ data: { password, new_password: newPassword } },
|
|
||||||
toastMutationOptions({
|
|
||||||
success: 'Password updated successfully',
|
|
||||||
error: 'Failed to update password',
|
|
||||||
onSuccess: () => {
|
|
||||||
setPassword('');
|
|
||||||
setNewPassword('');
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimezoneSubmit = (e: SyntheticEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
updateSettings.mutate(
|
|
||||||
{ data: { timezone } },
|
|
||||||
toastMutationOptions({
|
|
||||||
success: 'Timezone updated successfully',
|
|
||||||
error: 'Failed to update timezone',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
<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} />
|
<UserIcon size={60} />
|
||||||
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
|
<p className="text-lg text-content">{settings?.user.username || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex grow flex-col gap-4">
|
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">
|
<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>
|
<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}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<PasswordIcon size={15} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<PasswordIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<PasswordIcon size={15} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<PasswordIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
placeholder="New Password"
|
placeholder="New Password"
|
||||||
/>
|
/>
|
||||||
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<div className="lg:w-60">
|
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 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 className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -170,17 +132,31 @@ export default function SettingsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||||
<div className="relative flex grow">
|
<IconInput className="grow" icon={<ClockIcon size={15} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<ClockIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={timezone || 'UTC'}
|
value={timezone || 'UTC'}
|
||||||
onChange={e => setTimezone(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
>
|
>
|
||||||
<option value="UTC">UTC</option>
|
<option value="UTC">UTC</option>
|
||||||
@@ -194,19 +170,77 @@ export default function SettingsPage() {
|
|||||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</IconInput>
|
||||||
<div className="lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
<Button variant="secondary" type="submit">
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevicesSection({ devices }: { devices: Device[] }) {
|
||||||
|
return (
|
||||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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>
|
<p className="text-lg font-semibold text-content">Devices</p>
|
||||||
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" />
|
<Table columns={deviceColumns} data={devices} rowKey="id" />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data, isLoading } = useGetSettings();
|
||||||
|
const updateSettings = useUpdateSettings();
|
||||||
|
const settingsData = data;
|
||||||
|
const { showError } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
const runWithToast = useToastMutation();
|
||||||
|
|
||||||
|
const [timezone, setTimezone] = useState('UTC');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
|
||||||
|
setTimezone(settingsData.timezone);
|
||||||
|
}
|
||||||
|
}, [settingsData]);
|
||||||
|
|
||||||
|
const updatePassword = async (password: string, newPassword: string) => {
|
||||||
|
if (!password || !newPassword) {
|
||||||
|
showError('Please enter both current and new password');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runWithToast(
|
||||||
|
() => updateSettings.mutateAsync({ data: { password, new_password: newPassword } }),
|
||||||
|
{
|
||||||
|
success: 'Password updated successfully',
|
||||||
|
error: 'Failed to update password',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTimezone = () => {
|
||||||
|
updateSettings.mutate(
|
||||||
|
{ data: { timezone } },
|
||||||
|
toastMutationOptions({
|
||||||
|
success: 'Timezone updated successfully',
|
||||||
|
error: 'Failed to update timezone',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||||
|
<ProfileCard settings={settingsData} />
|
||||||
|
<div className="flex grow flex-col gap-4">
|
||||||
|
<PasswordSection onSubmit={updatePassword} />
|
||||||
|
<AppearanceSection />
|
||||||
|
<TimezoneSection timezone={timezone} onChange={setTimezone} onSubmit={updateTimezone} />
|
||||||
|
<DevicesSection devices={settingsData?.devices ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -8,9 +7,8 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
getThemeMode,
|
useLocalSetting,
|
||||||
setThemeMode,
|
readLocalSetting,
|
||||||
LOCAL_SETTINGS_KEY,
|
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from '../utils/localSettings';
|
} from '../utils/localSettings';
|
||||||
|
|
||||||
@@ -49,23 +47,23 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeThemeMode(): ResolvedThemeMode {
|
export function initializeThemeMode(): ResolvedThemeMode {
|
||||||
return applyThemeMode(getThemeMode());
|
return applyThemeMode(readLocalSetting('themeMode', 'system'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
|
const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
|
||||||
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
|
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 sets `themeModeState`.
|
// 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(() => {
|
useEffect(() => {
|
||||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
setResolvedThemeMode(applyThemeMode(themeMode));
|
||||||
}, [themeModeState]);
|
}, [themeMode]);
|
||||||
|
|
||||||
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
|
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeMode` is unchanged.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || themeModeState !== 'system') {
|
if (typeof window === 'undefined' || themeMode !== 'system') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,39 +76,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
||||||
};
|
};
|
||||||
}, [themeModeState]);
|
}, [themeMode]);
|
||||||
|
|
||||||
// Cross-Tab Sync - Another tab changed the persisted theme; adopt its mode and let the mode effect apply it.
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
|
||||||
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setThemeModeState(getThemeMode());
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', handleStorage);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
|
|
||||||
setThemeMode(nextThemeMode);
|
|
||||||
setThemeModeState(nextThemeMode);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
themeMode: themeModeState,
|
themeMode,
|
||||||
resolvedThemeMode,
|
resolvedThemeMode,
|
||||||
setThemeMode: updateThemeMode,
|
setThemeMode,
|
||||||
}),
|
}),
|
||||||
[resolvedThemeMode, themeModeState, updateThemeMode]
|
[resolvedThemeMode, themeMode, setThemeMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
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;
|
||||||
|
}
|
||||||
@@ -6,25 +6,8 @@ describe('getErrorMessage', () => {
|
|||||||
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers response.data.message over top-level message', () => {
|
it('reads the top-level message from API error bodies', () => {
|
||||||
expect(
|
expect(getErrorMessage({ code: 401, message: 'Unauthorized' })).toBe('Unauthorized');
|
||||||
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('uses the fallback for null, empty, and unknown values', () => {
|
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(undefined, 'Fallback message')).toBe('Fallback message');
|
||||||
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
||||||
expect(getErrorMessage({ message: ' ' }, '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 {
|
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === 'object' && error !== null) {
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||||
const errorWithResponse = error as {
|
const { message } = error as { message?: unknown };
|
||||||
message?: unknown;
|
if (typeof message === 'string' && message.trim() !== '') {
|
||||||
response?: {
|
return message;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { formatNumber, formatDuration } from './formatters';
|
import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters';
|
||||||
|
|
||||||
describe('formatNumber', () => {
|
describe('formatNumber', () => {
|
||||||
it('formats zero', () => {
|
it('formats zero', () => {
|
||||||
@@ -33,7 +33,6 @@ describe('formatNumber', () => {
|
|||||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatDuration', () => {
|
describe('formatDuration', () => {
|
||||||
@@ -61,5 +60,21 @@ describe('formatDuration', () => {
|
|||||||
it('formats days, hours, minutes, and seconds', () => {
|
it('formats days, hours, minutes, and seconds', () => {
|
||||||
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ export function formatDuration(seconds: number): string {
|
|||||||
return parts.join(' ');
|
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 {
|
export function formatUtcDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
export type DocumentsViewMode = 'grid' | 'list';
|
|
||||||
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
// Value arrays are the single source of truth; the union types derive from them so the
|
||||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
// 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];
|
||||||
|
|
||||||
|
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';
|
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||||
|
|
||||||
interface LocalSettings {
|
interface LocalSettingsMap {
|
||||||
themeMode?: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
documentsViewMode?: DocumentsViewMode;
|
documentsViewMode: DocumentsViewMode;
|
||||||
readerColorScheme?: ReaderColorScheme;
|
readerColorScheme: ReaderColorScheme;
|
||||||
readerFontFamily?: ReaderFontFamily;
|
readerFontFamily: ReaderFontFamily;
|
||||||
readerFontSize?: number;
|
readerFontSize: number;
|
||||||
readerDeviceId?: string;
|
readerDeviceId: string;
|
||||||
readerDeviceName?: string;
|
readerDeviceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocalSettingKey = keyof LocalSettingsMap;
|
||||||
|
|
||||||
function canUseLocalStorage(): boolean {
|
function canUseLocalStorage(): boolean {
|
||||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLocalSettings(): LocalSettings {
|
function readRawSettings(): Record<string, unknown> {
|
||||||
if (!canUseLocalStorage()) {
|
if (!canUseLocalStorage()) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -37,111 +50,100 @@ function readLocalSettings(): LocalSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalSettings(settings: LocalSettings): void {
|
function writeRawSettings(settings: Record<string, unknown>): void {
|
||||||
if (!canUseLocalStorage()) {
|
if (!canUseLocalStorage()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLocalSettings(partialSettings: LocalSettings): void {
|
function isValidValue(key: LocalSettingKey, value: unknown): boolean {
|
||||||
writeLocalSettings({
|
switch (key) {
|
||||||
...readLocalSettings(),
|
case 'themeMode':
|
||||||
...partialSettings,
|
return (THEME_MODES as readonly unknown[]).includes(value);
|
||||||
});
|
case 'documentsViewMode':
|
||||||
}
|
return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
|
||||||
|
case 'readerColorScheme':
|
||||||
export function getThemeMode(): ThemeMode {
|
return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
|
||||||
const settings = readLocalSettings();
|
case 'readerFontFamily':
|
||||||
return settings.themeMode === 'light' || settings.themeMode === 'dark'
|
return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
|
||||||
? settings.themeMode
|
case 'readerFontSize':
|
||||||
: 'system';
|
return typeof value === 'number' && value > 0;
|
||||||
}
|
case 'readerDeviceId':
|
||||||
|
case 'readerDeviceName':
|
||||||
export function setThemeMode(themeMode: ThemeMode): void {
|
return typeof value === 'string' && value.length > 0;
|
||||||
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;
|
|
||||||
default:
|
default:
|
||||||
return 'tan';
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
|
export function readLocalSetting<K extends LocalSettingKey>(
|
||||||
updateLocalSettings({ readerColorScheme });
|
key: K,
|
||||||
|
defaultValue: LocalSettingsMap[K]
|
||||||
|
): LocalSettingsMap[K] {
|
||||||
|
const value = readRawSettings()[key];
|
||||||
|
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReaderFontFamily(): ReaderFontFamily {
|
export function writeLocalSetting<K extends LocalSettingKey>(
|
||||||
const settings = readLocalSettings();
|
key: K,
|
||||||
switch (settings.readerFontFamily) {
|
value: LocalSettingsMap[K]
|
||||||
case 'Serif':
|
): void {
|
||||||
case 'Open Sans':
|
writeRawSettings({ ...readRawSettings(), [key]: value });
|
||||||
case 'Arbutus Slab':
|
|
||||||
case 'Lato':
|
|
||||||
return settings.readerFontFamily;
|
|
||||||
default:
|
|
||||||
return 'Serif';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
|
/**
|
||||||
updateLocalSettings({ readerFontFamily });
|
* 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 getReaderFontSize(): number {
|
*/
|
||||||
const settings = readLocalSettings();
|
export function useLocalSetting<K extends LocalSettingKey>(
|
||||||
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
|
key: K,
|
||||||
? settings.readerFontSize
|
defaultValue: LocalSettingsMap[K]
|
||||||
: 1;
|
) {
|
||||||
}
|
const [value, setValue] = useState<LocalSettingsMap[K]>(() =>
|
||||||
|
readLocalSetting(key, defaultValue)
|
||||||
export function setReaderFontSize(readerFontSize: number): void {
|
);
|
||||||
updateLocalSettings({ readerFontSize });
|
|
||||||
|
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 } {
|
export function getReaderDevice(): { id: string; name: string } {
|
||||||
const settings = readLocalSettings();
|
const raw = readRawSettings();
|
||||||
const id =
|
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
|
||||||
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
|
? (raw.readerDeviceId as string)
|
||||||
? settings.readerDeviceId
|
|
||||||
: crypto.randomUUID();
|
: crypto.randomUUID();
|
||||||
const name =
|
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
|
||||||
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
|
? (raw.readerDeviceName as string)
|
||||||
? settings.readerDeviceName
|
|
||||||
: 'Web Reader';
|
: 'Web Reader';
|
||||||
|
|
||||||
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
|
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
|
||||||
updateLocalSettings({
|
writeLocalSetting('readerDeviceId', id);
|
||||||
readerDeviceId: id,
|
writeLocalSetting('readerDeviceName', name);
|
||||||
readerDeviceName: name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id, name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setReaderDevice(name: string, id?: string): void {
|
|
||||||
updateLocalSettings({
|
|
||||||
readerDeviceId: id ?? crypto.randomUUID(),
|
|
||||||
readerDeviceName: name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ type SearchItem struct {
|
|||||||
Series string
|
Series string
|
||||||
FileType string
|
FileType string
|
||||||
FileSize string
|
FileSize string
|
||||||
UploadDate string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchFunc func(query string) (searchResults []SearchItem, err error)
|
type searchFunc func(query string) (searchResults []SearchItem, err error)
|
||||||
|
|||||||
Reference in New Issue
Block a user