+2
-1
@@ -41,7 +41,8 @@ Also follow the repository root guide at `../AGENTS.md`.
|
|||||||
|
|
||||||
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
- The generated client returns `{ data, status, headers }` for both success and error responses.
|
||||||
- Do not assume non-2xx responses throw.
|
- Do not assume non-2xx responses throw.
|
||||||
- Check `response.status` and response shape before treating a request as successful.
|
- Trust the generated discriminated-union response types and narrow on `status`; avoid hand-rolled re-validation of fields the schema already guarantees.
|
||||||
|
- Centralize mutation/reqest error handling via `useMutationWithToast` (toast options) or `getResponseError` (`utils/errors`) for imperative flows — don't re-implement inline `status` checks or `'message' in response.data` extraction.
|
||||||
|
|
||||||
## 4) Auth / Query State
|
## 4) Auth / Query State
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
resolveAuthStateFromMe,
|
resolveAuthStateFromMe,
|
||||||
authUserFromMutation,
|
authUserFromMutation,
|
||||||
} from './authHelpers';
|
} from './authHelpers';
|
||||||
|
import { getResponseError } from '../utils/errors';
|
||||||
|
|
||||||
interface AuthContextType extends AuthState {
|
interface AuthContextType extends AuthState {
|
||||||
login: (_username: string, _password: string) => Promise<void>;
|
login: (_username: string, _password: string) => Promise<void>;
|
||||||
@@ -66,16 +67,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const user = authUserFromMutation(response);
|
const user = authUserFromMutation(response);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
throw new Error('Login failed');
|
throw new Error(getResponseError(response) ?? 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthState(getAuthenticatedAuthState(user));
|
setAuthState(getAuthenticatedAuthState(user));
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
throw new Error('Login failed');
|
throw error instanceof Error ? error : new Error('Login failed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loginMutation, navigate, queryClient]
|
[loginMutation, navigate, queryClient]
|
||||||
@@ -94,16 +95,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const user = authUserFromMutation(response);
|
const user = authUserFromMutation(response);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
throw new Error('Registration failed');
|
throw new Error(getResponseError(response) ?? 'Registration failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthState(getAuthenticatedAuthState(user));
|
setAuthState(getAuthenticatedAuthState(user));
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
setAuthState(getUnauthenticatedAuthState());
|
setAuthState(getUnauthenticatedAuthState());
|
||||||
throw new Error('Registration failed');
|
throw error instanceof Error ? error : new Error('Registration failed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, queryClient, registerMutation]
|
[navigate, queryClient, registerMutation]
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { getMeResponse, loginResponse, registerResponse } from '../generated/anthoLumeAPIV1';
|
||||||
getMeResponse,
|
|
||||||
loginResponse,
|
|
||||||
registerResponse,
|
|
||||||
} from '../generated/anthoLumeAPIV1';
|
|
||||||
import type { LoginResponse } from '../generated/model';
|
import type { LoginResponse } from '../generated/model';
|
||||||
|
|
||||||
export type AuthUser = LoginResponse;
|
export type AuthUser = LoginResponse;
|
||||||
@@ -63,8 +59,6 @@ export function resolveAuthStateFromMe(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authUserFromMutation(
|
export function authUserFromMutation(response: loginResponse | registerResponse): AuthUser | null {
|
||||||
response: loginResponse | registerResponse
|
|
||||||
): AuthUser | null {
|
|
||||||
return response.status === 200 || response.status === 201 ? response.data : null;
|
return response.status === 200 || response.status === 201 ? response.data : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
interface BaseButtonProps {
|
interface BaseButtonProps {
|
||||||
variant?: 'default' | 'secondary';
|
variant?: 'default' | 'secondary';
|
||||||
@@ -20,9 +21,9 @@ const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ variant = 'default', children, className = '', ...props }, ref) => {
|
({ variant = 'default', children, className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
|
<button ref={ref} className={cn(getVariantClasses(variant), className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
interface IconInputProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconInput({ icon, children, className }: IconInputProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('relative flex', className)}>
|
||||||
|
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { SkeletonTable } from './Skeleton';
|
import { SkeletonTable } from './Skeleton';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
export interface Column<T> {
|
export interface Column<T> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +47,7 @@ export function Table<T>({
|
|||||||
{columns.map(column => (
|
{columns.map(column => (
|
||||||
<th
|
<th
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className={`p-3 text-left text-content-muted ${column.className || ''}`}
|
className={cn('p-3 text-left text-content-muted', column.className)}
|
||||||
>
|
>
|
||||||
{column.header}
|
{column.header}
|
||||||
</th>
|
</th>
|
||||||
@@ -64,10 +65,7 @@ export function Table<T>({
|
|||||||
data.map((row, index) => (
|
data.map((row, index) => (
|
||||||
<tr key={getRowKey(row, index)} className="border-b border-border">
|
<tr key={getRowKey(row, index)} className="border-b border-border">
|
||||||
{columns.map(column => (
|
{columns.map(column => (
|
||||||
<td
|
<td key={column.id} className={cn('p-3 text-content', column.className)}>
|
||||||
key={column.id}
|
|
||||||
className={`p-3 text-content ${column.className || ''}`}
|
|
||||||
>
|
|
||||||
{column.render(row, index)}
|
{column.render(row, index)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,3 +12,8 @@ export { TextInput } from './TextInput';
|
|||||||
|
|
||||||
// Field components
|
// Field components
|
||||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||||
|
|
||||||
|
// Button / Table
|
||||||
|
export { Button } from './Button';
|
||||||
|
export { Table, type Column, type TableProps } from './Table';
|
||||||
|
export { IconInput } from './IconInput';
|
||||||
|
|||||||
@@ -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?.status === 200 ? infoData.data.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -268,10 +268,10 @@ export class EBookReader {
|
|||||||
|
|
||||||
private async setupReader() {
|
private async setupReader() {
|
||||||
this.bookState.words = await countWords(this.book);
|
this.bookState.words = await countWords(this.book);
|
||||||
const { cfi } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
const cfiResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||||
await this.setPosition(cfi);
|
await this.setPosition(cfiResult?.cfi);
|
||||||
const { element } = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
const elementResult = await getCFIFromXPath(this.book, this.rendition, this.bookState.progress);
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = elementResult?.element ?? null;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
const stats = await this.getBookStats();
|
const stats = await this.getBookStats();
|
||||||
this.onStats(stats);
|
this.onStats(stats);
|
||||||
@@ -390,11 +390,11 @@ export class EBookReader {
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
}) {
|
}) {
|
||||||
const currentProgress = this.bookState.progress;
|
const currentProgress = this.bookState.progress;
|
||||||
const { cfi } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
const cfiResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||||
this.setTheme(newTheme);
|
this.setTheme(newTheme);
|
||||||
await this.setPosition(cfi);
|
await this.setPosition(cfiResult?.cfi);
|
||||||
const { element } = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
const elementResult = await getCFIFromXPath(this.book, this.rendition, currentProgress);
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = elementResult?.element ?? null;
|
||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,10 +449,10 @@ export class EBookReader {
|
|||||||
|
|
||||||
async createProgress() {
|
async createProgress() {
|
||||||
const currentCFI = await this.rendition.currentLocation();
|
const currentCFI = await this.rendition.currentLocation();
|
||||||
const { element, xpath } = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
const xpathResult = await getXPathFromCFI(this.book, this.rendition, currentCFI.start.cfi);
|
||||||
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
const currentWord = await getBookWordPosition(this.book, this.rendition);
|
||||||
this.bookState.progress = xpath ?? '';
|
this.bookState.progress = xpathResult?.xpath ?? '';
|
||||||
this.bookState.progressElement = element ?? null;
|
this.bookState.progressElement = xpathResult?.element ?? null;
|
||||||
|
|
||||||
const percentage =
|
const percentage =
|
||||||
this.bookState.words > 0
|
this.bookState.words > 0
|
||||||
|
|||||||
@@ -124,24 +124,28 @@ export async function getBookWordPosition(book: EpubBook, rendition: EpubRenditi
|
|||||||
return chapterWordPosition + preChapterWordPosition;
|
return chapterWordPosition + preChapterWordPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getXPathFromCFI(book: EpubBook, rendition: EpubRendition, cfi: string) {
|
export async function getXPathFromCFI(
|
||||||
|
book: EpubBook,
|
||||||
|
rendition: EpubRendition,
|
||||||
|
cfi: string
|
||||||
|
): Promise<{ xpath: string; element: Element | null } | null> {
|
||||||
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
|
||||||
if (!cfiBaseMatch?.[1]) {
|
if (!cfiBaseMatch?.[1]) {
|
||||||
return {} as { xpath?: string; element?: Element | null };
|
return null;
|
||||||
}
|
}
|
||||||
const startCFI = cfiBaseMatch[1];
|
const startCFI = cfiBaseMatch[1];
|
||||||
|
|
||||||
const docFragmentIndex =
|
const docFragmentIndex =
|
||||||
(book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
(book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
|
||||||
if (docFragmentIndex <= 0) {
|
if (docFragmentIndex <= 0) {
|
||||||
return {} as { xpath?: string; element?: Element | null };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
|
||||||
const contents = rendition.getContents()[0];
|
const contents = rendition.getContents()[0];
|
||||||
const currentNodeStart = contents?.range(cfi).startContainer;
|
const currentNodeStart = contents?.range(cfi).startContainer;
|
||||||
if (!currentNodeStart) {
|
if (!currentNodeStart) {
|
||||||
return {} as { xpath?: string; element?: Element | null };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentNode: Node | null = currentNodeStart;
|
let currentNode: Node | null = currentNodeStart;
|
||||||
@@ -181,23 +185,21 @@ export async function getCFIFromXPath(
|
|||||||
book: EpubBook,
|
book: EpubBook,
|
||||||
rendition: EpubRendition,
|
rendition: EpubRendition,
|
||||||
xpath?: string
|
xpath?: string
|
||||||
) {
|
): Promise<{ cfi: string; element: Element } | null> {
|
||||||
if (!xpath) {
|
if (!xpath) {
|
||||||
return {} as { cfi?: string; element?: Element | null };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||||
if (!fragMatch?.[1]) {
|
if (!fragMatch?.[1]) {
|
||||||
return {} as { cfi?: string; element?: Element | null };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
|
||||||
const sectionItem = book.spine.get(spinePosition);
|
const sectionItem = book.spine.get(spinePosition);
|
||||||
await sectionItem.load(book.load.bind(book));
|
await sectionItem.load(book.load.bind(book));
|
||||||
|
|
||||||
const renderedContent = rendition
|
const renderedContent = rendition.getContents().find(item => item.sectionIndex == spinePosition);
|
||||||
.getContents()
|
|
||||||
.find(item => item.sectionIndex == spinePosition);
|
|
||||||
const docItem = renderedContent?.document || sectionItem.document;
|
const docItem = renderedContent?.document || sectionItem.document;
|
||||||
|
|
||||||
const namespaceURI = docItem.documentElement.namespaceURI;
|
const namespaceURI = docItem.documentElement.namespaceURI;
|
||||||
@@ -244,7 +246,7 @@ export async function getCFIFromXPath(
|
|||||||
const element = xpathElement || derivedSelectorElement;
|
const element = xpathElement || derivedSelectorElement;
|
||||||
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
|
||||||
if (!isElementNode) {
|
if (!isElementNode) {
|
||||||
return {} as { cfi?: string; element?: Element | null };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedElement = element as Element;
|
const resolvedElement = element as Element;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type SyntheticEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { LoadingState } from '../components';
|
import { LoadingState, Table, type Column } from '../components';
|
||||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||||
import type { DirectoryItem } from '../generated/model';
|
import type { DirectoryItem } from '../generated/model';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
@@ -20,8 +20,7 @@ export default function AdminImportPage() {
|
|||||||
|
|
||||||
const postImport = usePostImport();
|
const postImport = usePostImport();
|
||||||
|
|
||||||
const directoryResponse =
|
const directoryResponse = directoryData?.status === 200 ? directoryData.data : null;
|
||||||
directoryData?.status === 200 ? directoryData.data : null;
|
|
||||||
const directories = directoryResponse?.items ?? [];
|
const directories = directoryResponse?.items ?? [];
|
||||||
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
|
||||||
|
|
||||||
@@ -37,7 +36,8 @@ export default function AdminImportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = (e: SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
if (!selectedDirectory) return;
|
if (!selectedDirectory) return;
|
||||||
|
|
||||||
postImport.mutate(
|
postImport.mutate(
|
||||||
@@ -65,8 +65,6 @@ export default function AdminImportPage() {
|
|||||||
|
|
||||||
if (selectedDirectory) {
|
if (selectedDirectory) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
|
||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
<p className="text-lg font-semibold text-content">Selected Import Directory</p>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
<form className="flex flex-col gap-4" onSubmit={handleImport}>
|
||||||
@@ -111,59 +109,39 @@ export default function AdminImportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const directoryColumns: Column<DirectoryItem>[] = [
|
||||||
<div className="overflow-x-auto">
|
{
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
id: 'select',
|
||||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
header: '',
|
||||||
<thead className="text-content-muted">
|
className: 'w-12',
|
||||||
<tr>
|
render: item => (
|
||||||
<th className="w-12 border-b border-border p-3 text-left font-normal"></th>
|
|
||||||
<th className="break-all border-b border-border p-3 text-left font-normal">
|
|
||||||
{currentPath}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{currentPath !== '/' && (
|
|
||||||
<tr>
|
|
||||||
<td className="border-b border-border p-3 text-content-muted"></td>
|
|
||||||
<td className="border-b border-border p-3">
|
|
||||||
<button onClick={handleNavigateUp}>
|
|
||||||
<p>../</p>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{directories.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="p-3 text-center" colSpan={2}>
|
|
||||||
No Folders
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
directories.map((item: DirectoryItem) => (
|
|
||||||
<tr key={item.name}>
|
|
||||||
<td className="border-b border-border p-3 text-content-muted">
|
|
||||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||||
<FolderOpenIcon size={20} />
|
<FolderOpenIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
),
|
||||||
<td className="border-b border-border p-3">
|
},
|
||||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
{ id: 'name', header: currentPathDisplay, render: item => item.name ?? '' },
|
||||||
<p>{item.name ?? ''}</p>
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
|
{currentPathDisplay !== '/' && (
|
||||||
|
<button
|
||||||
|
onClick={handleNavigateUp}
|
||||||
|
className="self-start text-content hover:text-primary-600"
|
||||||
|
>
|
||||||
|
../
|
||||||
</button>
|
</button>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
<Table
|
||||||
</table>
|
columns={directoryColumns}
|
||||||
</div>
|
data={directories}
|
||||||
|
emptyMessage="No Folders"
|
||||||
|
rowKey={item => item.name ?? ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,21 @@
|
|||||||
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
import { useGetImportResults } from '../generated/anthoLumeAPIV1';
|
||||||
import { LoadingState } from '../components';
|
import { Table, type Column } from '../components';
|
||||||
import type { ImportResult } from '../generated/model';
|
import type { ImportResult } from '../generated/model';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export default function AdminImportResultsPage() {
|
export default function AdminImportResultsPage() {
|
||||||
const { data: resultsData, isLoading } = useGetImportResults();
|
const { data: resultsData, isLoading } = useGetImportResults();
|
||||||
const results =
|
const results = resultsData?.status === 200 ? (resultsData.data.results ?? []) : [];
|
||||||
resultsData?.status === 200 ? resultsData.data.results || [] : [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
const columns: Column<ImportResult>[] = [
|
||||||
return <LoadingState />;
|
{
|
||||||
}
|
id: 'document',
|
||||||
|
header: 'Document',
|
||||||
return (
|
render: result => (
|
||||||
<div className="overflow-x-auto">
|
<div className="grid grid-cols-[4rem_auto] gap-y-1">
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow-sm">
|
|
||||||
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
|
||||||
<thead className="text-content-muted">
|
|
||||||
<tr>
|
|
||||||
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
|
||||||
Document
|
|
||||||
</th>
|
|
||||||
<th className="border-b border-border p-3 text-left font-normal uppercase">Status</th>
|
|
||||||
<th className="border-b border-border p-3 text-left font-normal uppercase">Error</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className="p-3 text-center" colSpan={3}>
|
|
||||||
No Results
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
results.map((result: ImportResult, index: number) => (
|
|
||||||
<tr key={result.path ?? index}>
|
|
||||||
<td className="grid grid-cols-[4rem_auto] border-b border-border p-3">
|
|
||||||
<span className="text-content-muted">Name:</span>
|
<span className="text-content-muted">Name:</span>
|
||||||
{result.id ? (
|
{result.id ? (
|
||||||
<Link
|
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||||
to={`/documents/${result.id}`}
|
|
||||||
className="text-secondary-600 hover:underline"
|
|
||||||
>
|
|
||||||
{result.name}
|
{result.name}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -49,19 +23,19 @@ export default function AdminImportResultsPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-content-muted">File:</span>
|
<span className="text-content-muted">File:</span>
|
||||||
<span>{result.path}</span>
|
<span>{result.path}</span>
|
||||||
</td>
|
|
||||||
<td className="border-b border-border p-3">
|
|
||||||
<p>{result.status}</p>
|
|
||||||
</td>
|
|
||||||
<td className="border-b border-border p-3">
|
|
||||||
<p>{result.error || ''}</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ id: 'status', header: 'Status', render: result => result.status },
|
||||||
|
{ id: 'error', header: 'Error', render: result => result.error ?? '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={results}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey={result => result.path ?? result.name ?? ''}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { SyntheticEvent } from 'react';
|
import { SyntheticEvent } from 'react';
|
||||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||||
import { Button } from '../components/Button';
|
import { Button, LoadingState, TextInput, IconInput } from '../components';
|
||||||
import { LoadingState, TextInput } from '../components';
|
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon } from '../icons';
|
import { Search2Icon } from '../icons';
|
||||||
|
|
||||||
@@ -22,10 +21,7 @@ export default function AdminLogsPage() {
|
|||||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<Search2Icon size={15} hoverable={false} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={filter}
|
value={filter}
|
||||||
@@ -33,7 +29,7 @@ export default function AdminLogsPage() {
|
|||||||
className="p-2"
|
className="p-2"
|
||||||
placeholder="JQ Filter"
|
placeholder="JQ Filter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
Filter
|
Filter
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, SyntheticEvent } from 'react';
|
import { useState, SyntheticEvent } from 'react';
|
||||||
import { LoadingState } from '../components';
|
import { usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage, getResponseError } from '../utils/errors';
|
||||||
import { streamResponseToFile, backupFilename } from '../utils/download';
|
import { streamResponseToFile, backupFilename } from '../utils/download';
|
||||||
|
|
||||||
interface BackupTypes {
|
interface BackupTypes {
|
||||||
@@ -13,7 +12,6 @@ interface BackupTypes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { isLoading } = useGetAdmin();
|
|
||||||
const postAdminAction = usePostAdminAction();
|
const postAdminAction = usePostAdminAction();
|
||||||
const { showInfo, showError, removeToast } = useToasts();
|
const { showInfo, showError, removeToast } = useToasts();
|
||||||
const toastMutationOptions = useMutationWithToast();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
@@ -76,12 +74,13 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
removeToast(startedToastId);
|
removeToast(startedToastId);
|
||||||
|
|
||||||
if (response.status >= 200 && response.status < 300) {
|
const message = getResponseError(response);
|
||||||
showInfo('Restore completed successfully');
|
if (message) {
|
||||||
|
showError('Restore failed: ' + message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showError('Restore failed: ' + getErrorMessage(response.data));
|
showInfo('Restore completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
removeToast(startedToastId);
|
removeToast(startedToastId);
|
||||||
showError('Restore failed: ' + getErrorMessage(error));
|
showError('Restore failed: ' + getErrorMessage(error));
|
||||||
@@ -108,10 +107,6 @@ export default function AdminPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full grow flex-col gap-4">
|
<div className="flex w-full grow flex-col gap-4">
|
||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
|||||||
import type { User } from '../generated/model';
|
import type { User } from '../generated/model';
|
||||||
import { AddIcon, DeleteIcon } from '../icons';
|
import { AddIcon, DeleteIcon } from '../icons';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
|
import { useToasts } from '../components/ToastContext';
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const toastMutationOptions = useMutationWithToast();
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
const { showError } = useToasts();
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
@@ -23,7 +25,10 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
const handleCreateUser = (e: SyntheticEvent) => {
|
const handleCreateUser = (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newUsername || !newPassword) return;
|
if (!newUsername || !newPassword) {
|
||||||
|
showError('Please enter username and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateUser.mutate(
|
updateUser.mutate(
|
||||||
{
|
{
|
||||||
@@ -116,15 +121,15 @@ export default function AdminUsersPage() {
|
|||||||
id: 'password',
|
id: 'password',
|
||||||
header: 'Password',
|
header: 'Password',
|
||||||
render: user => (
|
render: user => (
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setResetUserId(user.id);
|
setResetUserId(user.id);
|
||||||
setResetPassword('');
|
setResetPassword('');
|
||||||
}}
|
}}
|
||||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
className="px-2 py-1"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -162,19 +167,19 @@ export default function AdminUsersPage() {
|
|||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
|
<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">
|
<form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={e => setNewUsername(e.target.value)}
|
onChange={e => setNewUsername(e.target.value)}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
className="bg-surface p-2 text-content"
|
className="p-2"
|
||||||
/>
|
/>
|
||||||
<input
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
className="bg-surface p-2 text-content"
|
className="p-2"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -185,12 +190,7 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
<label htmlFor="new_is_admin">Admin</label>
|
<label htmlFor="new_is_admin">Admin</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button type="submit">Create</Button>
|
||||||
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ function EditableField({
|
|||||||
export default function DocumentPage() {
|
export default function DocumentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
const { data: docData, isLoading: docLoading } = useGetDocument(id || '', {
|
||||||
|
query: { enabled: Boolean(id) },
|
||||||
|
});
|
||||||
const editMutation = useEditDocument();
|
const editMutation = useEditDocument();
|
||||||
const { showError } = useToasts();
|
const { showError } = useToasts();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Document } from '../generated/model';
|
import type { Document } from '../generated/model';
|
||||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||||
import { LoadingState, Pagination, TextInput } from '../components';
|
import { LoadingState, Pagination, TextInput, IconInput } from '../components';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
@@ -160,10 +160,7 @@ export default function DocumentsPage() {
|
|||||||
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row">
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<Search2Icon size={15} hoverable={false} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -171,7 +168,7 @@ export default function DocumentsPage() {
|
|||||||
placeholder="Search Author / Title"
|
placeholder="Search Author / Title"
|
||||||
name="search"
|
name="search"
|
||||||
/>
|
/>
|
||||||
</div>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex rounded border border-border bg-surface p-1">
|
<div className="inline-flex rounded border border-border bg-surface p-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ describe('LoginPage', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid credentials');
|
expect(showErrorMock).toHaveBeenCalledWith('bad credentials');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
import { useState, SyntheticEvent, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useAuthForm } from '../hooks/useAuthForm';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||||
|
|
||||||
export function getRegistrationEnabled(infoData: unknown): boolean {
|
|
||||||
if (!infoData || typeof infoData !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('data' in infoData) || !infoData.data || typeof infoData.data !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!('registration_enabled' in infoData.data) ||
|
|
||||||
typeof infoData.data.registration_enabled !== 'boolean'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return infoData.data.registration_enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showError } = useToasts();
|
const { username, password, isLoading, registrationEnabled, setUsername, setPassword, submit } =
|
||||||
const { data: infoData } = useGetInfo({
|
useAuthForm('login');
|
||||||
query: {
|
|
||||||
staleTime: Infinity,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCheckingAuth && isAuthenticated) {
|
if (!isCheckingAuth && isAuthenticated) {
|
||||||
@@ -46,19 +16,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(username, password);
|
|
||||||
} catch (_err) {
|
|
||||||
showError('Invalid credentials');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFormView
|
<AuthFormView
|
||||||
username={username}
|
username={username}
|
||||||
@@ -66,7 +23,7 @@ export default function LoginPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onUsernameChange={setUsername}
|
onUsernameChange={setUsername}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={setPassword}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={submit}
|
||||||
submitLabel="Login"
|
submitLabel="Login"
|
||||||
submittingLabel="Logging in..."
|
submittingLabel="Logging in..."
|
||||||
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
|
footer={authFormFooter({ to: '/register', text: 'Register here.' }, registrationEnabled)}
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ export default function ReaderPage() {
|
|||||||
|
|
||||||
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
|
||||||
|
|
||||||
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '');
|
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '', {
|
||||||
|
query: { enabled: Boolean(id) },
|
||||||
|
});
|
||||||
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
|
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
|
||||||
query: {
|
query: {
|
||||||
retry: false,
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: Boolean(id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
|
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
|
||||||
@@ -255,9 +259,7 @@ export default function ReaderPage() {
|
|||||||
<div className="flex items-center gap-1.5 lg:justify-end">
|
<div className="flex items-center gap-1.5 lg:justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setFontSize(Math.max(0.8, Number((fontSize - 0.1).toFixed(2))))}
|
||||||
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"
|
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
@@ -267,9 +269,7 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setFontSize(Math.min(2.2, Number((fontSize + 0.1).toFixed(2))))}
|
||||||
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"
|
className="rounded border border-border px-2.5 py-1.5 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ describe('RegisterPage', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(showErrorMock).toHaveBeenCalledWith('Registration failed');
|
expect(showErrorMock).toHaveBeenCalledWith('failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import { useState, SyntheticEvent, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useAuthForm } from '../hooks/useAuthForm';
|
||||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
|
||||||
import { AuthFormView, authFormFooter } from './AuthFormView';
|
import { AuthFormView, authFormFooter } from './AuthFormView';
|
||||||
import { getRegistrationEnabled } from './LoginPage';
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [username, setUsername] = useState('');
|
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { register, isAuthenticated, isCheckingAuth } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showError } = useToasts();
|
const {
|
||||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
username,
|
||||||
query: {
|
password,
|
||||||
staleTime: Infinity,
|
isLoading,
|
||||||
},
|
isLoadingInfo,
|
||||||
});
|
registrationEnabled,
|
||||||
|
setUsername,
|
||||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
setPassword,
|
||||||
|
submit,
|
||||||
|
} = useAuthForm('register');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCheckingAuth && isAuthenticated) {
|
if (!isCheckingAuth && isAuthenticated) {
|
||||||
@@ -33,19 +29,6 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
||||||
|
|
||||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await register(username, password);
|
|
||||||
} catch (_err) {
|
|
||||||
showError(registrationEnabled ? 'Registration failed' : 'Registration is disabled');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFormView
|
<AuthFormView
|
||||||
username={username}
|
username={username}
|
||||||
@@ -53,7 +36,7 @@ export default function RegisterPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onUsernameChange={setUsername}
|
onUsernameChange={setUsername}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={setPassword}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={submit}
|
||||||
submitLabel="Register"
|
submitLabel="Register"
|
||||||
submittingLabel="Registering..."
|
submittingLabel="Registering..."
|
||||||
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
inputsDisabled={isLoadingInfo || !registrationEnabled}
|
||||||
|
|||||||
@@ -2,25 +2,17 @@ import { useState, SyntheticEvent } from 'react';
|
|||||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||||
import type { SearchItem } from '../generated/model';
|
import type { SearchItem } from '../generated/model';
|
||||||
import { Button } from '../components/Button';
|
import { Button, Table, type Column, TextInput, IconInput } from '../components';
|
||||||
import { TextInput } from '../components';
|
|
||||||
import { inputClassName } from '../components/TextInput';
|
import { inputClassName } from '../components/TextInput';
|
||||||
import { Table, type Column } from '../components/Table';
|
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
import { Search2Icon, BookIcon } from '../icons';
|
||||||
|
|
||||||
const searchColumns: Column<SearchItem>[] = [
|
const searchColumns: Column<SearchItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'download',
|
id: 'document',
|
||||||
header: '',
|
header: 'Document',
|
||||||
className: 'w-12 text-content-muted',
|
render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}`,
|
||||||
render: () => (
|
|
||||||
<button className="hover:text-primary-600" title="Download">
|
|
||||||
<DownloadIcon size={15} />
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ id: 'document', header: 'Document', render: item => `${item.author || 'N/A'} - ${item.title || 'N/A'}` },
|
|
||||||
{ id: 'series', header: 'Series', render: item => item.series || 'N/A' },
|
{ id: 'series', header: 'Series', render: item => item.series || 'N/A' },
|
||||||
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
|
{ id: 'type', header: 'Type', render: item => item.file_type || 'N/A' },
|
||||||
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
|
{ id: 'size', header: 'Size', render: item => item.file_size || 'N/A' },
|
||||||
@@ -42,26 +34,6 @@ interface SearchPageViewProps {
|
|||||||
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void;
|
onSubmit: (e: SyntheticEvent<HTMLFormElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSearchResults(data: unknown): SearchItem[] {
|
|
||||||
if (!data || typeof data !== 'object') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('status' in data) || data.status !== 200) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('data' in data) || !data.data || typeof data.data !== 'object') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('results' in data.data) || !Array.isArray(data.data.results)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.data.results as SearchItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchPageView({
|
export function SearchPageView({
|
||||||
query,
|
query,
|
||||||
source,
|
source,
|
||||||
@@ -77,22 +49,16 @@ export function SearchPageView({
|
|||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<Search2Icon size={15} hoverable={false} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<Search2Icon size={15} hoverable={false} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => onQueryChange(e.target.value)}
|
onChange={e => onQueryChange(e.target.value)}
|
||||||
placeholder="Query"
|
placeholder="Query"
|
||||||
/>
|
/>
|
||||||
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<IconInput className="min-w-[12em]" icon={<BookIcon size={15} />}>
|
||||||
<div className="relative flex min-w-[12em]">
|
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<BookIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
||||||
@@ -101,7 +67,7 @@ export function SearchPageView({
|
|||||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</IconInput>
|
||||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
@@ -126,7 +92,7 @@ export default function SearchPage() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const results = getSearchResults(data);
|
const results = data?.status === 200 ? (data.data.results ?? []) : [];
|
||||||
|
|
||||||
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useEffect, SyntheticEvent } from 'react';
|
import { useState, useEffect, SyntheticEvent } from 'react';
|
||||||
import { LoadingState, TextInput } from '../components';
|
import { Button, LoadingState, Table, type Column, TextInput, IconInput } from '../components';
|
||||||
import { inputClassName } from '../components/TextInput';
|
import { inputClassName } from '../components/TextInput';
|
||||||
import { Table, type Column } from '../components/Table';
|
|
||||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||||
import type { Device } from '../generated/model';
|
import type { Device } from '../generated/model';
|
||||||
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||||
import { Button } from '../components/Button';
|
|
||||||
import { useToasts } from '../components/ToastContext';
|
import { useToasts } from '../components/ToastContext';
|
||||||
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
import { useMutationWithToast } from '../hooks/useMutationWithToast';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
@@ -20,7 +18,11 @@ const deviceColumns: Column<Device>[] = [
|
|||||||
className: 'pl-0',
|
className: 'pl-0',
|
||||||
render: device => device.device_name || 'Unknown',
|
render: device => device.device_name || 'Unknown',
|
||||||
},
|
},
|
||||||
{ id: 'last_synced', header: 'Last Sync', render: device => formatDeviceDate(device.last_synced) },
|
{
|
||||||
|
id: 'last_synced',
|
||||||
|
header: 'Last Sync',
|
||||||
|
render: device => formatDeviceDate(device.last_synced),
|
||||||
|
},
|
||||||
{ id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) },
|
{ id: 'created_at', header: 'Created', render: device => formatDeviceDate(device.created_at) },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -99,30 +101,24 @@ export default function SettingsPage() {
|
|||||||
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
|
||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<PasswordIcon size={15} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<PasswordIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<div className="relative flex">
|
<IconInput icon={<PasswordIcon size={15} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<PasswordIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
placeholder="New Password"
|
placeholder="New Password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</IconInput>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
Submit
|
Submit
|
||||||
@@ -172,10 +168,7 @@ export default function SettingsPage() {
|
|||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
|
||||||
<div className="relative flex grow">
|
<IconInput className="grow" icon={<ClockIcon size={15} />}>
|
||||||
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-xs">
|
|
||||||
<ClockIcon size={15} />
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={timezone || 'UTC'}
|
value={timezone || 'UTC'}
|
||||||
onChange={e => setTimezone(e.target.value)}
|
onChange={e => setTimezone(e.target.value)}
|
||||||
@@ -192,7 +185,7 @@ export default function SettingsPage() {
|
|||||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</IconInput>
|
||||||
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
<Button variant="secondary" type="submit" className="w-full lg:w-60">
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user