cleanup 4
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 18:41:49 -04:00
parent 232a821dc0
commit c9c9563a1f
24 changed files with 313 additions and 387 deletions
+2 -1
View File
@@ -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
+7 -6
View File
@@ -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]
+2 -8
View File
@@ -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;
} }
+3 -2
View File
@@ -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>
); );
+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>
);
}
+3 -5
View File
@@ -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>
))} ))}
+5
View File
@@ -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';
+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?.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,
};
}
+11 -11
View File
@@ -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
+13 -11
View File
@@ -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;
+30 -52
View File
@@ -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>
); );
} }
+22 -48
View File
@@ -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 ?? ''}
/>
); );
} }
+3 -7
View File
@@ -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
+6 -11
View File
@@ -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">
+14 -14
View File
@@ -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>
)} )}
+3 -1
View File
@@ -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 -6
View File
@@ -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
+1 -1
View File
@@ -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');
}); });
}); });
+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 { 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)}
+7 -7
View File
@@ -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"
> >
+ +
+1 -1
View File
@@ -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');
}); });
}); });
+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 { 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}
+10 -44
View File
@@ -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();
+12 -19
View File
@@ -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>