8 Commits

Author SHA1 Message Date
evan 09daf6d511 cleanup api
continuous-integration/drone/pr Build is failing
2026-07-03 20:48:29 -04:00
evan 9158648f3f be consistent 2026-07-03 20:35:45 -04:00
evan 1ff96ee9c8 cleanup 7
continuous-integration/drone/pr Build is failing
2026-07-03 20:22:26 -04:00
evan 457eb550e4 cleanup 6
continuous-integration/drone/pr Build is failing
2026-07-03 19:46:28 -04:00
evan 5a8b773a0b cleanup 5
continuous-integration/drone/pr Build is failing
2026-07-03 18:59:50 -04:00
evan c9c9563a1f cleanup 4
continuous-integration/drone/pr Build is failing
2026-07-03 18:41:49 -04:00
evan 232a821dc0 cleanup 3
continuous-integration/drone/pr Build is failing
2026-07-03 17:30:02 -04:00
evan 6231befaa8 cleanup 2
continuous-integration/drone/pr Build is failing
2026-07-03 16:49:58 -04:00
66 changed files with 2360 additions and 3406 deletions
+2
View File
@@ -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
View File
@@ -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,
+10 -11
View File
@@ -152,7 +152,7 @@ type Activity struct {
EndPercentage float32 `json:"end_percentage"`
ReadPercentage float32 `json:"read_percentage"`
StartPercentage float32 `json:"start_percentage"`
StartTime string `json:"start_time"`
StartTime time.Time `json:"start_time"`
Title *string `json:"title,omitempty"`
}
@@ -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.
@@ -357,14 +357,14 @@ type OperationType string
// Progress defines model for Progress.
type Progress struct {
Author *string `json:"author,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeviceId *string `json:"device_id,omitempty"`
DeviceName *string `json:"device_name,omitempty"`
DocumentId *string `json:"document_id,omitempty"`
Percentage *float64 `json:"percentage,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,omitempty"`
UserId string `json:"user_id"`
}
// ProgressListResponse defines model for ProgressListResponse.
@@ -391,7 +391,6 @@ type SearchItem struct {
Language *string `json:"language,omitempty"`
Series *string `json:"series,omitempty"`
Title *string `json:"title,omitempty"`
UploadDate *string `json:"upload_date,omitempty"`
}
// SearchResponse defines model for SearchResponse.
+12 -2
View File
@@ -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
View File
@@ -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{
-1
View File
@@ -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
View File
@@ -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),
}
}
+8
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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: {
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AdminRoute } from './AdminRoute';
import { useAuth } from './AuthContext';
vi.mock('./AuthContext', () => ({
useAuth: vi.fn(),
}));
const mockedUseAuth = vi.mocked(useAuth);
describe('AdminRoute', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children for admin users', () => {
mockedUseAuth.mockReturnValue({
isAuthenticated: true,
isCheckingAuth: false,
user: { username: 'evan', is_admin: true },
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
});
render(
<MemoryRouter>
<AdminRoute>
<div>Admin Panel</div>
</AdminRoute>
</MemoryRouter>
);
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});
it('redirects non-admin users to the home page', () => {
mockedUseAuth.mockReturnValue({
isAuthenticated: true,
isCheckingAuth: false,
user: { username: 'evan', is_admin: false },
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
});
render(
<MemoryRouter initialEntries={['/admin']}>
<Routes>
<Route
path="/admin"
element={
<AdminRoute>
<div>Admin Panel</div>
</AdminRoute>
}
/>
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText('Home Page')).toBeInTheDocument();
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface AdminRouteProps {
children: React.ReactNode;
}
// Role Guard - Runs behind ProtectedRoute, so auth is already resolved and `user` is populated by the time this renders.
export function AdminRoute({ children }: AdminRouteProps) {
const { user } = useAuth();
if (!user?.is_admin) {
return <Navigate to="/" replace />;
}
return children;
}
+19 -42
View File
@@ -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,
})
previousState: unauthenticatedState,
}),
[meData, meError, meLoading]
);
}, [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');
},
});
+2 -48
View File
@@ -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();
});
});
+4 -15
View File
@@ -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;
}
+3 -2
View File
@@ -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>
);
+62 -69
View File
@@ -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>
+19
View File
@@ -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>
);
}
+10 -24
View File
@@ -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"
+3 -15
View File
@@ -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>
);
}
+4 -43
View File
@@ -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>
+3 -5
View File
@@ -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>
))}
+11 -4
View File
@@ -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>
),
};
+6
View File
@@ -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';
+7 -7
View File
@@ -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
+4 -4
View File
@@ -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;
}
+5 -5
View File
@@ -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;
}
+64
View File
@@ -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,
};
}
+13 -10
View File
@@ -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)}`);
}
};
+35 -11
View File
@@ -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;
}
};
}
+15
View File
@@ -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 };
}
-66
View File
@@ -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% {
+56 -517
View File
@@ -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?.();
+260
View File
@@ -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 };
}
+155
View File
@@ -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;
};
}
+112
View File
@@ -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;
}
+9 -1
View File
@@ -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,
+8 -19
View File
@@ -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',
+37 -64
View File
@@ -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,8 +65,6 @@ 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}>
@@ -116,59 +109,39 @@ export default function AdminImportPage() {
</div>
</form>
</div>
</div>
</div>
);
}
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">
const directoryColumns: Column<DirectoryItem>[] = [
{
id: 'select',
header: '',
className: 'w-12',
render: item => (
<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>
),
},
{ id: 'name', header: currentPathDisplay, render: item => item.name ?? '' },
];
return (
<div className="flex flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
{currentPathDisplay !== '/' && (
<button
onClick={handleNavigateUp}
className="self-start text-content hover:text-primary-600"
>
../
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Table
columns={directoryColumns}
data={directories}
emptyMessage="No Folders"
rowKey={item => item.name ?? ''}
/>
</div>
);
}
+22 -48
View File
@@ -1,47 +1,21 @@
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 />;
}
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">
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"
>
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
{result.name}
</Link>
) : (
@@ -49,19 +23,19 @@ export default function AdminImportResultsPage() {
)}
<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>
),
},
{ id: 'status', header: 'Status', render: result => result.status },
{ id: 'error', header: 'Error', render: result => result.error ?? '' },
];
return (
<Table
columns={columns}
data={results}
loading={isLoading}
rowKey={result => result.path ?? result.name ?? ''}
/>
);
}
+4 -8
View File
@@ -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
+11 -21
View File
@@ -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">
+124 -92
View File
@@ -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
<ResetPasswordDialog
userId={resetUserId}
onClose={() => setResetUserId(null)}
onSave={handleUpdatePassword}
/>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setResetUserId(null)}>
Cancel
</Button>
<Button type="submit" disabled={!resetPassword}>
Save
</Button>
</div>
</form>
</div>
)}
</div>
);
+32 -50
View File
@@ -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 })}
+67 -106
View File
@@ -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) },
];
if (layout === 'grid') {
return (
<div className="relative w-full">
const fieldList = (
<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}
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
>
<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>
{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>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
{icons}
</div>
);
if (layout === 'grid') {
return (
<div className="flex size-full gap-4 rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
<Link to={documentPath} className="my-auto h-48 min-w-fit" aria-label={`Open ${title}`}>
<img
className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`}
alt={title}
/>
</Link>
<div className="flex w-full flex-col justify-between gap-4">
{fieldList}
{actions}
</div>
</div>
);
}
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>
{fieldList}
{actions}
</div>
))}
</div>
{icons}
</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"
/>
</IconInput>
</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>
</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>
)}
+32 -58
View File
@@ -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>
+3 -12
View File
@@ -51,17 +51,14 @@ 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,
},
},
} 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');
});
});
@@ -154,12 +151,9 @@ describe('LoginPage', () => {
it('shows the registration link only when registration is enabled', () => {
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: true,
},
},
} as ReturnType<typeof useGetInfo>);
const { rerender } = render(
@@ -171,12 +165,9 @@ describe('LoginPage', () => {
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
} as ReturnType<typeof useGetInfo>);
rerender(
+6 -49
View File
@@ -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)}
+8 -15
View File
@@ -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),
},
];
+54 -74
View File
@@ -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"
>
+
+3 -12
View File
@@ -51,17 +51,14 @@ 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,
},
},
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');
});
});
@@ -155,12 +152,9 @@ describe('RegisterPage', () => {
it('redirects to login when registration is disabled', async () => {
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
isLoading: false,
} as ReturnType<typeof useGetInfo>);
@@ -177,12 +171,9 @@ describe('RegisterPage', () => {
it('disables the form when registration is disabled', () => {
mockedUseGetInfo.mockReturnValue({
data: {
status: 200,
data: {
registration_enabled: false,
},
},
isLoading: false,
} as ReturnType<typeof useGetInfo>);
+14 -31
View File
@@ -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}
-7
View File
@@ -14,12 +14,9 @@ describe('SearchPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseGetSearch.mockReturnValue({
data: {
status: 200,
data: {
results: [],
},
},
isLoading: false,
} as ReturnType<typeof useGetSearch>);
});
@@ -66,8 +63,6 @@ describe('SearchPage', () => {
it('renders search results from the generated hook response', () => {
mockedUseGetSearch.mockReturnValue({
data: {
status: 200,
data: {
results: [
{
@@ -77,11 +72,9 @@ describe('SearchPage', () => {
series: 'Earthsea',
file_type: 'epub',
file_size: '1 MB',
upload_date: '2025-01-01',
},
],
},
},
isLoading: false,
} as ReturnType<typeof useGetSearch>);
+10 -50
View File
@@ -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"
/>
</IconInput>
</div>
</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();
+124 -86
View File
@@ -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,106 +31,69 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
];
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings();
const settingsData = data?.status === 200 ? data.data : null;
const { showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [timezone, setTimezone] = useState('UTC');
useEffect(() => {
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
setTimezone(settingsData.timezone);
}
}, [settingsData]);
const handlePasswordSubmit = (e: SyntheticEvent) => {
e.preventDefault();
if (!password || !newPassword) {
showError('Please enter both current and new password');
return;
}
updateSettings.mutate(
{ data: { password, new_password: newPassword } },
toastMutationOptions({
success: 'Password updated successfully',
error: 'Failed to update password',
onSuccess: () => {
setPassword('');
setNewPassword('');
},
})
);
};
const handleTimezoneSubmit = (e: SyntheticEvent) => {
e.preventDefault();
updateSettings.mutate(
{ data: { timezone } },
toastMutationOptions({
success: 'Timezone updated successfully',
error: 'Failed to update timezone',
})
);
};
if (isLoading) {
return <LoadingState />;
}
function ProfileCard({ settings }: { settings?: SettingsResponse }) {
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>
<p className="text-lg text-content">{settings?.user.username || 'N/A'}</p>
</div>
</div>
);
}
<div className="flex grow flex-col gap-4">
function PasswordSection({
onSubmit,
}: {
onSubmit: (password: string, next: string) => Promise<boolean>;
}) {
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault();
if (await onSubmit(password, newPassword)) {
setPassword('');
setNewPassword('');
}
};
return (
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex grow flex-col">
<div className="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>
<IconInput icon={<PasswordIcon size={15} />}>
<TextInput
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
</div>
</IconInput>
</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>
<IconInput icon={<PasswordIcon size={15} />}>
<TextInput
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="New Password"
/>
</div>
</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>
@@ -168,17 +132,31 @@ export default function SettingsPage() {
})}
</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={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>
<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 => setTimezone(e.target.value)}
onChange={e => onChange(e.target.value)}
className={inputClassName}
>
<option value="UTC">UTC</option>
@@ -192,17 +170,77 @@ export default function SettingsPage() {
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Australia/Sydney">Australia/Sydney</option>
</select>
</div>
</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={settingsData?.devices ?? []} rowKey="id" />
<Table columns={deviceColumns} data={devices} rowKey="id" />
</div>
);
}
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings();
const settingsData = data;
const { showError } = useToasts();
const toastMutationOptions = useMutationWithToast();
const runWithToast = useToastMutation();
const [timezone, setTimezone] = useState('UTC');
useEffect(() => {
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
setTimezone(settingsData.timezone);
}
}, [settingsData]);
const updatePassword = async (password: string, newPassword: string) => {
if (!password || !newPassword) {
showError('Please enter both current and new password');
return false;
}
return runWithToast(
() => updateSettings.mutateAsync({ data: { password, new_password: newPassword } }),
{
success: 'Password updated successfully',
error: 'Failed to update password',
}
);
};
const updateTimezone = () => {
updateSettings.mutate(
{ data: { timezone } },
toastMutationOptions({
success: 'Timezone updated successfully',
error: 'Failed to update timezone',
})
);
};
if (isLoading) {
return <LoadingState />;
}
return (
<div className="flex w-full flex-col gap-4 md:flex-row">
<ProfileCard settings={settingsData} />
<div className="flex grow flex-col gap-4">
<PasswordSection onSubmit={updatePassword} />
<AppearanceSection />
<TimezoneSection timezone={timezone} onChange={setTimezone} onSubmit={updateTimezone} />
<DevicesSection devices={settingsData?.devices ?? []} />
</div>
</div>
);
+14 -40
View File
@@ -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>;
+55
View File
@@ -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;
}
+3
View File
@@ -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;
+18 -3
View File
@@ -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');
});
});
+22
View File
@@ -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();
+100 -98
View File
@@ -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 });
}
/**
* 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;
}
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 });
}
// 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
const raw = readRawSettings();
const id = isValidValue('readerDeviceId', raw.readerDeviceId)
? (raw.readerDeviceId as string)
: crypto.randomUUID();
const name =
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
? settings.readerDeviceName
const name = isValidValue('readerDeviceName', raw.readerDeviceName)
? (raw.readerDeviceName as string)
: 'Web Reader';
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
updateLocalSettings({
readerDeviceId: id,
readerDeviceName: name,
});
if (id !== raw.readerDeviceId || name !== raw.readerDeviceName) {
writeLocalSetting('readerDeviceId', id);
writeLocalSetting('readerDeviceName', name);
}
return { id, name };
}
export function setReaderDevice(name: string, id?: string): void {
updateLocalSettings({
readerDeviceId: id ?? crypto.randomUUID(),
readerDeviceName: name,
});
}
-1
View File
@@ -37,7 +37,6 @@ type SearchItem struct {
Series string
FileType string
FileSize string
UploadDate string
}
type searchFunc func(query string) (searchResults []SearchItem, err error)