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

This commit is contained in:
2026-07-03 19:46:28 -04:00
parent 5a8b773a0b
commit 457eb550e4
16 changed files with 446 additions and 365 deletions
+14 -26
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { createContext, useContext, ReactNode, useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
@@ -10,7 +10,6 @@ import {
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import { import {
type AuthState, type AuthState,
getAuthenticatedAuthState,
getUnauthenticatedAuthState, getUnauthenticatedAuthState,
resolveAuthStateFromMe, resolveAuthStateFromMe,
authUserFromMutation, authUserFromMutation,
@@ -25,15 +24,9 @@ interface AuthContextType extends AuthState {
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const initialAuthState: AuthState = { const unauthenticatedState = getUnauthenticatedAuthState();
isAuthenticated: false,
user: null,
isCheckingAuth: true,
};
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
const loginMutation = useLogin(); const loginMutation = useLogin();
const registerMutation = useRegister(); const registerMutation = useRegister();
const logoutMutation = useLogout(); const logoutMutation = useLogout();
@@ -43,16 +36,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { const authState = useMemo(
setAuthState(prev => () =>
resolveAuthStateFromMe({ resolveAuthStateFromMe({
meData, meData,
meError, meError,
meLoading, meLoading,
previousState: prev, previousState: unauthenticatedState,
}) }),
); [meData, meError, meLoading]
}, [meData, meError, meLoading]); );
const login = useCallback( const login = useCallback(
async (username: string, password: string) => { async (username: string, password: string) => {
@@ -66,16 +59,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const user = authUserFromMutation(response); const user = authUserFromMutation(response);
if (!user) { if (!user) {
setAuthState(getUnauthenticatedAuthState());
throw new Error(getResponseError(response) ?? 'Login failed'); throw new Error(getResponseError(response) ?? 'Login failed');
} }
setAuthState(getAuthenticatedAuthState(user)); queryClient.setQueryData(getGetMeQueryKey(), response);
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
setAuthState(getUnauthenticatedAuthState()); queryClient.setQueryData(getGetMeQueryKey(), undefined);
throw error instanceof Error ? error : new Error('Login failed'); throw error instanceof Error ? error : new Error('Login failed');
} }
}, },
@@ -94,16 +85,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const user = authUserFromMutation(response); const user = authUserFromMutation(response);
if (!user) { if (!user) {
setAuthState(getUnauthenticatedAuthState());
throw new Error(getResponseError(response) ?? 'Registration failed'); throw new Error(getResponseError(response) ?? 'Registration failed');
} }
setAuthState(getAuthenticatedAuthState(user)); queryClient.setQueryData(getGetMeQueryKey(), response);
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
setAuthState(getUnauthenticatedAuthState()); queryClient.setQueryData(getGetMeQueryKey(), undefined);
throw error instanceof Error ? error : new Error('Registration failed'); throw error instanceof Error ? error : new Error('Registration failed');
} }
}, },
@@ -112,9 +101,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(() => { const logout = useCallback(() => {
logoutMutation.mutate(undefined, { logoutMutation.mutate(undefined, {
onSuccess: async () => { onSettled: () => {
setAuthState(getUnauthenticatedAuthState()); queryClient.clear();
queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
navigate('/login'); navigate('/login');
}, },
}); });
+63 -69
View File
@@ -4,11 +4,38 @@ import { SettingsIcon, GitIcon } from '../icons';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1'; import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { navItems, adminNavItems } from './navigation'; import { navItems, adminNavItems } from './navigation';
import { dataForStatus } from '../utils/apiResponses';
import { cn } from '../utils/cn';
function hasPrefix(path: string, prefix: string): boolean { function hasPrefix(path: string, prefix: string): boolean {
return path.startsWith(prefix); return path.startsWith(prefix);
} }
function NavToggleIcon({ isOpen }: { isOpen: boolean }) {
return (
<span className="relative block size-7" aria-hidden="true">
<span
className={cn(
'absolute left-0 top-1 h-0.5 w-7 bg-content transition-transform duration-200',
isOpen && 'translate-y-2 rotate-45'
)}
/>
<span
className={cn(
'absolute left-0 top-3 h-0.5 w-7 bg-content transition-opacity duration-200',
isOpen && 'opacity-0'
)}
/>
<span
className={cn(
'absolute left-0 top-5 h-0.5 w-7 bg-content transition-transform duration-200',
isOpen && '-translate-y-2 -rotate-45'
)}
/>
</span>
);
}
export default function HamburgerMenu() { export default function HamburgerMenu() {
const location = useLocation(); const location = useLocation();
const { user } = useAuth(); const { user } = useAuth();
@@ -20,82 +47,47 @@ export default function HamburgerMenu() {
staleTime: Infinity, staleTime: Infinity,
}, },
}); });
const version = const version = dataForStatus(infoData, 200)?.version ?? 'v1.0.0';
infoData && 'data' in infoData && infoData.data && 'version' in infoData.data const closeMenu = () => setIsOpen(false);
? infoData.data.version
: 'v1.0.0';
return ( return (
<div className="relative z-40 ml-6 flex flex-col"> <div className="relative z-40 ml-6 flex flex-col">
<input <button
type="checkbox" type="button"
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden" className="relative z-50 flex size-8 items-center justify-center lg:hidden"
id="mobile-nav-checkbox" aria-label="Toggle navigation"
checked={isOpen} aria-expanded={isOpen}
onChange={e => setIsOpen(e.target.checked)} aria-controls="mobile-navigation"
/> onClick={() => setIsOpen(open => !open)}
>
<span <NavToggleIcon isOpen={isOpen} />
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden" </button>
style={{
transformOrigin: '5px 0px',
transition:
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
}}
/>
<span
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
style={{
transformOrigin: '0% 100%',
transition:
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
opacity: isOpen ? 0 : 1,
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
}}
/>
<span
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
style={{
transformOrigin: '0% 0%',
transition:
'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease',
transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
}}
/>
<div <div
id="menu" id="mobile-navigation"
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48" className={cn(
style={{ 'fixed -ml-6 h-full w-56 bg-surface shadow-lg transition-transform duration-200 lg:w-48 lg:translate-x-0',
top: 0, isOpen ? 'translate-x-0' : '-translate-x-full'
paddingTop: 'env(safe-area-inset-top)', )}
transformOrigin: '0% 0%', style={{ top: 0, paddingTop: 'env(safe-area-inset-top)' }}
transform: isOpen ? 'none' : 'translate(-100%, 0)',
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
}}
> >
<style>{`
@media (min-width: 1024px) {
#menu {
transform: none !important;
}
}
`}</style>
<div className="flex h-16 justify-end lg:justify-around"> <div className="flex h-16 justify-end lg:justify-around">
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p> <p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">
AnthoLume
</p>
</div> </div>
<nav> <nav>
{navItems.map(item => ( {navItems.map(item => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={() => setIsOpen(false)} onClick={closeMenu}
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${ className={cn(
'my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200',
location.pathname === item.path location.pathname === item.path
? 'border-primary-500 text-content' ? 'border-primary-500 text-content'
: 'border-transparent text-content-subtle hover:text-content' : 'border-transparent text-content-subtle hover:text-content'
}`} )}
> >
<item.icon size={20} /> <item.icon size={20} />
<span className="mx-4 text-sm font-normal">{item.label}</span> <span className="mx-4 text-sm font-normal">{item.label}</span>
@@ -104,20 +96,22 @@ export default function HamburgerMenu() {
{isAdmin && ( {isAdmin && (
<div <div
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${ className={cn(
'my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200',
hasPrefix(location.pathname, '/admin') hasPrefix(location.pathname, '/admin')
? 'border-primary-500 text-content' ? 'border-primary-500 text-content'
: 'border-transparent text-content-subtle' : 'border-transparent text-content-subtle'
}`} )}
> >
<Link <Link
to="/admin" to="/admin"
onClick={() => setIsOpen(false)} onClick={closeMenu}
className={`flex w-full justify-start ${ className={cn(
'flex w-full justify-start',
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/') location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
? 'text-content' ? 'text-content'
: 'text-content-subtle hover:text-content' : 'text-content-subtle hover:text-content'
}`} )}
> >
<SettingsIcon size={20} /> <SettingsIcon size={20} />
<span className="mx-4 text-sm font-normal">Admin</span> <span className="mx-4 text-sm font-normal">Admin</span>
@@ -129,13 +123,13 @@ export default function HamburgerMenu() {
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={() => setIsOpen(false)} onClick={closeMenu}
className={`flex w-full justify-start ${ className={cn(
'flex w-full justify-start pl-7',
location.pathname === item.path location.pathname === item.path
? 'text-content' ? 'text-content'
: 'text-content-subtle hover:text-content' : 'text-content-subtle hover:text-content'
}`} )}
style={{ paddingLeft: '1.75em' }}
> >
<span className="mx-4 text-sm font-normal">{item.label}</span> <span className="mx-4 text-sm font-normal">{item.label}</span>
</Link> </Link>
+2 -1
View File
@@ -3,6 +3,7 @@ import { useGetInfo } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { dataForStatus } from '../utils/apiResponses';
export interface UseAuthFormResult { export interface UseAuthFormResult {
username: string; username: string;
@@ -28,7 +29,7 @@ export function useAuthForm(mode: 'login' | 'register'): UseAuthFormResult {
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({ const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
query: { staleTime: Infinity }, query: { staleTime: Infinity },
}); });
const registrationEnabled = infoData?.status === 200 ? infoData.data.registration_enabled : false; const registrationEnabled = dataForStatus(infoData, 200)?.registration_enabled ?? false;
const submit = useCallback( const submit = useCallback(
async (e: SyntheticEvent<HTMLFormElement>) => { async (e: SyntheticEvent<HTMLFormElement>) => {
+2 -1
View File
@@ -5,6 +5,7 @@ import type { Activity } from '../generated/model';
import { Pagination } from '../components'; import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
const ACTIVITY_PAGE_SIZE = 25; const ACTIVITY_PAGE_SIZE = 25;
@@ -24,7 +25,7 @@ export default function ActivityPage() {
page, page,
limit, limit,
}); });
const response = data?.status === 200 ? data.data : undefined; const response = dataForStatus(data, 200);
const activities = response?.activities ?? []; const activities = response?.activities ?? [];
const columns: Column<Activity>[] = [ const columns: Column<Activity>[] = [
+2 -1
View File
@@ -6,6 +6,7 @@ import type { DirectoryItem } from '../generated/model';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons'; import { FolderOpenIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminImportPage() { export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>(''); const [currentPath, setCurrentPath] = useState<string>('');
@@ -20,7 +21,7 @@ export default function AdminImportPage() {
const postImport = usePostImport(); const postImport = usePostImport();
const directoryResponse = directoryData?.status === 200 ? directoryData.data : null; const directoryResponse = dataForStatus(directoryData, 200);
const directories = directoryResponse?.items ?? []; const directories = directoryResponse?.items ?? [];
const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data'; const currentPathDisplay = directoryResponse?.current_path ?? currentPath ?? '/data';
@@ -2,10 +2,11 @@ import { useGetImportResults } from '../generated/anthoLumeAPIV1';
import { Table, type Column } 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';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminImportResultsPage() { export default function AdminImportResultsPage() {
const { data: resultsData, isLoading } = useGetImportResults(); const { data: resultsData, isLoading } = useGetImportResults();
const results = resultsData?.status === 200 ? (resultsData.data.results ?? []) : []; const results = dataForStatus(resultsData, 200)?.results ?? [];
const columns: Column<ImportResult>[] = [ const columns: Column<ImportResult>[] = [
{ {
+2 -1
View File
@@ -3,13 +3,14 @@ import { useGetLogs } from '../generated/anthoLumeAPIV1';
import { Button, LoadingState, TextInput, IconInput } from '../components'; import { Button, LoadingState, TextInput, IconInput } from '../components';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon } from '../icons'; import { Search2Icon } from '../icons';
import { dataForStatus } from '../utils/apiResponses';
export default function AdminLogsPage() { export default function AdminLogsPage() {
const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300); const [filter, setFilter, activeFilter, flushFilter] = useDebouncedState('', 300);
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {}); const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
const logs = logsData?.status === 200 ? (logsData.data.logs ?? []) : []; const logs = dataForStatus(logsData, 200)?.logs ?? [];
const handleFilterSubmit = (e: SyntheticEvent) => { const handleFilterSubmit = (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
+111 -84
View File
@@ -7,6 +7,105 @@ import type { User } from '../generated/model';
import { AddIcon, DeleteIcon } from '../icons'; import { AddIcon, DeleteIcon } from '../icons';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { dataForStatus } from '../utils/apiResponses';
interface AddUserFormProps {
onCreate: (_username: string, _password: string, _isAdmin: boolean) => void;
}
function AddUserForm({ onCreate }: AddUserFormProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
onCreate(username, password, isAdmin);
};
return (
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
<form onSubmit={handleSubmit} className="flex flex-col gap-2 text-sm text-content">
<TextInput
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Username"
className="p-2"
/>
<TextInput
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
className="p-2"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="new_is_admin"
checked={isAdmin}
onChange={e => setIsAdmin(e.target.checked)}
/>
<label htmlFor="new_is_admin">Admin</label>
</div>
<Button type="submit">Create</Button>
</form>
</div>
);
}
interface ResetPasswordDialogProps {
userId: string;
onClose: () => void;
onSave: (_userId: string, _password: string) => void;
}
function ResetPasswordDialog({ userId, onClose, onSave }: ResetPasswordDialogProps) {
const [password, setPassword] = useState('');
return (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<form
className="w-80 rounded bg-surface p-4 shadow-lg"
onClick={e => e.stopPropagation()}
onSubmit={e => {
e.preventDefault();
if (!password) return;
onSave(userId, password);
onClose();
}}
>
<p className="mb-3 text-content">Reset password for {userId}</p>
<TextInput
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="New password"
autoFocus
/>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!password}>
Save
</Button>
</div>
</form>
</div>
);
}
const permissionButtonClass = (active: boolean) =>
`rounded-md px-2 py-1 ${
active
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`;
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { data: usersData, isLoading, refetch } = useGetUsers({}); const { data: usersData, isLoading, refetch } = useGetUsers({});
@@ -15,17 +114,12 @@ export default function AdminUsersPage() {
const { showError } = useToasts(); const { showError } = useToasts();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [resetUserId, setResetUserId] = useState<string | null>(null); const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState('');
const users = usersData?.status === 200 ? (usersData.data.users ?? []) : []; const users = dataForStatus(usersData, 200)?.users ?? [];
const handleCreateUser = (e: SyntheticEvent) => { const handleCreateUser = (username: string, password: string, isAdmin: boolean) => {
e.preventDefault(); if (!username || !password) {
if (!newUsername || !newPassword) {
showError('Please enter username and password'); showError('Please enter username and password');
return; return;
} }
@@ -34,9 +128,9 @@ export default function AdminUsersPage() {
{ {
data: { data: {
operation: 'CREATE', operation: 'CREATE',
user: newUsername, user: username,
password: newPassword, password,
is_admin: newIsAdmin, is_admin: isAdmin,
}, },
}, },
toastMutationOptions({ toastMutationOptions({
@@ -44,9 +138,6 @@ export default function AdminUsersPage() {
error: 'Failed to create user', error: 'Failed to create user',
onSuccess: () => { onSuccess: () => {
setShowAddForm(false); setShowAddForm(false);
setNewUsername('');
setNewPassword('');
setNewIsAdmin(false);
refetch(); refetch();
}, },
}) })
@@ -94,13 +185,6 @@ export default function AdminUsersPage() {
); );
}; };
const permissionButtonClass = (active: boolean) =>
`rounded-md px-2 py-1 ${
active
? 'cursor-default bg-content text-content-inverse'
: 'cursor-pointer bg-surface-strong text-content'
}`;
const userColumns: Column<User>[] = [ const userColumns: Column<User>[] = [
{ {
id: 'actions', id: 'actions',
@@ -124,7 +208,6 @@ export default function AdminUsersPage() {
<Button <Button
onClick={() => { onClick={() => {
setResetUserId(user.id); setResetUserId(user.id);
setResetPassword('');
}} }}
className="px-2 py-1" className="px-2 py-1"
> >
@@ -164,72 +247,16 @@ export default function AdminUsersPage() {
return ( return (
<div className="relative h-full overflow-x-auto"> <div className="relative h-full overflow-x-auto">
{showAddForm && ( {showAddForm && <AddUserForm onCreate={handleCreateUser} />}
<div className="absolute left-10 top-10 rounded bg-surface-strong p-3 shadow-lg transition-all duration-200">
<form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
<TextInput
type="text"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
placeholder="Username"
className="p-2"
/>
<TextInput
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Password"
className="p-2"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="new_is_admin"
checked={newIsAdmin}
onChange={e => setNewIsAdmin(e.target.checked)}
/>
<label htmlFor="new_is_admin">Admin</label>
</div>
<Button type="submit">Create</Button>
</form>
</div>
)}
<Table columns={userColumns} data={users} rowKey="id" /> <Table columns={userColumns} data={users} rowKey="id" />
{resetUserId && ( {resetUserId && (
<div <ResetPasswordDialog
className="fixed inset-0 z-40 flex items-center justify-center bg-black/50" userId={resetUserId}
onClick={() => setResetUserId(null)} onClose={() => setResetUserId(null)}
> onSave={handleUpdatePassword}
<form />
className="w-80 rounded bg-surface p-4 shadow-lg"
onClick={e => e.stopPropagation()}
onSubmit={e => {
e.preventDefault();
if (!resetPassword) return;
handleUpdatePassword(resetUserId, resetPassword);
setResetUserId(null);
}}
>
<p className="mb-3 text-content">Reset password for {resetUserId}</p>
<TextInput
type="password"
value={resetPassword}
onChange={e => setResetPassword(e.target.value)}
placeholder="New password"
autoFocus
/>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setResetUserId(null)}>
Cancel
</Button>
<Button type="submit" disabled={!resetPassword}>
Save
</Button>
</div>
</form>
</div>
)} )}
</div> </div>
); );
+4 -3
View File
@@ -8,6 +8,7 @@ import {
} from '../generated/anthoLumeAPIV1'; } from '../generated/anthoLumeAPIV1';
import type { EditDocumentBody } from '../generated/model'; import type { EditDocumentBody } from '../generated/model';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
import { useToastMutation } from '../hooks/useMutationWithToast'; import { useToastMutation } from '../hooks/useMutationWithToast';
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons'; import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components'; import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
@@ -121,12 +122,12 @@ export default function DocumentPage() {
return <LoadingState />; return <LoadingState />;
} }
if (!docData || docData.status !== 200) { const document = dataForStatus(docData, 200)?.document;
if (!document) {
return <div className="text-content-muted">Document not found</div>; return <div className="text-content-muted">Document not found</div>;
} }
const document = docData.data.document;
const percentage = document.percentage ?? 0; const percentage = document.percentage ?? 0;
const secondsPerPercent = document.seconds_per_percent || 0; const secondsPerPercent = document.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent); const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
+39 -45
View File
@@ -10,6 +10,7 @@ import { formatDuration } from '../utils/formatters';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings'; import { useLocalSetting, type DocumentsViewMode } from '../utils/localSettings';
import { dataForStatus } from '../utils/apiResponses';
const DOCUMENTS_PAGE_SIZE = 9; const DOCUMENTS_PAGE_SIZE = 9;
@@ -21,14 +22,16 @@ interface DocumentItemProps {
function DocumentItem({ doc, layout }: DocumentItemProps) { function DocumentItem({ doc, layout }: DocumentItemProps) {
const percentage = doc.percentage || 0; const percentage = doc.percentage || 0;
const totalTimeSeconds = doc.total_time_seconds || 0; const totalTimeSeconds = doc.total_time_seconds || 0;
const documentPath = `/documents/${doc.id}`;
const title = doc.title || 'Unknown';
const icons = ( const actions = (
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted pointer-events-auto"> <div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
<Link to={`/activity?document=${doc.id}`}> <Link to={`/activity?document=${doc.id}`} aria-label={`View activity for ${title}`}>
<ActivityIcon size={20} /> <ActivityIcon size={20} />
</Link> </Link>
{doc.filepath ? ( {doc.filepath ? (
<a href={`/api/v1/documents/${doc.id}/file`}> <a href={`/api/v1/documents/${doc.id}/file`} aria-label={`Download ${title}`}>
<DownloadIcon size={20} /> <DownloadIcon size={20} />
</a> </a>
) : ( ) : (
@@ -38,63 +41,54 @@ function DocumentItem({ doc, layout }: DocumentItemProps) {
); );
const fields = [ const fields = [
{ label: 'Title', value: doc.title || 'Unknown' }, { label: 'Title', value: title, link: true },
{ label: 'Author', value: doc.author || 'Unknown' }, { label: 'Author', value: doc.author || 'Unknown' },
{ label: 'Progress', value: `${percentage}%` }, { label: 'Progress', value: `${percentage}%` },
{ label: 'Time Read', value: formatDuration(totalTimeSeconds) }, { label: 'Time Read', value: formatDuration(totalTimeSeconds) },
]; ];
const fieldList = (
<div
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
>
{fields.map(field => (
<div key={field.label}>
<p className="text-content-subtle">{field.label}</p>
{field.link ? (
<Link to={documentPath} className="font-medium hover:underline">
{field.value}
</Link>
) : (
<p className="font-medium">{field.value}</p>
)}
</div>
))}
</div>
);
if (layout === 'grid') { if (layout === 'grid') {
return ( return (
<div className="relative flex size-full gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted"> <div className="flex size-full gap-4 rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */} <Link to={documentPath} className="my-auto h-48 min-w-fit" aria-label={`Open ${title}`}>
<Link
to={`/documents/${doc.id}`}
aria-label={`Open ${doc.title || 'document'}`}
className="absolute inset-0"
/>
<div className="pointer-events-none my-auto h-48 min-w-fit">
<img <img
className="h-full rounded object-cover" className="h-full rounded object-cover"
src={`/api/v1/documents/${doc.id}/cover`} src={`/api/v1/documents/${doc.id}/cover`}
alt={doc.title} alt={title}
/> />
</div> </Link>
<div className="pointer-events-none flex w-full flex-col justify-around text-sm text-content"> <div className="flex w-full flex-col justify-between gap-4">
{fields.map(f => ( {fieldList}
<div key={f.label} className="inline-flex shrink-0 items-center"> {actions}
<div>
<p className="text-content-subtle">{f.label}</p>
<p className="font-medium">{f.value}</p>
</div>
</div>
))}
</div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
{icons}
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="relative block rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted"> <div className="rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */} <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<Link {fieldList}
to={`/documents/${doc.id}`} {actions}
aria-label={`Open ${doc.title || 'document'}`}
className="absolute inset-0"
/>
<div className="pointer-events-none flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
{fields.map(f => (
<div key={f.label}>
<p className="text-content-subtle">{f.label}</p>
<p className="font-medium">{f.value}</p>
</div>
))}
</div>
{icons}
</div> </div>
</div> </div>
); );
@@ -126,7 +120,7 @@ export default function DocumentsPage() {
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch }); const { data, isLoading, refetch } = useGetDocuments({ page, limit, search: debouncedSearch });
const createMutation = useCreateDocument(); const createMutation = useCreateDocument();
const documentsResponse = data?.status === 200 ? data.data : undefined; const documentsResponse = dataForStatus(data, 200);
const docs = documentsResponse?.documents; const docs = documentsResponse?.documents;
const previousPage = documentsResponse?.previous_page; const previousPage = documentsResponse?.previous_page;
const nextPage = documentsResponse?.next_page; const nextPage = documentsResponse?.next_page;
+2 -1
View File
@@ -5,6 +5,7 @@ import { useGetHome } from '../generated/anthoLumeAPIV1';
import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model'; import type { LeaderboardData, LeaderboardEntry, UserStreak } from '../generated/model';
import ReadingHistoryGraph from '../components/ReadingHistoryGraph'; import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters'; import { formatNumber, formatDuration } from '../utils/formatters';
import { dataForStatus } from '../utils/apiResponses';
interface InfoCardProps { interface InfoCardProps {
title: string; title: string;
@@ -166,7 +167,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() { export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome(); const { data: homeData, isLoading: homeLoading } = useGetHome();
const homeResponse = homeData?.status === 200 ? homeData.data : null; const homeResponse = dataForStatus(homeData, 200);
const dbInfo = homeResponse?.database_info; const dbInfo = homeResponse?.database_info;
const streaks = homeResponse?.streaks?.streaks; const streaks = homeResponse?.streaks?.streaks;
const graphData = homeResponse?.graph_data?.graph_data; const graphData = homeResponse?.graph_data?.graph_data;
+2 -1
View File
@@ -4,6 +4,7 @@ import { useGetProgressList } from '../generated/anthoLumeAPIV1';
import type { Progress } from '../generated/model'; import type { Progress } from '../generated/model';
import { Pagination } from '../components'; import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { dataForStatus } from '../utils/apiResponses';
const PROGRESS_PAGE_SIZE = 15; const PROGRESS_PAGE_SIZE = 15;
@@ -11,7 +12,7 @@ export default function ProgressPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = PROGRESS_PAGE_SIZE; const limit = PROGRESS_PAGE_SIZE;
const { data, isLoading } = useGetProgressList({ page, limit }); const { data, isLoading } = useGetProgressList({ page, limit });
const response = data?.status === 200 ? data.data : undefined; const response = dataForStatus(data, 200);
const progress = response?.progress ?? []; const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [ const columns: Column<Progress>[] = [
+4 -3
View File
@@ -13,6 +13,7 @@ import {
useLocalSetting, useLocalSetting,
} from '../utils/localSettings'; } from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader'; import { useEpubReader } from '../hooks/useEpubReader';
import { dataForStatus } from '../utils/apiResponses';
const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm'; const READER_SEGMENT_BUTTON = 'rounded border px-2 py-1.5 text-xs sm:text-sm';
const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content'; const READER_SEGMENT_ACTIVE = 'border-primary-500 bg-primary-500/10 text-content';
@@ -39,8 +40,8 @@ export default function ReaderPage() {
enabled: Boolean(id), enabled: Boolean(id),
}, },
}); });
const document = documentResponse?.status === 200 ? documentResponse.data.document : null; const document = dataForStatus(documentResponse, 200)?.document;
const progress = progressResponse?.status === 200 ? progressResponse.data.progress : undefined; const progress = dataForStatus(progressResponse, 200)?.progress;
const deviceId = defaultDeviceId; const deviceId = defaultDeviceId;
const deviceName = defaultDeviceName; const deviceName = defaultDeviceName;
@@ -108,7 +109,7 @@ export default function ReaderPage() {
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />; return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
} }
if (!id || !document || documentResponse?.status !== 200) { if (!id || !document) {
return <div className="p-6 text-content-muted">Document not found</div>; return <div className="p-6 text-content-muted">Document not found</div>;
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import { Button, Table, type Column, TextInput, IconInput } from '../components'
import { inputClassName } from '../components/TextInput'; import { inputClassName } from '../components/TextInput';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { Search2Icon, BookIcon } from '../icons'; import { Search2Icon, BookIcon } from '../icons';
import { dataForStatus } from '../utils/apiResponses';
const searchColumns: Column<SearchItem>[] = [ const searchColumns: Column<SearchItem>[] = [
{ {
@@ -92,7 +93,7 @@ export default function SearchPage() {
}, },
} }
); );
const results = data?.status === 200 ? (data.data.results ?? []) : []; const results = dataForStatus(data, 200)?.results ?? [];
const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => { const handleSubmit = (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
+174 -127
View File
@@ -2,12 +2,13 @@ import { useState, useEffect, SyntheticEvent } from 'react';
import { Button, LoadingState, Table, type Column, TextInput, IconInput } from '../components'; import { Button, LoadingState, Table, type Column, TextInput, IconInput } from '../components';
import { inputClassName } from '../components/TextInput'; import { inputClassName } from '../components/TextInput';
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1'; import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
import type { Device } from '../generated/model'; import type { Device, SettingsResponse } from '../generated/model';
import { UserIcon, PasswordIcon, ClockIcon } from '../icons'; import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { useMutationWithToast, useToastMutation } from '../hooks/useMutationWithToast';
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings'; import type { ThemeMode } from '../utils/localSettings';
import { dataForStatus } from '../utils/apiResponses';
const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A'); const formatDeviceDate = (value?: string) => (value ? new Date(value).toLocaleString() : 'N/A');
@@ -32,16 +33,171 @@ const themeModes: Array<{ value: ThemeMode; label: string; description: string }
{ value: 'system', label: 'System', description: 'Follow your device preference.' }, { value: 'system', label: 'System', description: 'Follow your device preference.' },
]; ];
function ProfileCard({ settings }: { settings?: SettingsResponse }) {
return (
<div>
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
<UserIcon size={60} />
<p className="text-lg text-content">{settings?.user.username || 'N/A'}</p>
</div>
</div>
);
}
function PasswordSection({
onSubmit,
}: {
onSubmit: (password: string, next: string) => Promise<boolean>;
}) {
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault();
if (await onSubmit(password, newPassword)) {
setPassword('');
setNewPassword('');
}
};
return (
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex grow flex-col">
<IconInput icon={<PasswordIcon size={15} />}>
<TextInput
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
</IconInput>
</div>
<div className="flex grow flex-col">
<IconInput icon={<PasswordIcon size={15} />}>
<TextInput
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="New Password"
/>
</IconInput>
</div>
<Button variant="secondary" type="submit" className="w-full lg:w-60">
Submit
</Button>
</form>
</div>
);
}
function AppearanceSection() {
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
return (
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
<p>
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{themeModes.map(mode => {
const isSelected = themeMode === mode.value;
return (
<button
key={mode.value}
type="button"
onClick={() => setThemeMode(mode.value)}
className={`rounded border p-4 text-left transition-colors ${
isSelected
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
}`}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-base font-semibold text-content">{mode.label}</span>
<span
className={`inline-flex size-4 rounded-full border ${
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
}`}
/>
</div>
<p className="text-sm">{mode.description}</p>
</button>
);
})}
</div>
</div>
);
}
function TimezoneSection({
timezone,
onChange,
onSubmit,
}: {
timezone: string;
onChange: (timezone: string) => void;
onSubmit: () => void;
}) {
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
onSubmit();
};
return (
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<IconInput className="grow" icon={<ClockIcon size={15} />}>
<select
value={timezone || 'UTC'}
onChange={e => onChange(e.target.value)}
className={inputClassName}
>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Australia/Sydney">Australia/Sydney</option>
</select>
</IconInput>
<Button variant="secondary" type="submit" className="w-full lg:w-60">
Submit
</Button>
</form>
</div>
);
}
function DevicesSection({ devices }: { devices: Device[] }) {
return (
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">Devices</p>
<Table columns={deviceColumns} data={devices} rowKey="id" />
</div>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const { data, isLoading } = useGetSettings(); const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings(); const updateSettings = useUpdateSettings();
const settingsData = data?.status === 200 ? data.data : null; const settingsData = dataForStatus(data, 200);
const { showError } = useToasts(); const { showError } = useToasts();
const toastMutationOptions = useMutationWithToast(); const toastMutationOptions = useMutationWithToast();
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme(); const runWithToast = useToastMutation();
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [timezone, setTimezone] = useState('UTC'); const [timezone, setTimezone] = useState('UTC');
useEffect(() => { useEffect(() => {
@@ -50,30 +206,22 @@ export default function SettingsPage() {
} }
}, [settingsData]); }, [settingsData]);
const handlePasswordSubmit = (e: SyntheticEvent) => { const updatePassword = async (password: string, newPassword: string) => {
e.preventDefault();
if (!password || !newPassword) { if (!password || !newPassword) {
showError('Please enter both current and new password'); showError('Please enter both current and new password');
return; return false;
} }
updateSettings.mutate( return runWithToast(
{ data: { password, new_password: newPassword } }, () => updateSettings.mutateAsync({ data: { password, new_password: newPassword } }),
toastMutationOptions({ {
success: 'Password updated successfully', success: 'Password updated successfully',
error: 'Failed to update password', error: 'Failed to update password',
onSuccess: () => { }
setPassword('');
setNewPassword('');
},
})
); );
}; };
const handleTimezoneSubmit = (e: SyntheticEvent) => { const updateTimezone = () => {
e.preventDefault();
updateSettings.mutate( updateSettings.mutate(
{ data: { timezone } }, { data: { timezone } },
toastMutationOptions({ toastMutationOptions({
@@ -89,113 +237,12 @@ export default function SettingsPage() {
return ( return (
<div className="flex w-full flex-col gap-4 md:flex-row"> <div className="flex w-full flex-col gap-4 md:flex-row">
<div> <ProfileCard settings={settingsData} />
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
<UserIcon size={60} />
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
</div>
</div>
<div className="flex grow flex-col gap-4"> <div className="flex grow flex-col gap-4">
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg"> <PasswordSection onSubmit={updatePassword} />
<p className="mb-2 text-lg font-semibold text-content">Change Password</p> <AppearanceSection />
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}> <TimezoneSection timezone={timezone} onChange={setTimezone} onSubmit={updateTimezone} />
<div className="flex grow flex-col"> <DevicesSection devices={settingsData?.devices ?? []} />
<IconInput icon={<PasswordIcon size={15} />}>
<TextInput
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
</IconInput>
</div>
<div className="flex grow flex-col">
<IconInput icon={<PasswordIcon size={15} />}>
<TextInput
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="New Password"
/>
</IconInput>
</div>
<Button variant="secondary" type="submit" className="w-full lg:w-60">
Submit
</Button>
</form>
</div>
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
<p>
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{themeModes.map(mode => {
const isSelected = themeMode === mode.value;
return (
<button
key={mode.value}
type="button"
onClick={() => setThemeMode(mode.value)}
className={`rounded border p-4 text-left transition-colors ${
isSelected
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
}`}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-base font-semibold text-content">{mode.label}</span>
<span
className={`inline-flex size-4 rounded-full border ${
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
}`}
/>
</div>
<p className="text-sm">{mode.description}</p>
</button>
);
})}
</div>
</div>
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
<IconInput className="grow" icon={<ClockIcon size={15} />}>
<select
value={timezone || 'UTC'}
onChange={e => setTimezone(e.target.value)}
className={inputClassName}
>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Australia/Sydney">Australia/Sydney</option>
</select>
</IconInput>
<Button variant="secondary" type="submit" className="w-full lg:w-60">
Submit
</Button>
</form>
</div>
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">Devices</p>
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" />
</div>
</div> </div>
</div> </div>
); );
+21
View File
@@ -0,0 +1,21 @@
type ApiResponse = {
status: number;
data: unknown;
};
export function dataForStatus<TResponse extends ApiResponse, TStatus extends TResponse['status']>(
response: TResponse | undefined,
status: TStatus
): Extract<TResponse, { status: TStatus }>['data'] | undefined {
return response?.status === status
? (response.data as Extract<TResponse, { status: TStatus }>['data'])
: undefined;
}
export function dataForSuccess<TResponse extends ApiResponse>(
response: TResponse | undefined
): Extract<TResponse, { status: 200 | 201 | 202 | 204 }>['data'] | undefined {
return response && response.status >= 200 && response.status < 300
? (response.data as Extract<TResponse, { status: 200 | 201 | 202 | 204 }>['data'])
: undefined;
}