@@ -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');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
|
|||||||
@@ -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>[] = [
|
||||||
|
|||||||
@@ -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>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (layout === 'grid') {
|
const fieldList = (
|
||||||
return (
|
<div
|
||||||
<div className="relative flex size-full gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted">
|
className={cn('grid flex-1 grid-cols-1 gap-3 text-sm', layout === 'list' && 'md:grid-cols-4')}
|
||||||
{/* Stretched Link - Covers the whole card; display content is pointer-events-none so clicks fall through to navigate. */}
|
>
|
||||||
<Link
|
{fields.map(field => (
|
||||||
to={`/documents/${doc.id}`}
|
<div key={field.label}>
|
||||||
aria-label={`Open ${doc.title || 'document'}`}
|
<p className="text-content-subtle">{field.label}</p>
|
||||||
className="absolute inset-0"
|
{field.link ? (
|
||||||
/>
|
<Link to={documentPath} className="font-medium hover:underline">
|
||||||
<div className="pointer-events-none my-auto h-48 min-w-fit">
|
{field.value}
|
||||||
<img
|
</Link>
|
||||||
className="h-full rounded object-cover"
|
) : (
|
||||||
src={`/api/v1/documents/${doc.id}/cover`}
|
<p className="font-medium">{field.value}</p>
|
||||||
alt={doc.title}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-none flex w-full flex-col justify-around text-sm text-content">
|
|
||||||
{fields.map(f => (
|
|
||||||
<div key={f.label} className="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-content-subtle">{f.label}</p>
|
|
||||||
<p className="font-medium">{f.value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
|
);
|
||||||
{icons}
|
|
||||||
|
if (layout === 'grid') {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full gap-4 rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted">
|
||||||
|
<Link to={documentPath} className="my-auto h-48 min-w-fit" aria-label={`Open ${title}`}>
|
||||||
|
<img
|
||||||
|
className="h-full rounded object-cover"
|
||||||
|
src={`/api/v1/documents/${doc.id}/cover`}
|
||||||
|
alt={title}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex w-full flex-col justify-between gap-4">
|
||||||
|
{fieldList}
|
||||||
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>[] = [
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,74 +33,37 @@ 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.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
function ProfileCard({ settings }: { settings?: SettingsResponse }) {
|
||||||
const { data, isLoading } = useGetSettings();
|
|
||||||
const updateSettings = useUpdateSettings();
|
|
||||||
const settingsData = data?.status === 200 ? data.data : null;
|
|
||||||
const { showError } = useToasts();
|
|
||||||
const toastMutationOptions = useMutationWithToast();
|
|
||||||
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
|
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [timezone, setTimezone] = useState('UTC');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
|
|
||||||
setTimezone(settingsData.timezone);
|
|
||||||
}
|
|
||||||
}, [settingsData]);
|
|
||||||
|
|
||||||
const handlePasswordSubmit = (e: SyntheticEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!password || !newPassword) {
|
|
||||||
showError('Please enter both current and new password');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings.mutate(
|
|
||||||
{ data: { password, new_password: newPassword } },
|
|
||||||
toastMutationOptions({
|
|
||||||
success: 'Password updated successfully',
|
|
||||||
error: 'Failed to update password',
|
|
||||||
onSuccess: () => {
|
|
||||||
setPassword('');
|
|
||||||
setNewPassword('');
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimezoneSubmit = (e: SyntheticEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
updateSettings.mutate(
|
|
||||||
{ data: { timezone } },
|
|
||||||
toastMutationOptions({
|
|
||||||
success: 'Timezone updated successfully',
|
|
||||||
error: 'Failed to update timezone',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
|
<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} />
|
<UserIcon size={60} />
|
||||||
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
|
<p className="text-lg text-content">{settings?.user.username || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex grow flex-col gap-4">
|
function PasswordSection({
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
onSubmit: (password: string, next: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await onSubmit(password, newPassword)) {
|
||||||
|
setPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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>
|
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||||
<div className="flex grow flex-col">
|
<div className="flex grow flex-col">
|
||||||
<IconInput icon={<PasswordIcon size={15} />}>
|
<IconInput icon={<PasswordIcon size={15} />}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -125,7 +89,13 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 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 className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -164,14 +134,31 @@ export default function SettingsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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={handleSubmit}>
|
||||||
<IconInput className="grow" icon={<ClockIcon size={15} />}>
|
<IconInput className="grow" icon={<ClockIcon size={15} />}>
|
||||||
<select
|
<select
|
||||||
value={timezone || 'UTC'}
|
value={timezone || 'UTC'}
|
||||||
onChange={e => setTimezone(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
>
|
>
|
||||||
<option value="UTC">UTC</option>
|
<option value="UTC">UTC</option>
|
||||||
@@ -191,11 +178,71 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevicesSection({ devices }: { devices: Device[] }) {
|
||||||
|
return (
|
||||||
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
<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>
|
<p className="text-lg font-semibold text-content">Devices</p>
|
||||||
<Table columns={deviceColumns} data={settingsData?.devices ?? []} rowKey="id" />
|
<Table columns={deviceColumns} data={devices} rowKey="id" />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data, isLoading } = useGetSettings();
|
||||||
|
const updateSettings = useUpdateSettings();
|
||||||
|
const settingsData = dataForStatus(data, 200);
|
||||||
|
const { showError } = useToasts();
|
||||||
|
const toastMutationOptions = useMutationWithToast();
|
||||||
|
const runWithToast = useToastMutation();
|
||||||
|
|
||||||
|
const [timezone, setTimezone] = useState('UTC');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
|
||||||
|
setTimezone(settingsData.timezone);
|
||||||
|
}
|
||||||
|
}, [settingsData]);
|
||||||
|
|
||||||
|
const updatePassword = async (password: string, newPassword: string) => {
|
||||||
|
if (!password || !newPassword) {
|
||||||
|
showError('Please enter both current and new password');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runWithToast(
|
||||||
|
() => updateSettings.mutateAsync({ data: { password, new_password: newPassword } }),
|
||||||
|
{
|
||||||
|
success: 'Password updated successfully',
|
||||||
|
error: 'Failed to update password',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTimezone = () => {
|
||||||
|
updateSettings.mutate(
|
||||||
|
{ data: { timezone } },
|
||||||
|
toastMutationOptions({
|
||||||
|
success: 'Timezone updated successfully',
|
||||||
|
error: 'Failed to update timezone',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||||
|
<ProfileCard settings={settingsData} />
|
||||||
|
<div className="flex grow flex-col gap-4">
|
||||||
|
<PasswordSection onSubmit={updatePassword} />
|
||||||
|
<AppearanceSection />
|
||||||
|
<TimezoneSection timezone={timezone} onChange={setTimezone} onSubmit={updateTimezone} />
|
||||||
|
<DevicesSection devices={settingsData?.devices ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user