Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09daf6d511 | |||
| 9158648f3f | |||
| 1ff96ee9c8 | |||
| 457eb550e4 | |||
| 5a8b773a0b | |||
| c9c9563a1f | |||
| 232a821dc0 |
@@ -25,6 +25,8 @@ Regenerate:
|
||||
- `cd frontend && pnpm run generate:api`
|
||||
|
||||
Notes:
|
||||
- `format: date-time` diverges by generator: **oapi-codegen (Go)** emits `time.Time`, while **orval (TS)** keeps `string`. Adding it to an existing string field will break Go handlers until they supply a `time.Time` (see `parseTime` / `parseTimeAny` in `api/v1/utils.go`).
|
||||
- Marking a schema field `required` flips the generated Go type from a pointer to a value; update every handler build site to drop the `&`. Only require a field when **all** endpoints sharing that schema populate it — `Progress` is populated with different subsets by its list vs single handlers.
|
||||
- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly.
|
||||
|
||||
Examples of generated files:
|
||||
|
||||
+1
-9
@@ -68,18 +68,10 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
|
||||
|
||||
apiActivities := make([]Activity, len(activities))
|
||||
for i, a := range activities {
|
||||
// Convert StartTime from interface{} to string
|
||||
startTimeStr := ""
|
||||
if a.StartTime != nil {
|
||||
if str, ok := a.StartTime.(string); ok {
|
||||
startTimeStr = str
|
||||
}
|
||||
}
|
||||
|
||||
apiActivities[i] = Activity{
|
||||
DocumentId: a.DocumentID,
|
||||
DeviceId: a.DeviceID,
|
||||
StartTime: startTimeStr,
|
||||
StartTime: parseTimeAny(a.StartTime),
|
||||
Title: a.Title,
|
||||
Author: a.Author,
|
||||
Duration: a.Duration,
|
||||
|
||||
+29
-30
@@ -145,15 +145,15 @@ func (e GetSearchParamsSource) Valid() bool {
|
||||
|
||||
// Activity defines model for Activity.
|
||||
type Activity struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
DeviceId string `json:"device_id"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Duration int64 `json:"duration"`
|
||||
EndPercentage float32 `json:"end_percentage"`
|
||||
ReadPercentage float32 `json:"read_percentage"`
|
||||
StartPercentage float32 `json:"start_percentage"`
|
||||
StartTime string `json:"start_time"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
DeviceId string `json:"device_id"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Duration int64 `json:"duration"`
|
||||
EndPercentage float32 `json:"end_percentage"`
|
||||
ReadPercentage float32 `json:"read_percentage"`
|
||||
StartPercentage float32 `json:"start_percentage"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ActivityResponse defines model for ActivityResponse.
|
||||
@@ -200,10 +200,10 @@ type DatabaseInfo struct {
|
||||
|
||||
// Device defines model for Device.
|
||||
type Device struct {
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
DeviceName *string `json:"device_name,omitempty"`
|
||||
Id *string `json:"id,omitempty"`
|
||||
LastSynced *time.Time `json:"last_synced,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Id string `json:"id"`
|
||||
LastSynced time.Time `json:"last_synced"`
|
||||
}
|
||||
|
||||
// DirectoryItem defines model for DirectoryItem.
|
||||
@@ -356,15 +356,15 @@ type OperationType string
|
||||
|
||||
// Progress defines model for Progress.
|
||||
type Progress struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
DeviceName *string `json:"device_name,omitempty"`
|
||||
DocumentId *string `json:"document_id,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
Progress *string `json:"progress,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UserId *string `json:"user_id,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DocumentId string `json:"document_id"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Progress *string `json:"progress,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UserId string `json:"user_id"`
|
||||
}
|
||||
|
||||
// ProgressListResponse defines model for ProgressListResponse.
|
||||
@@ -384,14 +384,13 @@ type ProgressResponse struct {
|
||||
|
||||
// SearchItem defines model for SearchItem.
|
||||
type SearchItem struct {
|
||||
Author *string `json:"author,omitempty"`
|
||||
FileSize *string `json:"file_size,omitempty"`
|
||||
FileType *string `json:"file_type,omitempty"`
|
||||
Id *string `json:"id,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Series *string `json:"series,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
UploadDate *string `json:"upload_date,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
FileSize *string `json:"file_size,omitempty"`
|
||||
FileType *string `json:"file_type,omitempty"`
|
||||
Id *string `json:"id,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Series *string `json:"series,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResponse defines model for SearchResponse.
|
||||
|
||||
+12
-2
@@ -106,6 +106,12 @@ components:
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- device_name
|
||||
- percentage
|
||||
- document_id
|
||||
- user_id
|
||||
- created_at
|
||||
|
||||
UpdateProgressRequest:
|
||||
type: object
|
||||
@@ -198,6 +204,7 @@ components:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
title:
|
||||
type: string
|
||||
author:
|
||||
@@ -240,8 +247,6 @@ components:
|
||||
type: string
|
||||
file_size:
|
||||
type: string
|
||||
upload_date:
|
||||
type: string
|
||||
|
||||
SearchResponse:
|
||||
type: object
|
||||
@@ -384,6 +389,11 @@ components:
|
||||
last_synced:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- device_name
|
||||
- created_at
|
||||
- last_synced
|
||||
|
||||
SettingsResponse:
|
||||
type: object
|
||||
|
||||
+10
-10
@@ -68,11 +68,11 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq
|
||||
apiProgress[i] = Progress{
|
||||
Title: row.Title,
|
||||
Author: row.Author,
|
||||
DeviceName: &row.DeviceName,
|
||||
Percentage: &row.Percentage,
|
||||
DocumentId: &row.DocumentID,
|
||||
UserId: &row.UserID,
|
||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||
DeviceName: row.DeviceName,
|
||||
Percentage: row.Percentage,
|
||||
DocumentId: row.DocumentID,
|
||||
UserId: row.UserID,
|
||||
CreatedAt: parseTimeAny(row.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObje
|
||||
}
|
||||
|
||||
apiProgress := Progress{
|
||||
DeviceName: &row.DeviceName,
|
||||
DeviceName: row.DeviceName,
|
||||
DeviceId: &row.DeviceID,
|
||||
Percentage: &row.Percentage,
|
||||
Percentage: row.Percentage,
|
||||
Progress: &row.Progress,
|
||||
DocumentId: &row.DocumentID,
|
||||
UserId: &row.UserID,
|
||||
CreatedAt: parseTimePtr(row.CreatedAt),
|
||||
DocumentId: row.DocumentID,
|
||||
UserId: row.UserID,
|
||||
CreatedAt: parseTime(row.CreatedAt),
|
||||
}
|
||||
|
||||
response := ProgressResponse{
|
||||
|
||||
@@ -38,7 +38,6 @@ func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject)
|
||||
Series: ptrOf(item.Series),
|
||||
FileType: ptrOf(item.FileType),
|
||||
FileSize: ptrOf(item.FileSize),
|
||||
UploadDate: ptrOf(item.UploadDate),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -29,10 +29,10 @@ func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObje
|
||||
apiDevices := make([]Device, len(devices))
|
||||
for i, device := range devices {
|
||||
apiDevices[i] = Device{
|
||||
Id: &device.ID,
|
||||
DeviceName: &device.DeviceName,
|
||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||
LastSynced: parseTimePtr(device.LastSynced),
|
||||
Id: device.ID,
|
||||
DeviceName: device.DeviceName,
|
||||
CreatedAt: parseTimeAny(device.CreatedAt),
|
||||
LastSynced: parseTimeAny(device.LastSynced),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsReque
|
||||
apiDevices := make([]Device, len(devices))
|
||||
for i, device := range devices {
|
||||
apiDevices[i] = Device{
|
||||
Id: &device.ID,
|
||||
DeviceName: &device.DeviceName,
|
||||
CreatedAt: parseTimePtr(device.CreatedAt),
|
||||
LastSynced: parseTimePtr(device.LastSynced),
|
||||
Id: device.ID,
|
||||
DeviceName: device.DeviceName,
|
||||
CreatedAt: parseTimeAny(device.CreatedAt),
|
||||
LastSynced: parseTimeAny(device.LastSynced),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,14 @@ func parseTime(s string) time.Time {
|
||||
return t
|
||||
}
|
||||
|
||||
// parseTimeAny parses an interface{} (from SQL) to time.Time, zero on failure.
|
||||
func parseTimeAny(v interface{}) time.Time {
|
||||
if s, ok := v.(string); ok {
|
||||
return parseTime(s)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// parseTimePtr parses an interface{} (from SQL) to *time.Time
|
||||
func parseTimePtr(v interface{}) *time.Time {
|
||||
if v == nil {
|
||||
|
||||
+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`.
|
||||
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
|
||||
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
|
||||
- Prefer `LoadingState` for result-area loading indicators (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.
|
||||
- Semantic colors map to runtime CSS variables (`--color-x: rgb(var(--x))`) via `@theme inline`; light/dark values live in `:root` / `.dark` in `src/index.css`. Dark mode is class-based via `@custom-variant dark`, toggled by `ThemeProvider`.
|
||||
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
|
||||
- Reuse shared primitives instead of re-rolling them: `formatDate` / `formatDateTime` (`src/utils/formatters.ts`) for user-facing timestamps (`formatUtcDate` is intentionally UTC, for graph day-buckets only); `SegmentedControl` for toggle groups (default `pill` variant needs only `options`/`value`/`onChange`; pass `variant="unstyled"` for bespoke shapes like grids/inline text); `usePaginatedList` + `documentColumn` for paginated list/table pages.
|
||||
|
||||
## 3) Generated API client
|
||||
|
||||
@@ -39,9 +43,10 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
|
||||
### Important behavior
|
||||
|
||||
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
||||
- Do not assume non-2xx responses throw.
|
||||
- Check `response.status` and response shape before treating a request as successful.
|
||||
- The generated client returns the documented **success body directly** and throws `ApiError` (`src/utils/apiFetch.ts`) for non-2xx responses.
|
||||
- Use React Query's native error flow (`isError`, `error`, `onError`) instead of status narrowing.
|
||||
- Use `getErrorMessage` (`src/utils/errors.ts`) to display caught errors; `ApiError.message` already contains the server-provided error message when present.
|
||||
- Centralize mutation error/success feedback via `useMutationWithToast` or `useToastMutation` rather than inline toast/catch duplication.
|
||||
|
||||
## 4) Auth / Query State
|
||||
|
||||
@@ -73,6 +78,7 @@ Also follow the repository root guide at `../AGENTS.md`.
|
||||
- Run frontend tests with `pnpm run test`.
|
||||
- `pnpm run build` still runs `tsc && vite build`, so unrelated TypeScript issues elsewhere in `src/` can fail the build.
|
||||
- When possible, validate changed files directly before escalating to full-project fixes.
|
||||
- `pnpm run format` currently reports pre-existing style violations in files unrelated to most changes; format only the files you touched (`npx prettier --write <files>`) rather than reformatting the whole tree.
|
||||
|
||||
## 7) Live Dev Server Debugging
|
||||
|
||||
|
||||
@@ -8,10 +8,16 @@ export default defineConfig({
|
||||
target: 'src/generated',
|
||||
schemas: 'src/generated/model',
|
||||
client: 'react-query',
|
||||
httpClient: 'fetch',
|
||||
mock: false,
|
||||
override: {
|
||||
useQuery: true,
|
||||
mutations: true,
|
||||
fetch: {
|
||||
includeHttpResponseReturnType: false,
|
||||
},
|
||||
mutator: {
|
||||
path: './src/utils/apiFetch.ts',
|
||||
name: 'apiFetch',
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { createContext, useContext, ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import {
|
||||
type AuthState,
|
||||
getAuthenticatedAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
authUserFromMutation,
|
||||
} from './authHelpers';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
@@ -24,15 +22,9 @@ interface AuthContextType extends AuthState {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const initialAuthState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
const unauthenticatedState = getUnauthenticatedAuthState();
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
const logoutMutation = useLogout();
|
||||
@@ -42,40 +34,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setAuthState(prev =>
|
||||
const authState = useMemo(
|
||||
() =>
|
||||
resolveAuthStateFromMe({
|
||||
meData,
|
||||
meError,
|
||||
meLoading,
|
||||
previousState: prev,
|
||||
})
|
||||
);
|
||||
}, [meData, meError, meLoading]);
|
||||
previousState: unauthenticatedState,
|
||||
}),
|
||||
[meData, meError, meLoading]
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await loginMutation.mutateAsync({
|
||||
const user = await loginMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = authUserFromMutation(response);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
} catch (error) {
|
||||
queryClient.setQueryData(getGetMeQueryKey(), undefined);
|
||||
throw error instanceof Error ? error : new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate, queryClient]
|
||||
@@ -84,26 +69,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await registerMutation.mutateAsync({
|
||||
const user = await registerMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const user = authUserFromMutation(response);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
queryClient.setQueryData(getGetMeQueryKey(), user);
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
} catch (error) {
|
||||
queryClient.setQueryData(getGetMeQueryKey(), undefined);
|
||||
throw error instanceof Error ? error : new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
[navigate, queryClient, registerMutation]
|
||||
@@ -111,9 +89,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
onSettled: () => {
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getCheckingAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
authUserFromMutation,
|
||||
type AuthState,
|
||||
} from './authHelpers';
|
||||
|
||||
@@ -31,11 +30,7 @@ describe('authHelpers', () => {
|
||||
it('resolves auth state from a successful /auth/me response', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
headers: new Headers(),
|
||||
},
|
||||
meData: { username: 'evan', is_admin: false },
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
@@ -47,20 +42,7 @@ describe('authHelpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 401,
|
||||
data: { code: 401, message: 'unauthorized' },
|
||||
headers: new Headers(),
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
|
||||
it('resolves auth state to unauthenticated when the me query errors (e.g. 401)', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
@@ -108,32 +90,4 @@ describe('authHelpers', () => {
|
||||
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';
|
||||
|
||||
export type AuthUser = LoginResponse;
|
||||
@@ -38,7 +33,7 @@ export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||
}
|
||||
|
||||
export function resolveAuthStateFromMe(params: {
|
||||
meData?: getMeResponse;
|
||||
meData?: LoginResponse;
|
||||
meError?: unknown;
|
||||
meLoading: boolean;
|
||||
previousState: AuthState;
|
||||
@@ -49,11 +44,11 @@ export function resolveAuthStateFromMe(params: {
|
||||
return getCheckingAuthState(previousState);
|
||||
}
|
||||
|
||||
if (meData?.status === 200) {
|
||||
return getAuthenticatedAuthState(meData.data);
|
||||
if (meData) {
|
||||
return getAuthenticatedAuthState(meData);
|
||||
}
|
||||
|
||||
if (meError || meData?.status === 401) {
|
||||
if (meError) {
|
||||
return getUnauthenticatedAuthState();
|
||||
}
|
||||
|
||||
@@ -62,9 +57,3 @@ export function resolveAuthStateFromMe(params: {
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function authUserFromMutation(
|
||||
response: loginResponse | registerResponse
|
||||
): AuthUser | null {
|
||||
return response.status === 200 || response.status === 201 ? response.data : null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface BaseButtonProps {
|
||||
variant?: 'default' | 'secondary';
|
||||
@@ -20,9 +21,9 @@ const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
||||
({ variant = 'default', children, className, ...props }, ref) => {
|
||||
return (
|
||||
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
||||
<button ref={ref} className={cn(getVariantClasses(variant), className)} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,37 @@ import { SettingsIcon, GitIcon } from '../icons';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { navItems, adminNavItems } from './navigation';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
function hasPrefix(path: string, prefix: string): boolean {
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
|
||||
function NavToggleIcon({ isOpen }: { isOpen: boolean }) {
|
||||
return (
|
||||
<span className="relative block size-7" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-1 h-0.5 w-7 bg-content transition-transform duration-200',
|
||||
isOpen && 'translate-y-2 rotate-45'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-3 h-0.5 w-7 bg-content transition-opacity duration-200',
|
||||
isOpen && 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-0 top-5 h-0.5 w-7 bg-content transition-transform duration-200',
|
||||
isOpen && '-translate-y-2 -rotate-45'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HamburgerMenu() {
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
@@ -20,82 +46,47 @@ export default function HamburgerMenu() {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
const version =
|
||||
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data
|
||||
? infoData.data.version
|
||||
: 'v1.0.0';
|
||||
const version = infoData?.version ?? 'v1.0.0';
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
<div className="relative z-40 ml-6 flex flex-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
|
||||
id="mobile-nav-checkbox"
|
||||
checked={isOpen}
|
||||
onChange={e => setIsOpen(e.target.checked)}
|
||||
/>
|
||||
|
||||
<span
|
||||
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '5px 0px',
|
||||
transition:
|
||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '0% 100%',
|
||||
transition:
|
||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||
opacity: isOpen ? 0 : 1,
|
||||
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
|
||||
style={{
|
||||
transformOrigin: '0% 0%',
|
||||
transition:
|
||||
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
|
||||
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-50 flex size-8 items-center justify-center lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-navigation"
|
||||
onClick={() => setIsOpen(open => !open)}
|
||||
>
|
||||
<NavToggleIcon isOpen={isOpen} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="menu"
|
||||
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48"
|
||||
style={{
|
||||
top: 0,
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: isOpen ? 'none' : 'translate(-100%, 0)',
|
||||
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
|
||||
}}
|
||||
id="mobile-navigation"
|
||||
className={cn(
|
||||
'fixed -ml-6 h-full w-56 bg-surface shadow-lg transition-transform duration-200 lg:w-48 lg:translate-x-0',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
style={{ top: 0, paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<style>{`
|
||||
@media (min-width: 1024px) {
|
||||
#menu {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-16 justify-end lg:justify-around">
|
||||
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p>
|
||||
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">
|
||||
AnthoLume
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
onClick={closeMenu}
|
||||
className={cn(
|
||||
'my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200',
|
||||
location.pathname === item.path
|
||||
? 'border-primary-500 text-content'
|
||||
: 'border-transparent text-content-subtle hover:text-content'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||
@@ -104,20 +95,22 @@ export default function HamburgerMenu() {
|
||||
|
||||
{isAdmin && (
|
||||
<div
|
||||
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
|
||||
className={cn(
|
||||
'my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200',
|
||||
hasPrefix(location.pathname, '/admin')
|
||||
? 'border-primary-500 text-content'
|
||||
: 'border-transparent text-content-subtle'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
onClick={closeMenu}
|
||||
className={cn(
|
||||
'flex w-full justify-start',
|
||||
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
|
||||
? 'text-content'
|
||||
: 'text-content-subtle hover:text-content'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
<span className="mx-4 text-sm font-normal">Admin</span>
|
||||
@@ -129,13 +122,13 @@ export default function HamburgerMenu() {
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex w-full justify-start ${
|
||||
onClick={closeMenu}
|
||||
className={cn(
|
||||
'flex w-full justify-start pl-7',
|
||||
location.pathname === item.path
|
||||
? 'text-content'
|
||||
: 'text-content-subtle hover:text-content'
|
||||
}`}
|
||||
style={{ paddingLeft: '1.75em' }}
|
||||
)}
|
||||
>
|
||||
<span className="mx-4 text-sm font-normal">{item.label}</span>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface IconInputProps {
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconInput({ icon, children, className }: IconInputProps) {
|
||||
return (
|
||||
<div className={cn('relative flex', className)}>
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
{icon}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { UserIcon, DropdownIcon } from '../icons';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
import { THEME_MODES } from '../utils/localSettings';
|
||||
import { SegmentedControl } from './SegmentedControl';
|
||||
import HamburgerMenu from './HamburgerMenu';
|
||||
import { getPageTitle } from './navigation';
|
||||
|
||||
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
@@ -63,30 +62,17 @@ export default function Layout() {
|
||||
{isUserDropdownOpen && (
|
||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||
<div
|
||||
className="border-b border-border px-4 py-3"
|
||||
role="group"
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
|
||||
Theme
|
||||
</p>
|
||||
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
|
||||
{themeModes.map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode)}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
themeMode === mode
|
||||
? 'bg-content text-content-inverse'
|
||||
: 'text-content-muted hover:bg-surface hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
className="w-full"
|
||||
ariaLabel="Theme mode"
|
||||
value={themeMode}
|
||||
onChange={setThemeMode}
|
||||
options={THEME_MODES.map(mode => ({ value: mode, label: mode }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="py-1"
|
||||
|
||||
@@ -47,9 +47,6 @@ function MyComponent() {
|
||||
- `removeToast(id: string): void`
|
||||
- Manually remove a toast by ID
|
||||
|
||||
- `clearToasts(): void`
|
||||
- Clear all active toasts
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
@@ -76,22 +73,13 @@ Skeleton components provide placeholder content while data is loading. They auto
|
||||
|
||||
#### `Skeleton`
|
||||
|
||||
Basic skeleton element with various variants:
|
||||
A pulsing placeholder block. Size and shape are controlled with Tailwind classes via `className`:
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from './components/Skeleton';
|
||||
|
||||
// Default (rounded rectangle)
|
||||
<Skeleton className="w-full h-8" />
|
||||
|
||||
// Text variant
|
||||
<Skeleton variant="text" className="w-3/4" />
|
||||
|
||||
// Circular variant (for avatars)
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
|
||||
// Rectangular variant
|
||||
<Skeleton variant="rectangular" width="100%" height={200} />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="w-3/4" />
|
||||
```
|
||||
|
||||
#### `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 {
|
||||
className?: string;
|
||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className = '',
|
||||
variant = 'default',
|
||||
width,
|
||||
height,
|
||||
animation = 'pulse',
|
||||
}: SkeletonProps) {
|
||||
const baseClasses = 'bg-surface-strong';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'rounded',
|
||||
text: 'h-4 rounded-md',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-none',
|
||||
};
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-wave',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const style = {
|
||||
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
|
||||
height:
|
||||
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return <div className={cn('h-4 animate-pulse rounded-md bg-surface-strong', className)} />;
|
||||
}
|
||||
|
||||
interface SkeletonTableProps {
|
||||
@@ -65,7 +29,7 @@ export function SkeletonTable({
|
||||
<tr className="border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className="p-3">
|
||||
<Skeleton variant="text" className="h-5 w-3/4" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -76,10 +40,7 @@ export function SkeletonTable({
|
||||
<tr key={rowIndex} className="border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-3">
|
||||
<Skeleton
|
||||
variant="text"
|
||||
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
|
||||
/>
|
||||
<Skeleton className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { SkeletonTable } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export interface Column<T> {
|
||||
id: string;
|
||||
@@ -46,7 +47,7 @@ export function Table<T>({
|
||||
{columns.map(column => (
|
||||
<th
|
||||
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}
|
||||
</th>
|
||||
@@ -64,10 +65,7 @@ export function Table<T>({
|
||||
data.map((row, index) => (
|
||||
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={column.id}
|
||||
className={`p-3 text-content ${column.className || ''}`}
|
||||
>
|
||||
<td key={column.id} className={cn('p-3 text-content', column.className)}>
|
||||
{column.render(row, index)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { Toast, ToastType, ToastProps } from './Toast';
|
||||
|
||||
interface ToastUpdate {
|
||||
message?: string;
|
||||
type?: ToastType;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
||||
showInfo: (message: string, duration?: number) => string;
|
||||
showWarning: (message: string, duration?: number) => string;
|
||||
showError: (message: string, duration?: number) => string;
|
||||
updateToast: (id: string, update: ToastUpdate) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
@@ -49,13 +55,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
// In-Place Update - Long-running flows show a persistent toast (duration 0) that resolves into its result toast; changing duration restarts the Toast's auto-dismiss timer.
|
||||
const updateToast = useCallback((id: string, update: ToastUpdate) => {
|
||||
setToasts(prev => prev.map(toast => (toast.id === id ? { ...toast, ...update } : toast)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
|
||||
value={{ showToast, showInfo, showWarning, showError, updateToast, removeToast }}
|
||||
>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Column } from './Table';
|
||||
|
||||
interface DocumentRow {
|
||||
document_id?: string;
|
||||
author?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Shared "Document" Column - Progress and Activity render the same author/title link to the doc.
|
||||
export const documentColumn: Column<DocumentRow> = {
|
||||
id: 'document',
|
||||
header: 'Document',
|
||||
render: row => (
|
||||
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
),
|
||||
};
|
||||
@@ -12,3 +12,9 @@ export { TextInput } from './TextInput';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
|
||||
// Button / Table
|
||||
export { Button } from './Button';
|
||||
export { Table, type Column, type TableProps } from './Table';
|
||||
export { IconInput } from './IconInput';
|
||||
export { SegmentedControl } from './SegmentedControl';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ElementType } from 'react';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, SettingsIcon } from '../icons';
|
||||
import { HomeIcon, DocumentsIcon, ActivityIcon, SearchIcon, ClockIcon } from '../icons';
|
||||
|
||||
export interface NavItem {
|
||||
path: string;
|
||||
@@ -10,16 +10,16 @@ export interface NavItem {
|
||||
export const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||
{ 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: '/search', label: 'Search', icon: SearchIcon },
|
||||
];
|
||||
|
||||
export const adminNavItems: NavItem[] = [
|
||||
{ path: '/admin', label: 'General', icon: SettingsIcon },
|
||||
{ path: '/admin/import', label: 'Import', icon: SettingsIcon },
|
||||
{ path: '/admin/users', label: 'Users', icon: SettingsIcon },
|
||||
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon },
|
||||
export const adminNavItems: { path: string; label: string }[] = [
|
||||
{ path: '/admin', label: 'General' },
|
||||
{ path: '/admin/import', label: 'Import' },
|
||||
{ path: '/admin/users', label: 'Users' },
|
||||
{ path: '/admin/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
// Ordered most-specific-first so prefix matching resolves nested routes correctly.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
export interface Device {
|
||||
id?: string;
|
||||
device_name?: string;
|
||||
created_at?: string;
|
||||
last_synced?: string;
|
||||
id: string;
|
||||
device_name: string;
|
||||
created_at: string;
|
||||
last_synced: string;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
export interface Progress {
|
||||
title?: string;
|
||||
author?: string;
|
||||
device_name?: string;
|
||||
device_name: string;
|
||||
device_id?: string;
|
||||
percentage?: number;
|
||||
percentage: number;
|
||||
progress?: string;
|
||||
document_id?: string;
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
document_id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -14,5 +14,4 @@ export interface SearchItem {
|
||||
series?: string;
|
||||
file_type?: string;
|
||||
file_size?: string;
|
||||
upload_date?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useState, type SyntheticEvent } from 'react';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export interface UseAuthFormResult {
|
||||
username: string;
|
||||
password: string;
|
||||
isLoading: boolean;
|
||||
isLoadingInfo: boolean;
|
||||
registrationEnabled: boolean;
|
||||
setUsername: (value: string) => void;
|
||||
setPassword: (value: string) => void;
|
||||
submit: (e: SyntheticEvent<HTMLFormElement>) => Promise<void>;
|
||||
}
|
||||
|
||||
// Shared auth form state + submit for login/register. Server error messages are surfaced via the
|
||||
// generated ErrorResponse contract rather than hardcoded fallbacks.
|
||||
export function useAuthForm(mode: 'login' | 'register'): UseAuthFormResult {
|
||||
const { login, register } = useAuth();
|
||||
const { showError } = useToasts();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
||||
query: { staleTime: Infinity },
|
||||
});
|
||||
const registrationEnabled = infoData?.registration_enabled ?? false;
|
||||
|
||||
const submit = useCallback(
|
||||
async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, password);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
getErrorMessage(error, mode === 'login' ? 'Login failed' : 'Registration failed')
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[mode, login, register, username, password, showError]
|
||||
);
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
isLoadingInfo,
|
||||
registrationEnabled,
|
||||
setUsername,
|
||||
setPassword,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { CreateActivityRequest } from '../generated/model/createActivityReq
|
||||
import type { UpdateProgressRequest } from '../generated/model/updateProgressRequest';
|
||||
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface UseEpubReaderOptions {
|
||||
documentId: string;
|
||||
@@ -60,6 +62,7 @@ export function useEpubReader({
|
||||
sectionTotalPages: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
const { showError } = useToasts();
|
||||
|
||||
useEffect(() => {
|
||||
isPaginationDisabledRef.current = isPaginationDisabled;
|
||||
@@ -90,20 +93,20 @@ export function useEpubReader({
|
||||
});
|
||||
|
||||
const saveProgress = async (payload: UpdateProgressRequest) => {
|
||||
const response = await updateProgress(payload);
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
'message' in response.data ? response.data.message : 'Unable to save reader progress'
|
||||
);
|
||||
// Swallow Save Failures - Transient progress-save errors must not take down the reader
|
||||
// (they previously routed to onError, which hides the whole book behind an error overlay).
|
||||
try {
|
||||
await updateProgress(payload);
|
||||
} catch (err) {
|
||||
showError(`Failed to save progress: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const saveActivity = async (payload: CreateActivityRequest) => {
|
||||
const response = await createActivity(payload);
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
'message' in response.data ? response.data.message : 'Unable to save reader activity'
|
||||
);
|
||||
try {
|
||||
await createActivity(payload);
|
||||
} catch (err) {
|
||||
showError(`Failed to save activity: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface ApiResponse {
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface ToastMutationOptions {
|
||||
success: string;
|
||||
error: string;
|
||||
@@ -14,18 +9,15 @@ interface ToastMutationOptions {
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
return function toastMutationOptions({ success, error, onSuccess }: ToastMutationOptions) {
|
||||
return {
|
||||
onSuccess: (response: ApiResponse) => {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
showError(`${error}: ${getErrorMessage(response.data)}`);
|
||||
return;
|
||||
}
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
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;
|
||||
}
|
||||
|
||||
/* Button visibility toggle */
|
||||
.css-button:checked + div {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.css-button + div {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
#mobile-nav-button span {
|
||||
transform-origin: 5px 0;
|
||||
transition:
|
||||
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
opacity 0.55s ease;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:first-child {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#mobile-nav-button span:nth-last-child(2) {
|
||||
transform-origin: 0 100%;
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) translate(2px, -2px);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span:nth-last-child(3) {
|
||||
opacity: 0;
|
||||
transform: rotate(0deg) scale(0.2, 0.2);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ span:nth-last-child(2) {
|
||||
transform: rotate(-45deg) translate(0, 6px);
|
||||
}
|
||||
|
||||
#mobile-nav-button:checked ~ #menu {
|
||||
transform: translate(0, 0) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#mobile-nav-button ~ #menu {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#menu {
|
||||
top: 0;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transform-origin: 0 0;
|
||||
transform: translate(-100%, 0);
|
||||
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#menu {
|
||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton Wave Animation */
|
||||
@keyframes wave {
|
||||
0% {
|
||||
|
||||
@@ -2,125 +2,27 @@ import ePub from 'epubjs';
|
||||
import NoSleep from 'nosleep.js';
|
||||
import type { CreateActivityRequest } from '../../generated/model/createActivityRequest';
|
||||
import type { UpdateProgressRequest } from '../../generated/model/updateProgressRequest';
|
||||
import type { ReaderColorScheme, ReaderFontFamily } from '../../utils/localSettings';
|
||||
import {
|
||||
READER_COLOR_SCHEMES,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
} from '../../utils/localSettings';
|
||||
import type { EpubBook, EpubRendition, ReaderStats, ReaderTocItem } from './types';
|
||||
import {
|
||||
countWords,
|
||||
getBookWordPosition,
|
||||
getCFIFromXPath,
|
||||
getParsedTOC,
|
||||
getVisibleWordCount,
|
||||
getXPathFromCFI,
|
||||
} from './epubUtils';
|
||||
import { registerRenditionGestures } from './gestures';
|
||||
|
||||
export type { ReaderStats, ReaderTocItem } from './types';
|
||||
|
||||
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const THEME_FILE = '/assets/reader/themes.css';
|
||||
const FONT_FILE = '/assets/reader/fonts.css';
|
||||
|
||||
interface TocNode {
|
||||
href: string;
|
||||
label?: string;
|
||||
subitems?: TocNode[];
|
||||
}
|
||||
|
||||
interface EpubContents {
|
||||
document: Document;
|
||||
sectionIndex?: number;
|
||||
range: (cfi: string) => Range;
|
||||
}
|
||||
|
||||
interface EpubVisibleSection {
|
||||
index: number;
|
||||
layout: { width: number; divisor: number };
|
||||
width: () => number;
|
||||
expand: () => void;
|
||||
}
|
||||
|
||||
interface EpubLocation {
|
||||
start: {
|
||||
cfi: string;
|
||||
href?: string;
|
||||
};
|
||||
end: {
|
||||
cfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EpubNavigation {
|
||||
toc?: TocNode[];
|
||||
}
|
||||
|
||||
interface EpubSpineItem {
|
||||
cfiBase: string;
|
||||
index: number;
|
||||
document: Document;
|
||||
load: (_loader: unknown) => Promise<Document>;
|
||||
cfiFromElement: (element: Element) => string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
interface EpubBook {
|
||||
ready: Promise<void>;
|
||||
navigation?: EpubNavigation;
|
||||
loaded: { navigation: Promise<EpubNavigation> };
|
||||
spine: {
|
||||
spineItems: EpubSpineItem[];
|
||||
get: (index: number) => EpubSpineItem;
|
||||
hooks: {
|
||||
content: { register: (_callback: (output: Document) => void) => void };
|
||||
};
|
||||
};
|
||||
load: (...args: unknown[]) => unknown;
|
||||
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
|
||||
getRange: (cfiRange: string) => Promise<Range>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface EpubRendition {
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
display: (target?: string) => Promise<void>;
|
||||
currentLocation: () => Promise<EpubLocation>;
|
||||
getContents: () => EpubContents[];
|
||||
themes: {
|
||||
default: (styles: Record<string, unknown>) => void;
|
||||
register: (name: string, styles: Record<string, unknown> | string) => void;
|
||||
select: (name: string) => void;
|
||||
};
|
||||
hooks: {
|
||||
content: { register: (_callback: () => void) => void };
|
||||
render: { register: (_callback: (contents: EpubContents) => void) => void };
|
||||
};
|
||||
manager?: {
|
||||
visible?: () => EpubVisibleSection[];
|
||||
};
|
||||
views: () => { container: { scrollLeft: number } };
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface ParsedCfiPath {
|
||||
steps: unknown[];
|
||||
terminal: unknown;
|
||||
}
|
||||
|
||||
interface ParsedCfi {
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
}
|
||||
|
||||
interface EpubCfiHelper {
|
||||
parse: (_value: string) => ParsedCfi;
|
||||
equalStep: (_a: unknown, _b: unknown) => boolean;
|
||||
segmentString: (_value: unknown) => string;
|
||||
}
|
||||
|
||||
interface EpubWithCfiConstructor {
|
||||
CFI: new () => EpubCfiHelper;
|
||||
}
|
||||
|
||||
export interface ReaderStats {
|
||||
chapterName: string;
|
||||
sectionPage: number;
|
||||
sectionTotalPages: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ReaderTocItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface BookState {
|
||||
pages: number;
|
||||
percentage: number;
|
||||
@@ -174,6 +76,7 @@ export class EBookReader {
|
||||
private rendition: EpubRendition;
|
||||
private noSleep: NoSleep | null = null;
|
||||
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private gestureDispose: (() => void) | null = null;
|
||||
private destroyed = false;
|
||||
private onReady: () => void;
|
||||
private onLoading: (_loading: boolean) => void;
|
||||
@@ -187,7 +90,6 @@ export class EBookReader {
|
||||
private onSwipeUp: () => void;
|
||||
private onCenterTap: () => void;
|
||||
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
|
||||
private wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: EBookReaderOptions) {
|
||||
this.container = options.container;
|
||||
@@ -217,7 +119,6 @@ export class EBookReader {
|
||||
pageStart: Date.now(),
|
||||
};
|
||||
|
||||
this.loadSettings();
|
||||
this.readerSettings.theme = {
|
||||
colorScheme: options.colorScheme,
|
||||
fontFamily: options.fontFamily,
|
||||
@@ -237,7 +138,16 @@ export class EBookReader {
|
||||
this.initCSP();
|
||||
this.initWakeLock();
|
||||
this.initThemes();
|
||||
this.initViewerListeners();
|
||||
|
||||
this.gestureDispose = registerRenditionGestures(this.rendition, {
|
||||
isPaginationDisabled: () => this.isPaginationDisabled(),
|
||||
nextPage: () => this.nextPage(),
|
||||
prevPage: () => this.prevPage(),
|
||||
onSwipeDown: () => this.onSwipeDown(),
|
||||
onSwipeUp: () => this.onSwipeUp(),
|
||||
onCenterTap: () => this.onCenterTap(),
|
||||
});
|
||||
|
||||
this.initDocumentListeners();
|
||||
|
||||
this.book.ready.then(this.setupReader.bind(this)).catch(error => {
|
||||
@@ -249,12 +159,6 @@ export class EBookReader {
|
||||
});
|
||||
}
|
||||
|
||||
private loadSettings() {
|
||||
this.readerSettings = {
|
||||
theme: this.readerSettings.theme ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private initWakeLock() {
|
||||
this.noSleep = new NoSleep();
|
||||
document.addEventListener('wakelock', this.handleWakeLock);
|
||||
@@ -279,7 +183,7 @@ export class EBookReader {
|
||||
};
|
||||
|
||||
private initThemes() {
|
||||
THEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
|
||||
READER_COLOR_SCHEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
|
||||
|
||||
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
|
||||
if (!themeLinkEl) {
|
||||
@@ -335,143 +239,24 @@ export class EBookReader {
|
||||
});
|
||||
}
|
||||
|
||||
private initViewerListeners() {
|
||||
const nextPage = this.nextPage.bind(this);
|
||||
const prevPage = this.prevPage.bind(this);
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
const handleSwipeDown = () => {
|
||||
this.resetWheelCooldown();
|
||||
this.onSwipeDown();
|
||||
};
|
||||
|
||||
const handleSwipeUp = () => {
|
||||
this.resetWheelCooldown();
|
||||
this.onSwipeUp();
|
||||
};
|
||||
|
||||
const handleGesture = () => {
|
||||
const drasticity = 50;
|
||||
|
||||
if (touchEndY - drasticity > touchStartY) {
|
||||
return handleSwipeDown();
|
||||
}
|
||||
|
||||
if (touchEndY + drasticity < touchStartY) {
|
||||
return handleSwipeUp();
|
||||
}
|
||||
|
||||
if (!this.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
|
||||
void nextPage();
|
||||
}
|
||||
|
||||
if (!this.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
|
||||
void prevPage();
|
||||
}
|
||||
};
|
||||
|
||||
this.rendition.hooks.render.register((contents: EpubContents) => {
|
||||
const renderDoc = contents.document;
|
||||
|
||||
const wakeLockListener = () => {
|
||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||
};
|
||||
renderDoc.addEventListener('click', wakeLockListener);
|
||||
renderDoc.addEventListener('gesturechange', wakeLockListener);
|
||||
renderDoc.addEventListener('touchstart', wakeLockListener);
|
||||
|
||||
renderDoc.addEventListener('click', (event: MouseEvent) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
const pagePixels = windowWidth * 0.2;
|
||||
const top = barPixels;
|
||||
const bottom = window.innerHeight - top;
|
||||
const left = pagePixels;
|
||||
const right = windowWidth - left;
|
||||
const leftOffset = this.rendition.views().container.scrollLeft;
|
||||
const yCoord = event.clientY;
|
||||
const xCoord = event.clientX - leftOffset;
|
||||
|
||||
if (yCoord < top) {
|
||||
handleSwipeDown();
|
||||
} else if (yCoord > bottom) {
|
||||
handleSwipeUp();
|
||||
} else if (!this.isPaginationDisabled() && xCoord < left) {
|
||||
void prevPage();
|
||||
} else if (!this.isPaginationDisabled() && xCoord > right) {
|
||||
void nextPage();
|
||||
} else {
|
||||
this.onCenterTap();
|
||||
}
|
||||
});
|
||||
|
||||
renderDoc.addEventListener('wheel', (event: WheelEvent) => {
|
||||
if (this.wheelTimeoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaY > 25) {
|
||||
handleSwipeUp();
|
||||
return;
|
||||
}
|
||||
if (event.deltaY < -25) {
|
||||
handleSwipeDown();
|
||||
}
|
||||
});
|
||||
|
||||
renderDoc.addEventListener(
|
||||
'touchstart',
|
||||
(event: TouchEvent) => {
|
||||
touchStartX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchStartY = event.changedTouches[0]?.screenY ?? 0;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
renderDoc.addEventListener(
|
||||
'touchend',
|
||||
(event: TouchEvent) => {
|
||||
touchEndX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchEndY = event.changedTouches[0]?.screenY ?? 0;
|
||||
handleGesture();
|
||||
},
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private resetWheelCooldown() {
|
||||
if (this.wheelTimeoutId) {
|
||||
clearTimeout(this.wheelTimeoutId);
|
||||
this.wheelTimeoutId = null;
|
||||
}
|
||||
|
||||
this.wheelTimeoutId = setTimeout(() => {
|
||||
this.wheelTimeoutId = null;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
private initDocumentListeners() {
|
||||
const nextPage = this.nextPage.bind(this);
|
||||
const prevPage = this.prevPage.bind(this);
|
||||
|
||||
this.keyupHandler = (event: KeyboardEvent) => {
|
||||
if ((event.keyCode || event.which) === 37) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
void prevPage();
|
||||
}
|
||||
if ((event.keyCode || event.which) === 39) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
void nextPage();
|
||||
}
|
||||
if ((event.keyCode || event.which) === 84) {
|
||||
if (event.key === 't' || event.key === 'T') {
|
||||
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
|
||||
const currentThemeIdx = THEMES.indexOf(currentTheme);
|
||||
const currentThemeIdx = READER_COLOR_SCHEMES.indexOf(currentTheme);
|
||||
const colorScheme =
|
||||
THEMES.length === currentThemeIdx + 1 ? THEMES[0] : THEMES[currentThemeIdx + 1];
|
||||
READER_COLOR_SCHEMES.length === currentThemeIdx + 1
|
||||
? READER_COLOR_SCHEMES[0]
|
||||
: READER_COLOR_SCHEMES[currentThemeIdx + 1];
|
||||
if (colorScheme) {
|
||||
this.setTheme({ colorScheme });
|
||||
}
|
||||
@@ -482,44 +267,20 @@ export class EBookReader {
|
||||
}
|
||||
|
||||
private async setupReader() {
|
||||
this.bookState.words = await this.countWords();
|
||||
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
|
||||
await this.setPosition(cfi);
|
||||
const { element } = await this.getCFIFromXPath(this.bookState.progress);
|
||||
this.bookState.progressElement = element ?? null;
|
||||
this.bookState.words = await countWords(this.book);
|
||||
const cfiResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||
await this.setPosition(cfiResult?.cfi);
|
||||
const elementResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||
this.bookState.progressElement = elementResult?.element ?? null;
|
||||
this.highlightPositionMarker();
|
||||
const stats = await this.getBookStats();
|
||||
this.onStats(stats);
|
||||
this.bookState.pageStart = Date.now();
|
||||
this.onToc(this.getParsedTOC());
|
||||
this.onToc(getParsedTOC(this.book));
|
||||
this.onLoading(false);
|
||||
this.onReady();
|
||||
}
|
||||
|
||||
private getParsedTOC(): ReaderTocItem[] {
|
||||
if (!this.book.navigation?.toc) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
|
||||
const sectionTitle = item.label?.trim() ?? '';
|
||||
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
|
||||
if (!item.subitems || item.subitems.length === 0) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const allSubSections = item.subitems.map(subitem => {
|
||||
let itemTitle = subitem.label?.trim() ?? 'Untitled';
|
||||
if (sectionTitle !== '') {
|
||||
itemTitle = `${sectionTitle} - ${itemTitle}`;
|
||||
}
|
||||
return { title: itemTitle, href: subitem.href };
|
||||
});
|
||||
agg.push(...allSubSections);
|
||||
return agg;
|
||||
}, []);
|
||||
}
|
||||
|
||||
setTheme(newTheme?: { colorScheme?: ReaderColorScheme; fontFamily?: string; fontSize?: number }) {
|
||||
this.readerSettings.theme =
|
||||
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
|
||||
@@ -615,6 +376,8 @@ export class EBookReader {
|
||||
return;
|
||||
}
|
||||
|
||||
// Triple Display - epubjs occasionally renders at the wrong position on a single display()
|
||||
// when restoring a CFI; calling it three times is a known workaround to land on the exact page.
|
||||
await this.rendition.display(cfi);
|
||||
await this.rendition.display(cfi);
|
||||
await this.rendition.display(cfi);
|
||||
@@ -627,11 +390,11 @@ export class EBookReader {
|
||||
fontSize?: number;
|
||||
}) {
|
||||
const currentProgress = this.bookState.progress;
|
||||
const { cfi } = await this.getCFIFromXPath(currentProgress);
|
||||
const cfiResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||
this.setTheme(newTheme);
|
||||
await this.setPosition(cfi);
|
||||
const { element } = await this.getCFIFromXPath(currentProgress);
|
||||
this.bookState.progressElement = element ?? null;
|
||||
await this.setPosition(cfiResult?.cfi);
|
||||
const elementResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||
this.bookState.progressElement = elementResult?.element ?? null;
|
||||
this.highlightPositionMarker();
|
||||
}
|
||||
|
||||
@@ -641,8 +404,8 @@ export class EBookReader {
|
||||
|
||||
const pageStart = this.bookState.pageStart;
|
||||
let elapsedTime = Date.now() - pageStart;
|
||||
const pageWords = await this.getVisibleWordCount();
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const pageWords = await getVisibleWordCount(this.book, this.rendition);
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
const percentRead = pageWords / this.bookState.words;
|
||||
const pageWPM = pageWords / (elapsedTime / 60000);
|
||||
|
||||
@@ -686,10 +449,10 @@ export class EBookReader {
|
||||
|
||||
async createProgress() {
|
||||
const currentCFI = await this.rendition.currentLocation();
|
||||
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
this.bookState.progress = xpath ?? '';
|
||||
this.bookState.progressElement = element ?? null;
|
||||
const xpathResult = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
this.bookState.progress = xpathResult?.xpath ?? '';
|
||||
this.bookState.progressElement = xpathResult?.element ?? null;
|
||||
|
||||
const percentage =
|
||||
this.bookState.words > 0
|
||||
@@ -744,7 +507,7 @@ export class EBookReader {
|
||||
}
|
||||
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const currentWord = await this.getBookWordPosition();
|
||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||
const currentTOC = this.book.navigation?.toc?.find(
|
||||
item => item.href === currentLocation.start.href
|
||||
);
|
||||
@@ -760,228 +523,6 @@ export class EBookReader {
|
||||
};
|
||||
}
|
||||
|
||||
async getXPathFromCFI(cfi: string) {
|
||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||
if (!cfiBaseMatch?.[1]) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
const startCFI = cfiBaseMatch[1];
|
||||
|
||||
const docFragmentIndex =
|
||||
(this.book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||
if (docFragmentIndex <= 0) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||
const contents = this.rendition.getContents()[0];
|
||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||
if (!currentNodeStart) {
|
||||
return {} as { xpath?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
let currentNode: Node | null = currentNodeStart;
|
||||
const element =
|
||||
currentNode.nodeType === Node.ELEMENT_NODE
|
||||
? (currentNode as Element)
|
||||
: currentNode.parentElement;
|
||||
|
||||
let allPos = '';
|
||||
while (currentNode && currentNode.nodeName !== 'BODY') {
|
||||
let parentElement: Element | null = currentNode.parentElement;
|
||||
if (!parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
const currentElement = currentNode as Element;
|
||||
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
|
||||
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
|
||||
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
|
||||
currentNode = parentElement;
|
||||
allPos = `/${nodePos}${allPos}`;
|
||||
}
|
||||
|
||||
return { xpath: `${basePos}${allPos}`, element };
|
||||
}
|
||||
|
||||
async getCFIFromXPath(xpath?: string) {
|
||||
if (!xpath) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||
if (!fragMatch?.[1]) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||
const sectionItem = this.book.spine.get(spinePosition);
|
||||
await sectionItem.load(this.book.load.bind(this.book));
|
||||
|
||||
const renderedContent = this.rendition
|
||||
.getContents()
|
||||
.find(item => item.sectionIndex == spinePosition);
|
||||
const docItem = renderedContent?.document || sectionItem.document;
|
||||
|
||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||
let remainingXPath = xpath
|
||||
.replace(fragMatch[0], '/html')
|
||||
.replace(/\.(\d+)$/, '')
|
||||
.replace(/\/text\(\)(\[\d+\])?$/, '');
|
||||
|
||||
const derivedSelectorElement = remainingXPath
|
||||
.replace(/^\/html\/body/, 'body')
|
||||
.split('/')
|
||||
.reduce(
|
||||
(element: ParentNode | null, item: string) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
|
||||
if (!indexMatch) {
|
||||
return element.querySelector(item);
|
||||
}
|
||||
|
||||
const [, tag, rawIndex] = indexMatch;
|
||||
if (!tag || !rawIndex) {
|
||||
return null;
|
||||
}
|
||||
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
|
||||
},
|
||||
docItem as ParentNode | null
|
||||
);
|
||||
|
||||
if (namespaceURI) {
|
||||
remainingXPath = remainingXPath.split('/').join('/ns:');
|
||||
}
|
||||
|
||||
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
|
||||
if (prefix === 'ns') {
|
||||
return namespaceURI;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const xpathElement = docSearch.iterateNext();
|
||||
const element = xpathElement || derivedSelectorElement;
|
||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||
if (!isElementNode) {
|
||||
return {} as { cfi?: string; element?: Element | null };
|
||||
}
|
||||
|
||||
const resolvedElement = element as Element;
|
||||
|
||||
let cfi = sectionItem.cfiFromElement(resolvedElement);
|
||||
if (cfi.endsWith('!/)')) {
|
||||
cfi = `${cfi.slice(0, -1)}0)`;
|
||||
}
|
||||
|
||||
return { cfi, element: resolvedElement };
|
||||
}
|
||||
|
||||
async getVisibleWordCount() {
|
||||
const visibleText = await this.getVisibleText();
|
||||
return visibleText.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
async getBookWordPosition() {
|
||||
const contents = this.rendition.getContents()[0];
|
||||
if (!contents) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spineItem = this.book.spine.get(contents.sectionIndex ?? 0);
|
||||
const firstElement = spineItem.document.body.children[0];
|
||||
if (!firstElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstCFI = spineItem.cfiFromElement(firstElement);
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||
const textRange = await this.book.getRange(cfiRange);
|
||||
const chapterText = textRange.toString();
|
||||
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
||||
const preChapterWordPosition = this.book.spine.spineItems
|
||||
.slice(0, contents.sectionIndex ?? 0)
|
||||
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
|
||||
|
||||
return chapterWordPosition + preChapterWordPosition;
|
||||
}
|
||||
|
||||
async getVisibleText() {
|
||||
this.rendition.manager?.visible?.()?.forEach(item => item.expand());
|
||||
const currentLocation = await this.rendition.currentLocation();
|
||||
const cfiRange = this.getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
|
||||
const textRange = await this.book.getRange(cfiRange);
|
||||
return textRange.toString();
|
||||
}
|
||||
|
||||
getCFIRange(a: string, b: string) {
|
||||
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
|
||||
const start = CFI.parse(a);
|
||||
const end = CFI.parse(b);
|
||||
const cfi: {
|
||||
range: boolean;
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
start: ParsedCfiPath;
|
||||
end: ParsedCfiPath;
|
||||
} = {
|
||||
range: true,
|
||||
base: start.base,
|
||||
path: { steps: [], terminal: null },
|
||||
start: start.path,
|
||||
end: end.path,
|
||||
};
|
||||
|
||||
const len = cfi.start.steps.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||
if (i === len - 1) {
|
||||
if (cfi.start.terminal === cfi.end.terminal) {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
cfi.range = false;
|
||||
}
|
||||
} else {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||
|
||||
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
|
||||
}
|
||||
|
||||
async countWords() {
|
||||
const spineWC = await Promise.all(
|
||||
this.book.spine.spineItems.map(async item => {
|
||||
const newDoc = await item.load(this.book.load.bind(this.book));
|
||||
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
|
||||
.trim()
|
||||
.split(/\s+/).length;
|
||||
item.wordCount = spineWords;
|
||||
return spineWords;
|
||||
})
|
||||
);
|
||||
|
||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
if (this.keyupHandler) {
|
||||
@@ -991,9 +532,7 @@ export class EBookReader {
|
||||
if (this.wakeTimeoutId) {
|
||||
clearTimeout(this.wakeTimeoutId);
|
||||
}
|
||||
if (this.wheelTimeoutId) {
|
||||
clearTimeout(this.wheelTimeoutId);
|
||||
}
|
||||
this.gestureDispose?.();
|
||||
void this.noSleep?.disable();
|
||||
this.rendition.destroy?.();
|
||||
this.book.destroy?.();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import ePub from 'epubjs';
|
||||
import type {
|
||||
EpubBook,
|
||||
EpubRendition,
|
||||
EpubWithCfiConstructor,
|
||||
ParsedCfiPath,
|
||||
ReaderTocItem,
|
||||
} from './types';
|
||||
|
||||
export function getParsedTOC(book: EpubBook): ReaderTocItem[] {
|
||||
if (!book.navigation?.toc) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
|
||||
const sectionTitle = item.label?.trim() ?? '';
|
||||
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
|
||||
if (!item.subitems || item.subitems.length === 0) {
|
||||
return agg;
|
||||
}
|
||||
|
||||
const allSubSections = item.subitems.map(subitem => {
|
||||
let itemTitle = subitem.label?.trim() ?? 'Untitled';
|
||||
if (sectionTitle !== '') {
|
||||
itemTitle = `${sectionTitle} - ${itemTitle}`;
|
||||
}
|
||||
return { title: itemTitle, href: subitem.href };
|
||||
});
|
||||
agg.push(...allSubSections);
|
||||
return agg;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export async function countWords(book: EpubBook) {
|
||||
const spineWC = await Promise.all(
|
||||
book.spine.spineItems.map(async item => {
|
||||
const newDoc = await item.load(book.load.bind(book));
|
||||
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
|
||||
.trim()
|
||||
.split(/\s+/).length;
|
||||
item.wordCount = spineWords;
|
||||
return spineWords;
|
||||
})
|
||||
);
|
||||
|
||||
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
|
||||
}
|
||||
|
||||
export function getCFIRange(a: string, b: string) {
|
||||
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
|
||||
const start = CFI.parse(a);
|
||||
const end = CFI.parse(b);
|
||||
const cfi: {
|
||||
range: boolean;
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
start: ParsedCfiPath;
|
||||
end: ParsedCfiPath;
|
||||
} = {
|
||||
range: true,
|
||||
base: start.base,
|
||||
path: { steps: [], terminal: null },
|
||||
start: start.path,
|
||||
end: end.path,
|
||||
};
|
||||
|
||||
const len = cfi.start.steps.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||
if (i === len - 1) {
|
||||
if (cfi.start.terminal === cfi.end.terminal) {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
cfi.range = false;
|
||||
}
|
||||
} else {
|
||||
cfi.path.steps.push(cfi.start.steps[i]);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||
|
||||
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
|
||||
}
|
||||
|
||||
export async function getVisibleText(book: EpubBook, rendition: EpubRendition) {
|
||||
rendition.manager?.visible?.()?.forEach(item => item.expand());
|
||||
const currentLocation = await rendition.currentLocation();
|
||||
const cfiRange = getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
|
||||
const textRange = await book.getRange(cfiRange);
|
||||
return textRange.toString();
|
||||
}
|
||||
|
||||
export async function getVisibleWordCount(book: EpubBook, rendition: EpubRendition) {
|
||||
const visibleText = await getVisibleText(book, rendition);
|
||||
return visibleText.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
export async function getBookWordPosition(book: EpubBook, rendition: EpubRendition) {
|
||||
const contents = rendition.getContents()[0];
|
||||
if (!contents) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const spineItem = book.spine.get(contents.sectionIndex ?? 0);
|
||||
const firstElement = spineItem.document.body.children[0];
|
||||
if (!firstElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstCFI = spineItem.cfiFromElement(firstElement);
|
||||
const currentLocation = await rendition.currentLocation();
|
||||
const cfiRange = getCFIRange(firstCFI, currentLocation.start.cfi);
|
||||
const textRange = await book.getRange(cfiRange);
|
||||
const chapterText = textRange.toString();
|
||||
const chapterWordPosition = chapterText.trim().split(/\s+/).length;
|
||||
const preChapterWordPosition = book.spine.spineItems
|
||||
.slice(0, contents.sectionIndex ?? 0)
|
||||
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
|
||||
|
||||
return chapterWordPosition + preChapterWordPosition;
|
||||
}
|
||||
|
||||
export async function getXPathFromCFI(
|
||||
book: EpubBook,
|
||||
rendition: EpubRendition,
|
||||
cfi: string
|
||||
): Promise<{ xpath: string; element: Element | null } | null> {
|
||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||
if (!cfiBaseMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
const startCFI = cfiBaseMatch[1];
|
||||
|
||||
const docFragmentIndex =
|
||||
(book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||
if (docFragmentIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||
const contents = rendition.getContents()[0];
|
||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||
if (!currentNodeStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentNode: Node | null = currentNodeStart;
|
||||
const element =
|
||||
currentNode.nodeType === Node.ELEMENT_NODE
|
||||
? (currentNode as Element)
|
||||
: currentNode.parentElement;
|
||||
|
||||
let allPos = '';
|
||||
while (currentNode && currentNode.nodeName !== 'BODY') {
|
||||
let parentElement: Element | null = currentNode.parentElement;
|
||||
if (!parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
|
||||
currentNode = parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
const currentElement = currentNode as Element;
|
||||
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
|
||||
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
|
||||
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
|
||||
currentNode = parentElement;
|
||||
allPos = `/${nodePos}${allPos}`;
|
||||
}
|
||||
|
||||
return { xpath: `${basePos}${allPos}`, element };
|
||||
}
|
||||
|
||||
export async function getCFIFromXPath(
|
||||
book: EpubBook,
|
||||
rendition: EpubRendition,
|
||||
xpath?: string
|
||||
): Promise<{ cfi: string; element: Element } | null> {
|
||||
if (!xpath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||
if (!fragMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||
const sectionItem = book.spine.get(spinePosition);
|
||||
await sectionItem.load(book.load.bind(book));
|
||||
|
||||
const renderedContent = rendition.getContents().find(item => item.sectionIndex == spinePosition);
|
||||
const docItem = renderedContent?.document || sectionItem.document;
|
||||
|
||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||
let remainingXPath = xpath
|
||||
.replace(fragMatch[0], '/html')
|
||||
.replace(/\.(\d+)$/, '')
|
||||
.replace(/\/text\(\)(\[\d+\])?$/, '');
|
||||
|
||||
const derivedSelectorElement = remainingXPath
|
||||
.replace(/^\/html\/body/, 'body')
|
||||
.split('/')
|
||||
.reduce(
|
||||
(element: ParentNode | null, item: string) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
|
||||
if (!indexMatch) {
|
||||
return element.querySelector(item);
|
||||
}
|
||||
|
||||
const [, tag, rawIndex] = indexMatch;
|
||||
if (!tag || !rawIndex) {
|
||||
return null;
|
||||
}
|
||||
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
|
||||
},
|
||||
docItem as ParentNode | null
|
||||
);
|
||||
|
||||
if (namespaceURI) {
|
||||
remainingXPath = remainingXPath.split('/').join('/ns:');
|
||||
}
|
||||
|
||||
const docSearch = docItem.evaluate(remainingXPath, docItem, prefix => {
|
||||
if (prefix === 'ns') {
|
||||
return namespaceURI;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const xpathElement = docSearch.iterateNext();
|
||||
const element = xpathElement || derivedSelectorElement;
|
||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||
if (!isElementNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedElement = element as Element;
|
||||
|
||||
let cfi = sectionItem.cfiFromElement(resolvedElement);
|
||||
if (cfi.endsWith('!/)')) {
|
||||
cfi = `${cfi.slice(0, -1)}0)`;
|
||||
}
|
||||
|
||||
return { cfi, element: resolvedElement };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { EpubContents, EpubRendition } from './types';
|
||||
|
||||
export interface GestureHandlers {
|
||||
isPaginationDisabled: () => boolean;
|
||||
nextPage: () => Promise<void>;
|
||||
prevPage: () => Promise<void>;
|
||||
onSwipeDown: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onCenterTap: () => void;
|
||||
}
|
||||
|
||||
const WHEEL_COOLDOWN_MS = 400;
|
||||
const SWIPE_THRESHOLD = 25;
|
||||
|
||||
/**
|
||||
* Registers touch / click-zone / wheel listeners on every rendered section. Returns a dispose
|
||||
* (called from EBookReader.destroy) that clears the pending wheel-cooldown timeout and removes
|
||||
* every listener added across rendered sections, so they don't accumulate on re-render.
|
||||
*/
|
||||
export function registerRenditionGestures(
|
||||
rendition: EpubRendition,
|
||||
handlers: GestureHandlers
|
||||
): () => void {
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
let wheelTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const listenerCleanups: Array<() => void> = [];
|
||||
|
||||
const resetWheelCooldown = () => {
|
||||
if (wheelTimeoutId) {
|
||||
clearTimeout(wheelTimeoutId);
|
||||
}
|
||||
wheelTimeoutId = setTimeout(() => {
|
||||
wheelTimeoutId = null;
|
||||
}, WHEEL_COOLDOWN_MS);
|
||||
};
|
||||
|
||||
const handleSwipeDown = () => {
|
||||
resetWheelCooldown();
|
||||
handlers.onSwipeDown();
|
||||
};
|
||||
|
||||
const handleSwipeUp = () => {
|
||||
resetWheelCooldown();
|
||||
handlers.onSwipeUp();
|
||||
};
|
||||
|
||||
const handleGesture = () => {
|
||||
const drasticity = 50;
|
||||
|
||||
if (touchEndY - drasticity > touchStartY) {
|
||||
return handleSwipeDown();
|
||||
}
|
||||
|
||||
if (touchEndY + drasticity < touchStartY) {
|
||||
return handleSwipeUp();
|
||||
}
|
||||
|
||||
if (!handlers.isPaginationDisabled() && touchEndX + drasticity < touchStartX) {
|
||||
void handlers.nextPage();
|
||||
}
|
||||
|
||||
if (!handlers.isPaginationDisabled() && touchEndX - drasticity > touchStartX) {
|
||||
void handlers.prevPage();
|
||||
}
|
||||
};
|
||||
|
||||
rendition.hooks.render.register((contents: EpubContents) => {
|
||||
const renderDoc = contents.document;
|
||||
|
||||
const onWakeLock = () => {
|
||||
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const barPixels = windowHeight * 0.2;
|
||||
const pagePixels = windowWidth * 0.2;
|
||||
const top = barPixels;
|
||||
const bottom = window.innerHeight - top;
|
||||
const left = pagePixels;
|
||||
const right = windowWidth - left;
|
||||
const leftOffset = rendition.views().container.scrollLeft;
|
||||
const yCoord = event.clientY;
|
||||
const xCoord = event.clientX - leftOffset;
|
||||
|
||||
if (yCoord < top) {
|
||||
handleSwipeDown();
|
||||
} else if (yCoord > bottom) {
|
||||
handleSwipeUp();
|
||||
} else if (!handlers.isPaginationDisabled() && xCoord < left) {
|
||||
void handlers.prevPage();
|
||||
} else if (!handlers.isPaginationDisabled() && xCoord > right) {
|
||||
void handlers.nextPage();
|
||||
} else {
|
||||
handlers.onCenterTap();
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (wheelTimeoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.deltaY > SWIPE_THRESHOLD) {
|
||||
handleSwipeUp();
|
||||
return;
|
||||
}
|
||||
if (event.deltaY < -SWIPE_THRESHOLD) {
|
||||
handleSwipeDown();
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
touchStartX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchStartY = event.changedTouches[0]?.screenY ?? 0;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
touchEndX = event.changedTouches[0]?.screenX ?? 0;
|
||||
touchEndY = event.changedTouches[0]?.screenY ?? 0;
|
||||
handleGesture();
|
||||
};
|
||||
|
||||
renderDoc.addEventListener('click', onWakeLock);
|
||||
renderDoc.addEventListener('gesturechange', onWakeLock);
|
||||
renderDoc.addEventListener('touchstart', onWakeLock);
|
||||
renderDoc.addEventListener('click', onClick);
|
||||
renderDoc.addEventListener('wheel', onWheel);
|
||||
renderDoc.addEventListener('touchstart', onTouchStart);
|
||||
renderDoc.addEventListener('touchend', onTouchEnd);
|
||||
|
||||
listenerCleanups.push(() => {
|
||||
renderDoc.removeEventListener('click', onWakeLock);
|
||||
renderDoc.removeEventListener('gesturechange', onWakeLock);
|
||||
renderDoc.removeEventListener('touchstart', onWakeLock);
|
||||
renderDoc.removeEventListener('click', onClick);
|
||||
renderDoc.removeEventListener('wheel', onWheel);
|
||||
renderDoc.removeEventListener('touchstart', onTouchStart);
|
||||
renderDoc.removeEventListener('touchend', onTouchEnd);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (wheelTimeoutId) {
|
||||
clearTimeout(wheelTimeoutId);
|
||||
wheelTimeoutId = null;
|
||||
}
|
||||
listenerCleanups.forEach(cleanup => cleanup());
|
||||
listenerCleanups.length = 0;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export interface TocNode {
|
||||
href: string;
|
||||
label?: string;
|
||||
subitems?: TocNode[];
|
||||
}
|
||||
|
||||
export interface EpubContents {
|
||||
document: Document;
|
||||
sectionIndex?: number;
|
||||
range: (cfi: string) => Range;
|
||||
}
|
||||
|
||||
export interface EpubVisibleSection {
|
||||
index: number;
|
||||
layout: { width: number; divisor: number };
|
||||
width: () => number;
|
||||
expand: () => void;
|
||||
}
|
||||
|
||||
export interface EpubLocation {
|
||||
start: {
|
||||
cfi: string;
|
||||
href?: string;
|
||||
};
|
||||
end: {
|
||||
cfi: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EpubNavigation {
|
||||
toc?: TocNode[];
|
||||
}
|
||||
|
||||
export interface EpubSpineItem {
|
||||
cfiBase: string;
|
||||
index: number;
|
||||
document: Document;
|
||||
load: (_loader: unknown) => Promise<Document>;
|
||||
cfiFromElement: (element: Element) => string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
export interface EpubBook {
|
||||
ready: Promise<void>;
|
||||
navigation?: EpubNavigation;
|
||||
loaded: { navigation: Promise<EpubNavigation> };
|
||||
spine: {
|
||||
spineItems: EpubSpineItem[];
|
||||
get: (index: number) => EpubSpineItem;
|
||||
hooks: {
|
||||
content: { register: (_callback: (output: Document) => void) => void };
|
||||
};
|
||||
};
|
||||
load: (...args: unknown[]) => unknown;
|
||||
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
|
||||
getRange: (cfiRange: string) => Promise<Range>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface EpubRendition {
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
display: (target?: string) => Promise<void>;
|
||||
currentLocation: () => Promise<EpubLocation>;
|
||||
getContents: () => EpubContents[];
|
||||
themes: {
|
||||
default: (styles: Record<string, unknown>) => void;
|
||||
register: (name: string, styles: Record<string, unknown> | string) => void;
|
||||
select: (name: string) => void;
|
||||
};
|
||||
hooks: {
|
||||
content: { register: (_callback: () => void) => void };
|
||||
render: { register: (_callback: (contents: EpubContents) => void) => void };
|
||||
};
|
||||
manager?: {
|
||||
visible?: () => EpubVisibleSection[];
|
||||
};
|
||||
views: () => { container: { scrollLeft: number } };
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface ParsedCfiPath {
|
||||
steps: unknown[];
|
||||
terminal: unknown;
|
||||
}
|
||||
|
||||
export interface ParsedCfi {
|
||||
base: unknown;
|
||||
path: ParsedCfiPath;
|
||||
}
|
||||
|
||||
export interface EpubCfiHelper {
|
||||
parse: (_value: string) => ParsedCfi;
|
||||
equalStep: (_a: unknown, _b: unknown) => boolean;
|
||||
segmentString: (_value: unknown) => string;
|
||||
}
|
||||
|
||||
export interface EpubWithCfiConstructor {
|
||||
CFI: new () => EpubCfiHelper;
|
||||
}
|
||||
|
||||
export interface ReaderStats {
|
||||
chapterName: string;
|
||||
sectionPage: number;
|
||||
sectionTotalPages: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ReaderTocItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
||||
import App from './App';
|
||||
import { ApiError } from './utils/apiFetch';
|
||||
import './index.css';
|
||||
|
||||
initializeThemeMode();
|
||||
@@ -13,7 +14,14 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
// 4xx responses are deterministic (e.g. /auth/me 401 when logged out); only retry transient
|
||||
// network/5xx failures.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status < 500) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
|
||||
@@ -1,46 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { documentColumn } from '../components/documentColumn';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { formatDuration, formatDateTime } from '../utils/formatters';
|
||||
|
||||
const ACTIVITY_PAGE_SIZE = 25;
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const documentID = searchParams.get('document') || undefined;
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, setPage } = usePaginatedList(documentID);
|
||||
const limit = ACTIVITY_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [documentID]);
|
||||
|
||||
const { data, isLoading } = useGetActivity({
|
||||
doc_filter: Boolean(documentID),
|
||||
document_id: documentID,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
const response = data?.status === 200 ? data.data : undefined;
|
||||
const response = data;
|
||||
const activities = response?.activities ?? [];
|
||||
|
||||
const columns: Column<Activity>[] = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
documentColumn,
|
||||
{
|
||||
id: 'start_time',
|
||||
header: 'Time',
|
||||
render: row => row.start_time || 'N/A',
|
||||
render: row => formatDateTime(row.start_time),
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type SyntheticEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LoadingState } from '../components';
|
||||
import { LoadingState, Table, type Column } from '../components';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import type { DirectoryItem } from '../generated/model';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||
const { showInfo, showError } = useToasts();
|
||||
const navigate = useNavigate();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||
currentPath ? { directory: currentPath } : {}
|
||||
@@ -21,8 +20,7 @@ export default function AdminImportPage() {
|
||||
|
||||
const postImport = usePostImport();
|
||||
|
||||
const directoryResponse =
|
||||
directoryData?.status === 200 ? directoryData.data : null;
|
||||
const directoryResponse = directoryData;
|
||||
const directories = directoryResponse?.items ?? [];
|
||||
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;
|
||||
|
||||
postImport.mutate(
|
||||
@@ -48,15 +47,11 @@ export default function AdminImportPage() {
|
||||
type: importType,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: _response => {
|
||||
showInfo('Import completed successfully');
|
||||
navigate('/admin/import-results');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Import failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
toastMutationOptions({
|
||||
success: 'Import completed successfully',
|
||||
error: 'Import failed',
|
||||
onSuccess: () => navigate('/admin/import-results'),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -70,105 +65,83 @@ export default function AdminImportPage() {
|
||||
|
||||
if (selectedDirectory) {
|
||||
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">
|
||||
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<div className="flex items-center gap-4 text-content">
|
||||
<FolderOpenIcon size={20} />
|
||||
<p className="break-all text-lg font-medium">{selectedDirectory}</p>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col justify-around gap-2 text-content">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="direct"
|
||||
checked={importType === 'DIRECT'}
|
||||
onChange={() => setImportType('DIRECT')}
|
||||
/>
|
||||
<label htmlFor="direct">Direct</label>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="copy"
|
||||
checked={importType === 'COPY'}
|
||||
onChange={() => setImportType('COPY')}
|
||||
/>
|
||||
<label htmlFor="copy">Copy</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<div className="flex items-center gap-4 text-content">
|
||||
<FolderOpenIcon size={20} />
|
||||
<p className="break-all text-lg font-medium">{selectedDirectory}</p>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col justify-around gap-2 text-content">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="direct"
|
||||
checked={importType === 'DIRECT'}
|
||||
onChange={() => setImportType('DIRECT')}
|
||||
/>
|
||||
<label htmlFor="direct">Direct</label>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="px-10 py-2 text-base">
|
||||
Import Directory
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="px-10 py-2 text-base"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="copy"
|
||||
checked={importType === 'COPY'}
|
||||
onChange={() => setImportType('COPY')}
|
||||
/>
|
||||
<label htmlFor="copy">Copy</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="px-10 py-2 text-base">
|
||||
Import Directory
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="px-10 py-2 text-base"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const directoryColumns: Column<DirectoryItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: '',
|
||||
className: 'w-12',
|
||||
render: item => (
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<FolderOpenIcon size={20} />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: currentPathDisplay, render: item => item.name ?? '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||
<thead className="text-content-muted">
|
||||
<tr>
|
||||
<th className="w-12 border-b border-border p-3 text-left font-normal"></th>
|
||||
<th className="break-all border-b border-border p-3 text-left font-normal">
|
||||
{currentPath}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr>
|
||||
<td className="border-b border-border p-3 text-content-muted"></td>
|
||||
<td className="border-b border-border p-3">
|
||||
<button onClick={handleNavigateUp}>
|
||||
<p>../</p>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{directories.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={2}>
|
||||
No Folders
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
directories.map((item: DirectoryItem) => (
|
||||
<tr key={item.name}>
|
||||
<td className="border-b border-border p-3 text-content-muted">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<FolderOpenIcon size={20} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||
<p>{item.name ?? ''}</p>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
{currentPathDisplay !== '/' && (
|
||||
<button
|
||||
onClick={handleNavigateUp}
|
||||
className="self-start text-content hover:text-primary-600"
|
||||
>
|
||||
../
|
||||
</button>
|
||||
)}
|
||||
<Table
|
||||
columns={directoryColumns}
|
||||
data={directories}
|
||||
emptyMessage="No Folders"
|
||||
rowKey={item => item.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,41 @@
|
||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||
import { LoadingState } from '../components';
|
||||
import { Table, type Column } from '../components';
|
||||
import type { ImportResult } from '../generated/model';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function AdminImportResultsPage() {
|
||||
const { data: resultsData, isLoading } = useGetImportResults();
|
||||
const results =
|
||||
resultsData?.status === 200 ? resultsData.data.results || [] : [];
|
||||
const results = resultsData?.results ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
const columns: Column<ImportResult>[] = [
|
||||
{
|
||||
id: 'document',
|
||||
header: 'Document',
|
||||
render: result => (
|
||||
<div className="grid grid-cols-[4rem_auto] gap-y-1">
|
||||
<span className="text-content-muted">Name:</span>
|
||||
{result.id ? (
|
||||
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||
{result.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>N/A</span>
|
||||
)}
|
||||
<span className="text-content-muted">File:</span>
|
||||
<span>{result.path}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: 'status', header: 'Status', render: result => result.status },
|
||||
{ id: 'error', header: 'Error', render: result => result.error ?? '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-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>
|
||||
{result.id ? (
|
||||
<Link
|
||||
to={`/documents/${result.id}`}
|
||||
className="text-secondary-600 hover:underline"
|
||||
>
|
||||
{result.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>N/A</span>
|
||||
)}
|
||||
<span className="text-content-muted">File:</span>
|
||||
<span>{result.path}</span>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{result.status}</p>
|
||||
</td>
|
||||
<td className="border-b border-border p-3">
|
||||
<p>{result.error || ''}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={results}
|
||||
loading={isLoading}
|
||||
rowKey={result => result.path ?? result.name ?? ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SyntheticEvent } from 'react';
|
||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState, TextInput } from '../components';
|
||||
import { Button, LoadingState, TextInput, IconInput } from '../components';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon } from '../icons';
|
||||
|
||||
@@ -10,7 +9,7 @@ export default function AdminLogsPage() {
|
||||
|
||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
||||
|
||||
const logs = logsData?.status === 200 ? (logsData.data.logs ?? []) : [];
|
||||
const logs = logsData?.logs ?? [];
|
||||
|
||||
const handleFilterSubmit = (e: SyntheticEvent) => {
|
||||
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">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={filter}
|
||||
@@ -33,7 +29,7 @@ export default function AdminLogsPage() {
|
||||
className="p-2"
|
||||
placeholder="JQ Filter"
|
||||
/>
|
||||
</div>
|
||||
</IconInput>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Filter
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, SyntheticEvent } from 'react';
|
||||
import { LoadingState } from '../components';
|
||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
@@ -13,9 +12,8 @@ interface BackupTypes {
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { isLoading } = useGetAdmin();
|
||||
const postAdminAction = usePostAdminAction();
|
||||
const { showInfo, showError, removeToast } = useToasts();
|
||||
const { showInfo, showError, updateToast } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||
@@ -63,28 +61,24 @@ export default function AdminPage() {
|
||||
e.preventDefault();
|
||||
if (!restoreFile) return;
|
||||
|
||||
// Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator.
|
||||
const startedToastId = showInfo('Restore started', 0);
|
||||
// Progress Toast - Restore is long-running; a persistent 'started' toast resolves in place into the result toast on completion.
|
||||
const toastId = showInfo('Restore started', 0);
|
||||
|
||||
try {
|
||||
const response = await postAdminAction.mutateAsync({
|
||||
await postAdminAction.mutateAsync({
|
||||
data: {
|
||||
action: 'RESTORE',
|
||||
restore_file: restoreFile,
|
||||
},
|
||||
});
|
||||
|
||||
removeToast(startedToastId);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
showInfo('Restore completed successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
showError('Restore failed: ' + getErrorMessage(response.data));
|
||||
updateToast(toastId, { message: 'Restore completed successfully', duration: 5000 });
|
||||
} catch (error) {
|
||||
removeToast(startedToastId);
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
updateToast(toastId, {
|
||||
type: 'error',
|
||||
message: `Restore failed: ${getErrorMessage(error)}`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,10 +102,6 @@ export default function AdminPage() {
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full grow flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
|
||||
@@ -6,32 +6,131 @@ import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import type { User } from '../generated/model';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
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() {
|
||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||
const updateUser = useUpdateUser();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
const { showError } = useToasts();
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
|
||||
const users = usersData?.status === 200 ? (usersData.data.users ?? []) : [];
|
||||
const users = usersData?.users ?? [];
|
||||
|
||||
const handleCreateUser = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newUsername || !newPassword) return;
|
||||
const handleCreateUser = (username: string, password: string, isAdmin: boolean) => {
|
||||
if (!username || !password) {
|
||||
showError('Please enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
updateUser.mutate(
|
||||
{
|
||||
data: {
|
||||
operation: 'CREATE',
|
||||
user: newUsername,
|
||||
password: newPassword,
|
||||
is_admin: newIsAdmin,
|
||||
user: username,
|
||||
password,
|
||||
is_admin: isAdmin,
|
||||
},
|
||||
},
|
||||
toastMutationOptions({
|
||||
@@ -39,9 +138,6 @@ export default function AdminUsersPage() {
|
||||
error: 'Failed to create user',
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false);
|
||||
setNewUsername('');
|
||||
setNewPassword('');
|
||||
setNewIsAdmin(false);
|
||||
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>[] = [
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -116,15 +205,14 @@ export default function AdminUsersPage() {
|
||||
id: 'password',
|
||||
header: 'Password',
|
||||
render: user => (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -150,7 +238,12 @@ export default function AdminUsersPage() {
|
||||
</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) {
|
||||
@@ -159,77 +252,16 @@ export default function AdminUsersPage() {
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-x-auto">
|
||||
{showAddForm && (
|
||||
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
|
||||
<form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="bg-surface p-2 text-content"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="bg-surface p-2 text-content"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="new_is_admin"
|
||||
checked={newIsAdmin}
|
||||
onChange={e => setNewIsAdmin(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="new_is_admin">Admin</label>
|
||||
</div>
|
||||
<button
|
||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showAddForm && <AddUserForm onCreate={handleCreateUser} />}
|
||||
|
||||
<Table columns={userColumns} data={users} rowKey="id" />
|
||||
|
||||
{resetUserId && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50"
|
||||
onClick={() => setResetUserId(null)}
|
||||
>
|
||||
<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>
|
||||
<ResetPasswordDialog
|
||||
userId={resetUserId}
|
||||
onClose={() => setResetUserId(null)}
|
||||
onSave={handleUpdatePassword}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,16 +8,11 @@ import {
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import type { EditDocumentBody } from '../generated/model';
|
||||
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 { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
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-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600';
|
||||
|
||||
interface EditableFieldProps {
|
||||
label: string;
|
||||
@@ -100,7 +95,7 @@ function EditableField({
|
||||
type="text"
|
||||
value={draft}
|
||||
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>
|
||||
@@ -114,9 +109,11 @@ function EditableField({
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '', {
|
||||
query: { enabled: Boolean(id) },
|
||||
});
|
||||
const editMutation = useEditDocument();
|
||||
const { showError } = useToasts();
|
||||
const runWithToast = useToastMutation();
|
||||
|
||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||
|
||||
@@ -124,30 +121,21 @@ export default function DocumentPage() {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!docData || docData.status !== 200) {
|
||||
const doc = docData?.document;
|
||||
|
||||
if (!doc) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const document = docData.data.document;
|
||||
|
||||
const percentage = document.percentage ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const percentage = doc.percentage ?? 0;
|
||||
const secondsPerPercent = doc.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
||||
try {
|
||||
const response = await editMutation.mutateAsync({ id: document.id, data });
|
||||
if (response.status !== 200) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
const save = (data: EditDocumentBody): Promise<boolean> =>
|
||||
runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
|
||||
error: 'Failed to save',
|
||||
onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
@@ -155,13 +143,13 @@ 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">
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={`${doc.title} cover`}
|
||||
/>
|
||||
|
||||
{document.filepath && (
|
||||
{doc.filepath && (
|
||||
<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-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
|
||||
>
|
||||
Read
|
||||
@@ -172,26 +160,26 @@ export default function DocumentPage() {
|
||||
<div className="min-w-[50%] md:mr-2">
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-content-muted">ISBN-10:</p>
|
||||
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
||||
<p className="font-medium">{doc.isbn10 || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-content-muted">ISBN-13:</p>
|
||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
||||
<p className="font-medium">{doc.isbn13 || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative my-auto flex grow justify-between text-content-muted">
|
||||
<a
|
||||
href={`/activity?document=${document.id}`}
|
||||
href={`/activity?document=${doc.id}`}
|
||||
aria-label="Activity"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<ActivityIcon size={28} />
|
||||
</a>
|
||||
|
||||
{document.filepath ? (
|
||||
{doc.filepath ? (
|
||||
<a
|
||||
href={`/api/v1/documents/${document.id}/file`}
|
||||
href={`/api/v1/documents/${doc.id}/file`}
|
||||
aria-label="Download"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
@@ -207,15 +195,11 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||
<EditableField
|
||||
label="Title"
|
||||
value={document.title}
|
||||
onSave={value => save({ title: value })}
|
||||
/>
|
||||
<EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
|
||||
|
||||
<EditableField
|
||||
label="Author"
|
||||
value={document.author}
|
||||
value={doc.author}
|
||||
onSave={value => save({ author: value })}
|
||||
/>
|
||||
|
||||
@@ -232,7 +216,7 @@ export default function DocumentPage() {
|
||||
<InfoIcon size={18} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute right-0 top-7 z-30 ${popupClassName} ${
|
||||
className={`absolute right-0 top-7 z-30 rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200 ${
|
||||
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
@@ -244,9 +228,7 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||
<p className="font-medium">
|
||||
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
||||
</p>
|
||||
<p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||
@@ -259,8 +241,8 @@ export default function DocumentPage() {
|
||||
}
|
||||
>
|
||||
<FieldValue>
|
||||
{document.total_time_seconds && document.total_time_seconds > 0
|
||||
? formatDuration(document.total_time_seconds)
|
||||
{doc.total_time_seconds && doc.total_time_seconds > 0
|
||||
? formatDuration(doc.total_time_seconds)
|
||||
: 'N/A'}
|
||||
</FieldValue>
|
||||
</Field>
|
||||
@@ -272,7 +254,7 @@ export default function DocumentPage() {
|
||||
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={document.description || ''}
|
||||
value={doc.description || ''}
|
||||
multiline
|
||||
valueClassName="hyphens-auto text-justify"
|
||||
onSave={value => save({ description: value })}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document } from '../generated/model';
|
||||
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 { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { cn } from '../utils/cn';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import {
|
||||
getDocumentsViewMode,
|
||||
setDocumentsViewMode,
|
||||
type DocumentsViewMode,
|
||||
} from '../utils/localSettings';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
|
||||
|
||||
const DOCUMENTS_PAGE_SIZE = 9;
|
||||
|
||||
@@ -22,25 +20,18 @@ interface DocumentItemProps {
|
||||
}
|
||||
|
||||
function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const percentage = doc.percentage || 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 onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const icons = (
|
||||
const actions = (
|
||||
<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} />
|
||||
</Link>
|
||||
{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} />
|
||||
</a>
|
||||
) : (
|
||||
@@ -50,91 +41,82 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
|
||||
);
|
||||
|
||||
const fields = [
|
||||
{ label: 'Title', value: doc.title || 'Unknown' },
|
||||
{ label: 'Title', value: title, link: true },
|
||||
{ label: 'Author', value: doc.author || 'Unknown' },
|
||||
{ label: 'Progress', value: `${percentage}%` },
|
||||
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) },
|
||||
];
|
||||
|
||||
const fieldList = (
|
||||
<div
|
||||
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
|
||||
>
|
||||
{fields.map(field => (
|
||||
<div key={field.label}>
|
||||
<p className="text-content-subtle">{field.label}</p>
|
||||
{field.link ? (
|
||||
<Link to={documentPath} className="font-medium hover:underline">
|
||||
{field.value}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">{field.value}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
||||
onClick={open}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="relative my-auto h-48 min-w-fit">
|
||||
<img
|
||||
className="h-full rounded object-cover"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-around text-sm text-content">
|
||||
{fields.map(f => (
|
||||
<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 className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
||||
{icons}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-hidden"
|
||||
onClick={open}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
|
||||
{fields.map(f => (
|
||||
<div key={f.label}>
|
||||
<p className="text-content-subtle">{f.label}</p>
|
||||
<p className="font-medium">{f.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{icons}
|
||||
{fieldList}
|
||||
{actions}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [search, setSearch, debouncedSearch] = useDebouncedState('', 300);
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, setPage } = usePaginatedList(debouncedSearch);
|
||||
const limit = DOCUMENTS_PAGE_SIZE;
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
|
||||
const [viewMode, setViewMode] = useLocalSetting('documentsViewMode', 'grid');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showWarning } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
|
||||
useEffect(() => {
|
||||
setDocumentsViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
|
||||
const createMutation = useCreateDocument();
|
||||
const documentsResponse = data?.status === 200 ? data.data : undefined;
|
||||
const documentsResponse = data;
|
||||
const docs = documentsResponse?.documents;
|
||||
const previousPage = documentsResponse?.previous_page;
|
||||
const nextPage = documentsResponse?.next_page;
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={search}
|
||||
@@ -190,24 +162,17 @@ export default function DocumentsPage() {
|
||||
placeholder="Search Author / Title"
|
||||
name="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex rounded border border-border bg-surface p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={getViewModeButtonClasses('grid')}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={getViewModeButtonClasses('list')}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</IconInput>
|
||||
</div>
|
||||
<SegmentedControl<DocumentsViewMode>
|
||||
ariaLabel="Document view mode"
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
options={[
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'list', label: 'List' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,9 +183,7 @@ export default function DocumentsPage() {
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="grid" />)
|
||||
) : (
|
||||
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||
No documents found.
|
||||
</div>
|
||||
<EmptyDocuments className="col-span-full" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -230,9 +193,7 @@ export default function DocumentsPage() {
|
||||
) : docs && docs.length > 0 ? (
|
||||
docs.map(doc => <DocumentItem key={doc.id} doc={doc} layout="list" />)
|
||||
) : (
|
||||
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
|
||||
No documents found.
|
||||
</div>
|
||||
<EmptyDocuments />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { LoadingState } from '../components';
|
||||
import { LoadingState, SegmentedControl } from '../components';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||
import type {
|
||||
LeaderboardData,
|
||||
LeaderboardEntry,
|
||||
UserStreak,
|
||||
} from '../generated/model';
|
||||
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
|
||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||
|
||||
@@ -38,51 +34,39 @@ function InfoCard({ title, size, link }: InfoCardProps) {
|
||||
}
|
||||
|
||||
interface StreakCardProps {
|
||||
window: 'DAY' | 'WEEK';
|
||||
currentStreak: number;
|
||||
currentStreakStartDate: string;
|
||||
currentStreakEndDate: string;
|
||||
maxStreak: number;
|
||||
maxStreakStartDate: string;
|
||||
maxStreakEndDate: string;
|
||||
streak: UserStreak;
|
||||
}
|
||||
|
||||
function StreakCard({
|
||||
window,
|
||||
currentStreak,
|
||||
currentStreakStartDate,
|
||||
currentStreakEndDate,
|
||||
maxStreak,
|
||||
maxStreakStartDate,
|
||||
maxStreakEndDate,
|
||||
}: StreakCardProps) {
|
||||
function StreakCard({ streak }: StreakCardProps) {
|
||||
const isWeekly = streak.window === 'WEEK';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="relative w-full rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
{isWeekly ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
</p>
|
||||
<div className="my-6 flex items-end space-x-2">
|
||||
<p className="text-5xl font-bold">{currentStreak}</p>
|
||||
<p className="text-5xl font-bold">{streak.current_streak}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||
<p>{isWeekly ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-content-subtle">
|
||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||
{streak.current_streak_start_date} ➞ {streak.current_streak_end_date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{currentStreak}</div>
|
||||
<div className="flex items-end font-bold">{streak.current_streak}</div>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||
<p>{isWeekly ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-content-subtle">
|
||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||
{streak.max_streak_start_date} ➞ {streak.max_streak_end_date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{maxStreak}</div>
|
||||
<div className="flex items-end font-bold">{streak.max_streak}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,9 +99,6 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
|
||||
const currentData = data[selectedPeriod];
|
||||
|
||||
const getPeriodClassName = (period: TimePeriod) =>
|
||||
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||
@@ -126,20 +107,22 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||
{name} Leaderboard
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
|
||||
all
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
|
||||
year
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
|
||||
month
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
|
||||
week
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedControl<TimePeriod>
|
||||
variant="unstyled"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
ariaLabel={`${name} leaderboard period`}
|
||||
value={selectedPeriod}
|
||||
onChange={setSelectedPeriod}
|
||||
buttonClassName="cursor-pointer"
|
||||
activeClassName="text-content"
|
||||
inactiveClassName="text-content-subtle hover:text-content"
|
||||
options={[
|
||||
{ value: 'all', label: 'all' },
|
||||
{ value: 'year', label: 'year' },
|
||||
{ value: 'month', label: 'month' },
|
||||
{ value: 'week', label: 'week' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +155,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
export default function HomePage() {
|
||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||
|
||||
const homeResponse = homeData?.status === 200 ? homeData.data : null;
|
||||
const homeResponse = homeData;
|
||||
const dbInfo = homeResponse?.database_info;
|
||||
const streaks = homeResponse?.streaks?.streaks;
|
||||
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">
|
||||
{streaks?.map((streak: UserStreak) => (
|
||||
<StreakCard
|
||||
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}
|
||||
/>
|
||||
<StreakCard key={streak.window} streak={streak} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,16 +51,13 @@ describe('LoginPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
@@ -112,8 +109,8 @@ describe('LoginPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -127,7 +124,7 @@ describe('LoginPage', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid credentials');
|
||||
expect(showErrorMock).toHaveBeenCalledWith('bad credentials');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,10 +152,7 @@ describe('LoginPage', () => {
|
||||
it('shows the registration link only when registration is enabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
registration_enabled: true,
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
@@ -172,10 +166,7 @@ describe('LoginPage', () => {
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
import { useState, SyntheticEvent, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuthForm } from '../hooks/useAuthForm';
|
||||
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() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
||||
const { username, password, isLoading, registrationEnabled, setUsername, setPassword, submit } =
|
||||
useAuthForm('login');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
@@ -46,19 +16,6 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [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 (
|
||||
<AuthFormView
|
||||
username={username}
|
||||
@@ -66,7 +23,7 @@ export default function LoginPage() {
|
||||
isLoading={isLoading}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={submit}
|
||||
submitLabel="Login"
|
||||
submittingLabel="Logging in..."
|
||||
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 type { Progress } from '../generated/model';
|
||||
import { Pagination } from '../components';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { documentColumn } from '../components/documentColumn';
|
||||
import { usePaginatedList } from '../hooks/usePaginatedList';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
const PROGRESS_PAGE_SIZE = 15;
|
||||
|
||||
export default function ProgressPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, setPage } = usePaginatedList();
|
||||
const limit = PROGRESS_PAGE_SIZE;
|
||||
const { data, isLoading } = useGetProgressList({ page, limit });
|
||||
const response = data?.status === 200 ? data.data : undefined;
|
||||
const response = data;
|
||||
const progress = response?.progress ?? [];
|
||||
|
||||
const columns: Column<Progress>[] = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
documentColumn,
|
||||
{
|
||||
id: 'device_name',
|
||||
header: 'Device Name',
|
||||
@@ -32,12 +25,12 @@ export default function ProgressPage() {
|
||||
{
|
||||
id: 'percentage',
|
||||
header: 'Percentage',
|
||||
render: row => (typeof row.percentage === 'number' ? `${Math.round(row.percentage)}%` : '0%'),
|
||||
render: row => `${Math.round(row.percentage)}%`,
|
||||
},
|
||||
{
|
||||
id: '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 { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||
import { LoadingState } from '../components/LoadingState';
|
||||
import { SegmentedControl } from '../components/SegmentedControl';
|
||||
import { CloseIcon } from '../icons';
|
||||
import {
|
||||
getReaderColorScheme,
|
||||
getReaderDevice,
|
||||
getReaderFontFamily,
|
||||
getReaderFontSize,
|
||||
setReaderColorScheme,
|
||||
setReaderFontFamily,
|
||||
setReaderFontSize,
|
||||
READER_COLOR_SCHEMES,
|
||||
READER_FONT_FAMILIES,
|
||||
type ReaderColorScheme,
|
||||
type ReaderFontFamily,
|
||||
getReaderDevice,
|
||||
useLocalSetting,
|
||||
} from '../utils/localSettings';
|
||||
import { useEpubReader } from '../hooks/useEpubReader';
|
||||
|
||||
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
|
||||
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
|
||||
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
|
||||
const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
|
||||
const READER_SEGMENT_INACTIVE =
|
||||
'border-border text-content-muted hover:bg-surface-muted hover:text-content';
|
||||
|
||||
export default function ReaderPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
|
||||
const [isBottomBarOpen, setIsBottomBarOpen] = useState(false);
|
||||
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
|
||||
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
|
||||
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
|
||||
const [colorScheme, setColorScheme] = useLocalSetting('readerColorScheme', 'tan');
|
||||
const [fontFamily, setFontFamily] = useLocalSetting('readerFontFamily', 'Serif');
|
||||
const [fontSize, setFontSize] = useLocalSetting('readerFontSize', 1);
|
||||
|
||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
||||
|
||||
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '');
|
||||
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '', {
|
||||
query: { enabled: Boolean(id) },
|
||||
});
|
||||
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
|
||||
query: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: Boolean(id),
|
||||
},
|
||||
});
|
||||
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
|
||||
const progress = progressResponse?.status === 200 ? progressResponse.data.progress : undefined;
|
||||
const doc = documentResponse?.document;
|
||||
const progress = progressResponse?.progress;
|
||||
|
||||
const deviceId = defaultDeviceId;
|
||||
const deviceName = defaultDeviceName;
|
||||
@@ -84,17 +88,17 @@ export default function ReaderPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (document?.title) {
|
||||
window.document.title = `AnthoLume - Reader - ${document.title}`;
|
||||
if (doc?.title) {
|
||||
document.title = `AnthoLume - Reader - ${doc.title}`;
|
||||
}
|
||||
}, [document?.title]);
|
||||
}, [doc?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTopBarOpen || isBottomBarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = window.document.activeElement;
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
@@ -104,7 +108,7 @@ export default function ReaderPage() {
|
||||
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
|
||||
}
|
||||
|
||||
if (!id || !document || documentResponse?.status !== 200) {
|
||||
if (!id || !doc) {
|
||||
return <div className="p-6 text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
@@ -119,24 +123,24 @@ export default function ReaderPage() {
|
||||
<div className="mx-auto flex max-h-[70vh] min-h-0 w-full max-w-6xl flex-col gap-4 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Link to={`/documents/${document.id}`} className="block shrink-0">
|
||||
<Link to={`/documents/${doc.id}`} className="block shrink-0">
|
||||
<img
|
||||
className="h-28 w-20 rounded object-cover shadow-sm"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={`${doc.title} cover`}
|
||||
/>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
|
||||
<p className="truncate text-lg font-semibold text-content">{document.title}</p>
|
||||
<p className="truncate text-lg font-semibold text-content">{doc.title}</p>
|
||||
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
|
||||
<p className="truncate text-sm text-content-muted">{document.author}</p>
|
||||
<p className="truncate text-sm text-content-muted">{doc.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/documents/${document.id}`}
|
||||
to={`/documents/${doc.id}`}
|
||||
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
Back
|
||||
@@ -218,48 +222,32 @@ export default function ReaderPage() {
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">
|
||||
Theme
|
||||
</p>
|
||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{colorSchemes.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColorSchemeState(option);
|
||||
setReaderColorScheme(option);
|
||||
}}
|
||||
className={`rounded border px-2 py-1.5 text-xs capitalize sm:text-sm ${
|
||||
colorScheme === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl<ReaderColorScheme>
|
||||
variant="unstyled"
|
||||
className="grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 lg:grid-cols-5"
|
||||
ariaLabel="Reader theme"
|
||||
value={colorScheme}
|
||||
onChange={setColorScheme}
|
||||
buttonClassName={`${READER_SEGMENT_BUTTON} capitalize`}
|
||||
activeClassName={READER_SEGMENT_ACTIVE}
|
||||
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||
options={READER_COLOR_SCHEMES.map(value => ({ value, label: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-[10px] uppercase tracking-wide text-content-subtle">Font</p>
|
||||
<div className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{fontFamilies.map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFontFamilyState(option);
|
||||
setReaderFontFamily(option);
|
||||
}}
|
||||
className={`rounded border px-2 py-1.5 text-xs sm:text-sm ${
|
||||
fontFamily === option
|
||||
? 'border-primary-500 bg-primary-500/10 text-content'
|
||||
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl<ReaderFontFamily>
|
||||
variant="unstyled"
|
||||
className="grid w-full grid-cols-1 gap-1.5 sm:grid-cols-2 lg:grid-cols-4"
|
||||
ariaLabel="Reader font"
|
||||
value={fontFamily}
|
||||
onChange={setFontFamily}
|
||||
buttonClassName={READER_SEGMENT_BUTTON}
|
||||
activeClassName={READER_SEGMENT_ACTIVE}
|
||||
inactiveClassName={READER_SEGMENT_INACTIVE}
|
||||
options={READER_FONT_FAMILIES.map(value => ({ value, label: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -269,11 +257,7 @@ export default function ReaderPage() {
|
||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
onClick={() => setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
-
|
||||
@@ -283,11 +267,7 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
|
||||
setFontSizeState(nextSize);
|
||||
setReaderFontSize(nextSize);
|
||||
}}
|
||||
onClick={() => setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))}
|
||||
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||
>
|
||||
+
|
||||
|
||||
@@ -51,16 +51,13 @@ describe('RegisterPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
registration_enabled: true,
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
@@ -113,8 +110,8 @@ describe('RegisterPage', () => {
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
updateToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -128,7 +125,7 @@ describe('RegisterPage', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Registration failed');
|
||||
expect(showErrorMock).toHaveBeenCalledWith('failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,10 +153,7 @@ describe('RegisterPage', () => {
|
||||
it('redirects to login when registration is disabled', async () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
@@ -178,10 +172,7 @@ describe('RegisterPage', () => {
|
||||
it('disables the form when registration is disabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
registration_enabled: false,
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { useState, SyntheticEvent, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
import { useAuthForm } from '../hooks/useAuthForm';
|
||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||
import { getRegistrationEnabled } from './LoginPage';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { register, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
||||
const {
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
isLoadingInfo,
|
||||
registrationEnabled,
|
||||
setUsername,
|
||||
setPassword,
|
||||
submit,
|
||||
} = useAuthForm('register');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
@@ -33,19 +29,6 @@ export default function RegisterPage() {
|
||||
}
|
||||
}, [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 (
|
||||
<AuthFormView
|
||||
username={username}
|
||||
@@ -53,7 +36,7 @@ export default function RegisterPage() {
|
||||
isLoading={isLoading}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={submit}
|
||||
submitLabel="Register"
|
||||
submittingLabel="Registering..."
|
||||
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
||||
|
||||
@@ -15,10 +15,7 @@ describe('SearchPage', () => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [],
|
||||
},
|
||||
results: [],
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
@@ -67,20 +64,16 @@ describe('SearchPage', () => {
|
||||
it('renders search results from the generated hook response', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
author: 'Ursula Le Guin',
|
||||
title: 'A Wizard of Earthsea',
|
||||
series: 'Earthsea',
|
||||
file_type: 'epub',
|
||||
file_size: '1 MB',
|
||||
upload_date: '2025-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
results: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
author: 'Ursula Le Guin',
|
||||
title: 'A Wizard of Earthsea',
|
||||
series: 'Earthsea',
|
||||
file_type: 'epub',
|
||||
file_size: '1 MB',
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
@@ -2,34 +2,20 @@ import { useState, SyntheticEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import type { SearchItem } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { TextInput } from '../components';
|
||||
import { Button, Table, type Column, TextInput, IconInput } from '../components';
|
||||
import { inputClassName } from '../components/TextInput';
|
||||
import { Table, type Column } from '../components/Table';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Search2Icon, BookIcon } from '../icons';
|
||||
|
||||
const searchColumns: Column<SearchItem>[] = [
|
||||
{
|
||||
id: 'download',
|
||||
header: '',
|
||||
className: 'w-12 text-content-muted',
|
||||
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: 'document', header: 'Document', render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}` },
|
||||
{ id: 'series', header: 'Series', render: item => item.series || 'N/A' },
|
||||
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
|
||||
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
|
||||
{
|
||||
id: 'date',
|
||||
header: 'Date',
|
||||
className: 'hidden md:table-cell',
|
||||
render: item => item.upload_date || 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
interface SearchPageViewProps {
|
||||
@@ -42,26 +28,6 @@ interface SearchPageViewProps {
|
||||
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({
|
||||
query,
|
||||
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">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<Search2Icon size={15} hoverable={false} />
|
||||
</span>
|
||||
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => onQueryChange(e.target.value)}
|
||||
placeholder="Query"
|
||||
/>
|
||||
</div>
|
||||
</IconInput>
|
||||
</div>
|
||||
<div className="relative flex min-w-[12em]">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<BookIcon size={15} />
|
||||
</span>
|
||||
<IconInput className="min-w-[12em]" icon={<BookIcon size={15} />}>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
||||
@@ -101,7 +61,7 @@ export function SearchPageView({
|
||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
</IconInput>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Search
|
||||
</Button>
|
||||
@@ -126,7 +86,7 @@ export default function SearchPage() {
|
||||
},
|
||||
}
|
||||
);
|
||||
const results = getSearchResults(data);
|
||||
const results = data?.results ?? [];
|
||||
|
||||
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
+181
-143
@@ -1,17 +1,14 @@
|
||||
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 { Table, type Column } from '../components/Table';
|
||||
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 { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||
import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { ThemeMode } from '../utils/localSettings';
|
||||
|
||||
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
|
||||
import { formatDateTime } from '../utils/formatters';
|
||||
|
||||
const deviceColumns: Column<Device>[] = [
|
||||
{
|
||||
@@ -20,8 +17,12 @@ const deviceColumns: Column<Device>[] = [
|
||||
className: 'pl-0',
|
||||
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 }> = [
|
||||
@@ -30,16 +31,171 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
|
||||
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
|
||||
];
|
||||
|
||||
function ProfileCard({ settings }: { settings?: SettingsResponse }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
||||
<UserIcon size={60} />
|
||||
<p className="text-lg text-content">{settings?.user.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordSection({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (password: string, next: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
if (await onSubmit(password, newPassword)) {
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex grow flex-col">
|
||||
<IconInput icon={<PasswordIcon size={15} />}>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
</IconInput>
|
||||
</div>
|
||||
<div className="flex grow flex-col">
|
||||
<IconInput icon={<PasswordIcon size={15} />}>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="New Password"
|
||||
/>
|
||||
</IconInput>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
|
||||
<p>
|
||||
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => {
|
||||
const isSelected = themeMode === mode.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode.value)}
|
||||
className={`rounded border p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
|
||||
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-content">{mode.label}</span>
|
||||
<span
|
||||
className={`inline-flex size-4 rounded-full border ${
|
||||
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">{mode.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimezoneSection({
|
||||
timezone,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
timezone: string;
|
||||
onChange: (timezone: string) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const handleSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<IconInput className="grow" icon={<ClockIcon size={15} />}>
|
||||
<select
|
||||
value={timezone || 'UTC'}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="America/Chicago">America/Chicago</option>
|
||||
<option value="America/Denver">America/Denver</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||
</select>
|
||||
</IconInput>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesSection({ devices }: { devices: Device[] }) {
|
||||
return (
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Devices</p>
|
||||
<Table columns={deviceColumns} data={devices} rowKey="id" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = data?.status === 200 ? data.data : null;
|
||||
const settingsData = data;
|
||||
const { showError } = useToasts();
|
||||
const toastMutationOptions = useMutationWithToast();
|
||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
||||
const runWithToast = useToastMutation();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [timezone, setTimezone] = useState('UTC');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,30 +204,22 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
const handlePasswordSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const updatePassword = async (password: string, newPassword: string) => {
|
||||
if (!password || !newPassword) {
|
||||
showError('Please enter both current and new password');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
updateSettings.mutate(
|
||||
{ data: { password, new_password: newPassword } },
|
||||
toastMutationOptions({
|
||||
return runWithToast(
|
||||
() => updateSettings.mutateAsync({ data: { password, new_password: newPassword } }),
|
||||
{
|
||||
success: 'Password updated successfully',
|
||||
error: 'Failed to update password',
|
||||
onSuccess: () => {
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
},
|
||||
})
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleTimezoneSubmit = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const updateTimezone = () => {
|
||||
updateSettings.mutate(
|
||||
{ data: { timezone } },
|
||||
toastMutationOptions({
|
||||
@@ -87,122 +235,12 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
||||
<UserIcon size={60} />
|
||||
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileCard settings={settingsData} />
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<PasswordIcon size={15} />
|
||||
</span>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<PasswordIcon size={15} />
|
||||
</span>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="New Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
|
||||
<p>
|
||||
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{themeModes.map(mode => {
|
||||
const isSelected = themeMode === mode.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(mode.value)}
|
||||
className={`rounded border p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
|
||||
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-content">{mode.label}</span>
|
||||
<span
|
||||
className={`inline-flex size-4 rounded-full border ${
|
||||
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">{mode.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
||||
<div className="relative flex grow">
|
||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||
<ClockIcon size={15} />
|
||||
</span>
|
||||
<select
|
||||
value={timezone || 'UTC'}
|
||||
onChange={e => setTimezone(e.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="America/Chicago">America/Chicago</option>
|
||||
<option value="America/Denver">America/Denver</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||
<p className="text-lg font-semibold text-content">Devices</p>
|
||||
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" />
|
||||
</div>
|
||||
<PasswordSection onSubmit={updatePassword} />
|
||||
<AppearanceSection />
|
||||
<TimezoneSection timezone={timezone} onChange={setTimezone} onSubmit={updateTimezone} />
|
||||
<DevicesSection devices={settingsData?.devices ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -8,9 +7,8 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
getThemeMode,
|
||||
setThemeMode,
|
||||
LOCAL_SETTINGS_KEY,
|
||||
useLocalSetting,
|
||||
readLocalSetting,
|
||||
type ThemeMode,
|
||||
} from '../utils/localSettings';
|
||||
|
||||
@@ -49,23 +47,23 @@ export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
|
||||
}
|
||||
|
||||
export function initializeThemeMode(): ResolvedThemeMode {
|
||||
return applyThemeMode(getThemeMode());
|
||||
return applyThemeMode(readLocalSetting('themeMode', 'system'));
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
|
||||
const [themeMode, setThemeMode] = useLocalSetting('themeMode', 'system');
|
||||
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
|
||||
resolveThemeMode(getThemeMode())
|
||||
resolveThemeMode(themeMode)
|
||||
);
|
||||
|
||||
// Single Source Of Truth - The mode effect is the only place that applies the theme to the DOM and resolves it into state. Every other code path just 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(() => {
|
||||
setResolvedThemeMode(applyThemeMode(themeModeState));
|
||||
}, [themeModeState]);
|
||||
setResolvedThemeMode(applyThemeMode(themeMode));
|
||||
}, [themeMode]);
|
||||
|
||||
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeModeState` is unchanged.
|
||||
// System Preference - When the user follows 'system', the resolved theme must react to OS changes even though `themeMode` is unchanged.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || themeModeState !== 'system') {
|
||||
if (typeof window === 'undefined' || themeMode !== 'system') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -78,39 +76,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
|
||||
};
|
||||
}, [themeModeState]);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
}, [themeMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode: themeModeState,
|
||||
themeMode,
|
||||
resolvedThemeMode,
|
||||
setThemeMode: updateThemeMode,
|
||||
setThemeMode,
|
||||
}),
|
||||
[resolvedThemeMode, themeModeState, updateThemeMode]
|
||||
[resolvedThemeMode, themeMode, setThemeMode]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ErrorResponse } from '../generated/model';
|
||||
|
||||
// Thrown by the generated API client for any non-2xx response. `body` is the parsed error payload
|
||||
// (the API's ErrorResponse for documented failures); `message` is the server-provided message when
|
||||
// present, so `error.message` is always display-ready.
|
||||
export class ApiError<TBody = ErrorResponse> extends Error {
|
||||
readonly status: number;
|
||||
readonly body: TBody;
|
||||
|
||||
constructor(status: number, body: TBody, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
|
||||
function messageFromBody(body: unknown, fallback: string): string {
|
||||
if (body && typeof body === 'object' && 'message' in body) {
|
||||
const { message } = body as { message?: unknown };
|
||||
if (typeof message === 'string' && message.trim() !== '') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<unknown> {
|
||||
if (EMPTY_BODY_STATUSES.has(response.status)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
return contentType.includes('application/json') ? JSON.parse(raw) : raw;
|
||||
}
|
||||
|
||||
// Orval mutator - The generated client calls this for every request. Returns the parsed success
|
||||
// body directly and throws ApiError on non-2xx, so React Query surfaces failures via isError/onError.
|
||||
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, options);
|
||||
const body = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body, messageFromBody(body, response.statusText || 'Request failed'));
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Extracts a display message from a caught error. The generated client throws ApiError (an Error
|
||||
// subclass whose `message` is the server-provided message), so this covers both API and unexpected
|
||||
// failures in a single catch path.
|
||||
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatNumber, formatDuration } from './formatters';
|
||||
import { formatNumber, formatDuration, formatDate, formatDateTime } from './formatters';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('formats zero', () => {
|
||||
@@ -33,7 +33,6 @@ describe('formatNumber', () => {
|
||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
@@ -61,5 +60,21 @@ describe('formatDuration', () => {
|
||||
it('formats days, hours, minutes, and seconds', () => {
|
||||
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('formatDate / formatDateTime', () => {
|
||||
it('returns N/A for empty or unparseable values', () => {
|
||||
expect(formatDate(undefined)).toBe('N/A');
|
||||
expect(formatDate('')).toBe('N/A');
|
||||
expect(formatDate('not-a-date')).toBe('N/A');
|
||||
expect(formatDateTime(undefined)).toBe('N/A');
|
||||
expect(formatDateTime('not-a-date')).toBe('N/A');
|
||||
});
|
||||
|
||||
it('formats a valid ISO timestamp to a non-empty, non-N/A string', () => {
|
||||
const date = formatDate('2023-06-15T12:00:00Z');
|
||||
expect(date).not.toBe('N/A');
|
||||
expect(date.length).toBeGreaterThan(0);
|
||||
expect(formatDateTime('2023-06-15T12:00:00Z')).not.toBe('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,28 @@ export function formatDuration(seconds: number): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function toValidDate(value?: string): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// Local Display Dates - Shared formatters for user-facing timestamps so every list/table renders
|
||||
// them the same way, returning 'N/A' for empty or unparseable values.
|
||||
export function formatDate(value?: string): string {
|
||||
const date = toValidDate(value);
|
||||
return date ? date.toLocaleDateString() : 'N/A';
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string): string {
|
||||
const date = toValidDate(value);
|
||||
return date ? date.toLocaleString() : 'N/A';
|
||||
}
|
||||
|
||||
// UTC Date - Intentionally UTC (not local): the reading graph buckets activity by UTC day, so
|
||||
// converting to local time could shift a point to the wrong day.
|
||||
export function formatUtcDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getUTCFullYear();
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type DocumentsViewMode = 'grid' | 'list';
|
||||
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
|
||||
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// Value arrays are the single source of truth; the union types derive from them so the
|
||||
// runtime iteration lists (reader theme picker, nav, etc.) and the schema can't drift.
|
||||
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
export type ThemeMode = (typeof THEME_MODES)[number];
|
||||
|
||||
export const DOCUMENTS_VIEW_MODES = ['grid', 'list'] as const;
|
||||
export type DocumentsViewMode = (typeof DOCUMENTS_VIEW_MODES)[number];
|
||||
|
||||
export const READER_COLOR_SCHEMES = ['light', 'tan', 'blue', 'gray', 'black'] as const;
|
||||
export type ReaderColorScheme = (typeof READER_COLOR_SCHEMES)[number];
|
||||
|
||||
export const READER_FONT_FAMILIES = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'] as const;
|
||||
export type ReaderFontFamily = (typeof READER_FONT_FAMILIES)[number];
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = 'antholume:settings';
|
||||
|
||||
interface LocalSettings {
|
||||
themeMode?: ThemeMode;
|
||||
documentsViewMode?: DocumentsViewMode;
|
||||
readerColorScheme?: ReaderColorScheme;
|
||||
readerFontFamily?: ReaderFontFamily;
|
||||
readerFontSize?: number;
|
||||
readerDeviceId?: string;
|
||||
readerDeviceName?: string;
|
||||
interface LocalSettingsMap {
|
||||
themeMode: ThemeMode;
|
||||
documentsViewMode: DocumentsViewMode;
|
||||
readerColorScheme: ReaderColorScheme;
|
||||
readerFontFamily: ReaderFontFamily;
|
||||
readerFontSize: number;
|
||||
readerDeviceId: string;
|
||||
readerDeviceName: string;
|
||||
}
|
||||
|
||||
type LocalSettingKey = keyof LocalSettingsMap;
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function readLocalSettings(): LocalSettings {
|
||||
function readRawSettings(): Record<string, unknown> {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
@@ -37,111 +50,100 @@ function readLocalSettings(): LocalSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalSettings(settings: LocalSettings): void {
|
||||
function writeRawSettings(settings: Record<string, unknown>): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function updateLocalSettings(partialSettings: LocalSettings): void {
|
||||
writeLocalSettings({
|
||||
...readLocalSettings(),
|
||||
...partialSettings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getThemeMode(): ThemeMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.themeMode === 'light' || settings.themeMode === 'dark'
|
||||
? settings.themeMode
|
||||
: 'system';
|
||||
}
|
||||
|
||||
export function setThemeMode(themeMode: ThemeMode): void {
|
||||
updateLocalSettings({ themeMode });
|
||||
}
|
||||
|
||||
export function getDocumentsViewMode(): DocumentsViewMode {
|
||||
const settings = readLocalSettings();
|
||||
return settings.documentsViewMode === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
|
||||
updateLocalSettings({ documentsViewMode });
|
||||
}
|
||||
|
||||
export function getReaderColorScheme(): ReaderColorScheme {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerColorScheme) {
|
||||
case 'light':
|
||||
case 'tan':
|
||||
case 'blue':
|
||||
case 'gray':
|
||||
case 'black':
|
||||
return settings.readerColorScheme;
|
||||
function isValidValue(key: LocalSettingKey, value: unknown): boolean {
|
||||
switch (key) {
|
||||
case 'themeMode':
|
||||
return (THEME_MODES as readonly unknown[]).includes(value);
|
||||
case 'documentsViewMode':
|
||||
return (DOCUMENTS_VIEW_MODES as readonly unknown[]).includes(value);
|
||||
case 'readerColorScheme':
|
||||
return (READER_COLOR_SCHEMES as readonly unknown[]).includes(value);
|
||||
case 'readerFontFamily':
|
||||
return (READER_FONT_FAMILIES as readonly unknown[]).includes(value);
|
||||
case 'readerFontSize':
|
||||
return typeof value === 'number' && value > 0;
|
||||
case 'readerDeviceId':
|
||||
case 'readerDeviceName':
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
default:
|
||||
return 'tan';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
|
||||
updateLocalSettings({ readerColorScheme });
|
||||
export function readLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
): LocalSettingsMap[K] {
|
||||
const value = readRawSettings()[key];
|
||||
return isValidValue(key, value) ? (value as LocalSettingsMap[K]) : defaultValue;
|
||||
}
|
||||
|
||||
export function getReaderFontFamily(): ReaderFontFamily {
|
||||
const settings = readLocalSettings();
|
||||
switch (settings.readerFontFamily) {
|
||||
case 'Serif':
|
||||
case 'Open Sans':
|
||||
case 'Arbutus Slab':
|
||||
case 'Lato':
|
||||
return settings.readerFontFamily;
|
||||
default:
|
||||
return 'Serif';
|
||||
}
|
||||
export function writeLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
value: LocalSettingsMap[K]
|
||||
): void {
|
||||
writeRawSettings({ ...readRawSettings(), [key]: value });
|
||||
}
|
||||
|
||||
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
|
||||
updateLocalSettings({ readerFontFamily });
|
||||
}
|
||||
|
||||
export function getReaderFontSize(): number {
|
||||
const settings = readLocalSettings();
|
||||
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
|
||||
? settings.readerFontSize
|
||||
: 1;
|
||||
}
|
||||
|
||||
export function setReaderFontSize(readerFontSize: number): void {
|
||||
updateLocalSettings({ readerFontSize });
|
||||
/**
|
||||
* Stateful accessor for a single localStorage-backed setting. Persists on change and re-reads
|
||||
* on cross-tab `storage` events. Validation rejects stale/invalid stored values in favor of
|
||||
* `defaultValue`, so callers never need their own get/set/validate pair.
|
||||
*/
|
||||
export function useLocalSetting<K extends LocalSettingKey>(
|
||||
key: K,
|
||||
defaultValue: LocalSettingsMap[K]
|
||||
) {
|
||||
const [value, setValue] = useState<LocalSettingsMap[K]>(() =>
|
||||
readLocalSetting(key, defaultValue)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== LOCAL_SETTINGS_KEY) {
|
||||
return;
|
||||
}
|
||||
setValue(readLocalSetting(key, defaultValue));
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, [key, defaultValue]);
|
||||
|
||||
const setValuePersisted = useCallback(
|
||||
(next: LocalSettingsMap[K]) => {
|
||||
writeLocalSetting(key, next);
|
||||
setValue(next);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
return [value, setValuePersisted] as const;
|
||||
}
|
||||
|
||||
// Reader Device - First-run UUID registration is a read side-effect that doesn't fit a value hook.
|
||||
export function getReaderDevice(): { id: string; name: string } {
|
||||
const settings = readLocalSettings();
|
||||
const id =
|
||||
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
|
||||
? settings.readerDeviceId
|
||||
: crypto.randomUUID();
|
||||
const name =
|
||||
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
|
||||
? settings.readerDeviceName
|
||||
: 'Web Reader';
|
||||
const raw = readRawSettings();
|
||||
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
|
||||
? (raw.readerDeviceId as string)
|
||||
: crypto.randomUUID();
|
||||
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
|
||||
? (raw.readerDeviceName as string)
|
||||
: 'Web Reader';
|
||||
|
||||
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id,
|
||||
readerDeviceName: name,
|
||||
});
|
||||
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
|
||||
writeLocalSetting('readerDeviceId', id);
|
||||
writeLocalSetting('readerDeviceName', name);
|
||||
}
|
||||
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
export function setReaderDevice(name: string, id?: string): void {
|
||||
updateLocalSettings({
|
||||
readerDeviceId: id ?? crypto.randomUUID(),
|
||||
readerDeviceName: name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ type SearchItem struct {
|
||||
Series string
|
||||
FileType string
|
||||
FileSize string
|
||||
UploadDate string
|
||||
}
|
||||
|
||||
type searchFunc func(query string) (searchResults []SearchItem, err error)
|
||||
|
||||
Reference in New Issue
Block a user