wip 19
This commit is contained in:
@@ -8,6 +8,7 @@ import ActivityPage from './pages/ActivityPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import AdminImportPage from './pages/AdminImportPage';
|
||||
import AdminImportResultsPage from './pages/AdminImportResultsPage';
|
||||
@@ -118,6 +119,7 @@ export function Routes() {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
</ReactRoutes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import {
|
||||
getGetMeQueryKey,
|
||||
useLogin,
|
||||
useLogout,
|
||||
useGetMe,
|
||||
useRegister,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
@@ -10,6 +17,7 @@ interface AuthState {
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (_username: string, _password: string) => Promise<void>;
|
||||
register: (_username: string, _password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -19,27 +27,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true, // Start with checking state to prevent redirects during initial load
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Always call /me to check authentication status
|
||||
const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Update auth state based on /me endpoint response
|
||||
useEffect(() => {
|
||||
setAuthState(prev => {
|
||||
if (meLoading) {
|
||||
// Still checking authentication
|
||||
console.log('[AuthContext] Checking authentication status...');
|
||||
return { ...prev, isCheckingAuth: true };
|
||||
} else if (meData?.data && meData.status === 200) {
|
||||
// User is authenticated - check that response has valid data
|
||||
console.log('[AuthContext] User authenticated:', meData.data);
|
||||
const userData = 'username' in meData.data ? meData.data : null;
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
@@ -47,16 +51,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
} else if (meError || (meData && meData.status === 401)) {
|
||||
// User is not authenticated or error occurred
|
||||
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
console.log('[AuthContext] Unexpected state - checking...');
|
||||
return { ...prev, isCheckingAuth: false }; // Assume not authenticated if we can't determine
|
||||
|
||||
return { ...prev, isCheckingAuth: false };
|
||||
});
|
||||
}, [meData, meError, meLoading]);
|
||||
|
||||
@@ -70,41 +72,92 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
// The backend uses session-based authentication, so no token to store
|
||||
// The session cookie is automatically set by the browser
|
||||
if (response.status !== 200 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user:
|
||||
'username' in response.data
|
||||
? (response.data as { username: string; is_admin: boolean })
|
||||
: null,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
console.error('[AuthContext] Login failed:', _error);
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate, queryClient]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await registerMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 201 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
[navigate, queryClient, registerMutation]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
}, [logoutMutation, navigate]);
|
||||
}, [logoutMutation, navigate, queryClient]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authState, login, logout }}>{children}</AuthContext.Provider>
|
||||
<AuthContext.Provider value={{ ...authState, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ export default function Layout() {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||
const userData = data?.data || user;
|
||||
const fetchedUser =
|
||||
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
||||
const userData = user ?? fetchedUser;
|
||||
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -34,15 +36,26 @@ export default function Layout() {
|
||||
|
||||
// Get current page title
|
||||
const navItems = [
|
||||
{ path: '/', title: 'Home' },
|
||||
{ path: '/admin/import-results', title: 'Admin - Import' },
|
||||
{ path: '/admin/import', title: 'Admin - Import' },
|
||||
{ path: '/admin/users', title: 'Admin - Users' },
|
||||
{ path: '/admin/logs', title: 'Admin - Logs' },
|
||||
{ path: '/admin', title: 'Admin - General' },
|
||||
{ path: '/documents', title: 'Documents' },
|
||||
{ path: '/progress', title: 'Progress' },
|
||||
{ path: '/activity', title: 'Activity' },
|
||||
{ path: '/search', title: 'Search' },
|
||||
{ path: '/settings', title: 'Settings' },
|
||||
{ path: '/', title: 'Home' },
|
||||
];
|
||||
const currentPageTitle =
|
||||
navItems.find(item => location.pathname === item.path)?.title || 'Documents';
|
||||
navItems.find(item =>
|
||||
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
||||
)?.title || 'Home';
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||
}, [currentPageTitle]);
|
||||
|
||||
// Show loading while checking authentication status
|
||||
if (isCheckingAuth) {
|
||||
@@ -62,7 +75,9 @@ export default function Layout() {
|
||||
<HamburgerMenu />
|
||||
|
||||
{/* Header Title */}
|
||||
<h1 className="px-6 text-xl font-bold lg:ml-44 dark:text-white">{currentPageTitle}</h1>
|
||||
<h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
|
||||
{currentPageTitle}
|
||||
</h1>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div
|
||||
@@ -78,7 +93,7 @@ export default function Layout() {
|
||||
|
||||
{isUserDropdownOpen && (
|
||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-700 dark:shadow-gray-800">
|
||||
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-700 dark:shadow-gray-800">
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
|
||||
@@ -2,14 +2,14 @@ import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export interface Column<T> {
|
||||
export interface Column<T extends Record<string, unknown>> {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: any, _row: T, _index: number) => React.ReactNode;
|
||||
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
export interface TableProps<T extends Record<string, unknown>> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
@@ -58,7 +58,7 @@ function SkeletonTable({
|
||||
);
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any>>({
|
||||
export function Table<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
|
||||
@@ -1690,6 +1690,112 @@ export const useLogin = <TError = ErrorResponse,
|
||||
return useMutation(getLoginMutationOptions(options), queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary User registration
|
||||
*/
|
||||
export type registerResponse201 = {
|
||||
data: LoginResponse
|
||||
status: 201
|
||||
}
|
||||
|
||||
export type registerResponse400 = {
|
||||
data: ErrorResponse
|
||||
status: 400
|
||||
}
|
||||
|
||||
export type registerResponse403 = {
|
||||
data: ErrorResponse
|
||||
status: 403
|
||||
}
|
||||
|
||||
export type registerResponse500 = {
|
||||
data: ErrorResponse
|
||||
status: 500
|
||||
}
|
||||
|
||||
export type registerResponseSuccess = (registerResponse201) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type registerResponseError = (registerResponse400 | registerResponse403 | registerResponse500) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type registerResponse = (registerResponseSuccess | registerResponseError)
|
||||
|
||||
export const getRegisterUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/v1/auth/register`
|
||||
}
|
||||
|
||||
export const register = async (loginRequest: LoginRequest, options?: RequestInit): Promise<registerResponse> => {
|
||||
|
||||
const res = await fetch(getRegisterUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
loginRequest,)
|
||||
}
|
||||
)
|
||||
|
||||
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
|
||||
|
||||
const data: registerResponse['data'] = body ? JSON.parse(body) : {}
|
||||
return { data, status: res.status, headers: res.headers } as registerResponse
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getRegisterMutationOptions = <TError = ErrorResponse,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof register>>, TError,{data: LoginRequest}, TContext>, fetch?: RequestInit}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof register>>, TError,{data: LoginRequest}, TContext> => {
|
||||
|
||||
const mutationKey = ['register'];
|
||||
const {mutation: mutationOptions, fetch: fetchOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, fetch: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof register>>, {data: LoginRequest}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return register(data,fetchOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type RegisterMutationResult = NonNullable<Awaited<ReturnType<typeof register>>>
|
||||
export type RegisterMutationBody = LoginRequest
|
||||
export type RegisterMutationError = ErrorResponse
|
||||
|
||||
/**
|
||||
* @summary User registration
|
||||
*/
|
||||
export const useRegister = <TError = ErrorResponse,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof register>>, TError,{data: LoginRequest}, TContext>, fetch?: RequestInit}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof register>>,
|
||||
TError,
|
||||
{data: LoginRequest},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRegisterMutationOptions(options), queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary User logout
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,11 @@ interface FolderOpenIconProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FolderOpenIcon({ size = 24, className = '', disabled = false }: FolderOpenIconProps) {
|
||||
export function FolderOpenIcon({
|
||||
size = 24,
|
||||
className = '',
|
||||
disabled = false,
|
||||
}: FolderOpenIconProps) {
|
||||
return (
|
||||
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||
<path
|
||||
|
||||
@@ -3,6 +3,10 @@ interface LoadingIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const spinnerAnimation = 'spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite';
|
||||
|
||||
const spinnerPath = 'M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z';
|
||||
|
||||
export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
|
||||
return (
|
||||
<svg
|
||||
@@ -15,15 +19,6 @@ export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
.spinner_l9ve {
|
||||
animation: spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite;
|
||||
}
|
||||
.spinner_cMYp {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.spinner_gHR3 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
@keyframes spinner_rcyq {
|
||||
0% {
|
||||
transform: translate(12px, 12px) scale(0);
|
||||
@@ -37,19 +32,19 @@ export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
|
||||
`}
|
||||
</style>
|
||||
<path
|
||||
className="spinner_l9ve"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||
d={spinnerPath}
|
||||
transform="translate(12, 12) scale(0)"
|
||||
style={{ animation: spinnerAnimation }}
|
||||
/>
|
||||
<path
|
||||
className="spinner_l9ve spinner_cMYp"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||
d={spinnerPath}
|
||||
transform="translate(12, 12) scale(0)"
|
||||
style={{ animation: spinnerAnimation, animationDelay: '0.4s' }}
|
||||
/>
|
||||
<path
|
||||
className="spinner_l9ve spinner_gHR3"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
|
||||
d={spinnerPath}
|
||||
transform="translate(12, 12) scale(0)"
|
||||
style={{ animation: spinnerAnimation, animationDelay: '0.8s' }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||
const activities = data?.data?.activities;
|
||||
const activities = data?.status === 200 ? data.data.activities : [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_: any, row: any) => (
|
||||
render: (_value: Activity['document_id'], row: Activity) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -23,19 +24,19 @@ export default function ActivityPage() {
|
||||
{
|
||||
key: 'start_time' as const,
|
||||
header: 'Time',
|
||||
render: (value: any) => value || 'N/A',
|
||||
render: (value: Activity['start_time']) => value || 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'duration' as const,
|
||||
header: 'Duration',
|
||||
render: (value: any) => {
|
||||
render: (value: Activity['duration']) => {
|
||||
return formatDuration(value || 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'end_percentage' as const,
|
||||
header: 'Percent',
|
||||
render: (value: any) => (value != null ? `${value}%` : '0%'),
|
||||
render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
@@ -50,7 +51,7 @@ export default function AdminImportPage() {
|
||||
}, 1500);
|
||||
},
|
||||
onError: error => {
|
||||
showError('Import failed: ' + (error as any).message);
|
||||
showError('Import failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react';
|
||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface BackupTypes {
|
||||
covers: boolean;
|
||||
@@ -43,10 +44,10 @@ export default function AdminPage() {
|
||||
|
||||
// Stream the response directly to disk using File System Access API
|
||||
// This avoids loading multi-GB files into browser memory
|
||||
if (typeof (window as any).showSaveFilePicker === 'function') {
|
||||
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
||||
try {
|
||||
// Modern browsers: Use File System Access API for direct disk writes
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
@@ -78,7 +79,7 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Backup failed: ' + (error as any).message);
|
||||
showError('Backup failed: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,7 +99,7 @@ export default function AdminPage() {
|
||||
showInfo('Restore completed successfully');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Restore failed: ' + (error as any).message);
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -116,7 +117,7 @@ export default function AdminPage() {
|
||||
showInfo('Metadata matching started');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Metadata matching failed: ' + (error as any).message);
|
||||
showError('Metadata matching failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -134,7 +135,7 @@ export default function AdminPage() {
|
||||
showInfo('Cache tables started');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Cache tables failed: ' + (error as any).message);
|
||||
showError('Cache tables failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react';
|
||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||
@@ -37,8 +38,8 @@ export default function AdminUsersPage() {
|
||||
setNewIsAdmin(false);
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to create user: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to create user: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -57,8 +58,8 @@ export default function AdminUsersPage() {
|
||||
showInfo('User deleted successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to delete user: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to delete user: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -80,8 +81,8 @@ export default function AdminUsersPage() {
|
||||
showInfo('Password updated successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to update password: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -102,8 +103,8 @@ export default function AdminUsersPage() {
|
||||
showInfo(`User permissions updated to ${role}`);
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to update admin status: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to update admin status: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { DocumentsResponse } from '../generated/model/documentsResponse';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
created_at: string;
|
||||
deleted: boolean;
|
||||
words?: number;
|
||||
filepath?: string;
|
||||
percentage?: number;
|
||||
total_time_seconds?: number;
|
||||
};
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentCard({ doc }: DocumentCardProps) {
|
||||
@@ -125,8 +116,8 @@ export default function DocumentsPage() {
|
||||
showInfo('Document uploaded successfully!');
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
} catch (error: any) {
|
||||
showError('Failed to upload document: ' + error.message);
|
||||
} catch (error) {
|
||||
showError('Failed to upload document: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,7 +161,7 @@ export default function DocumentsPage() {
|
||||
{isLoading ? (
|
||||
<div className="col-span-full text-center text-gray-500 dark:text-white">Loading...</div>
|
||||
) : (
|
||||
docs?.map((doc: any) => <DocumentCard key={doc.id} doc={doc} />)
|
||||
docs?.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -220,7 +211,9 @@ export default function DocumentsPage() {
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
handleFileChange({ target: { files: fileInputRef.current?.files } } as any);
|
||||
handleFileChange({
|
||||
target: { files: fileInputRef.current?.files },
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||
import type { LeaderboardData } from '../generated/model';
|
||||
import type {
|
||||
HomeResponse,
|
||||
LeaderboardData,
|
||||
LeaderboardEntry,
|
||||
UserStreak,
|
||||
} from '../generated/model';
|
||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||
|
||||
@@ -127,7 +132,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
||||
{name} Leaderboard
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-gray-400 items-center">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodChange('all')}
|
||||
@@ -172,7 +177,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="dark:text-white">
|
||||
{currentData?.slice(0, 3).map((item: any, index: number) => (
|
||||
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
||||
@@ -192,10 +197,11 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
export default function HomePage() {
|
||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||
|
||||
const dbInfo = homeData?.data?.database_info;
|
||||
const streaks = homeData?.data?.streaks?.streaks;
|
||||
const graphData = homeData?.data?.graph_data?.graph_data;
|
||||
const userStats = homeData?.data?.user_statistics;
|
||||
const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null;
|
||||
const dbInfo = homeResponse?.database_info;
|
||||
const streaks = homeResponse?.streaks?.streaks;
|
||||
const graphData = homeResponse?.graph_data?.graph_data;
|
||||
const userStats = homeResponse?.user_statistics;
|
||||
|
||||
if (homeLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
@@ -223,7 +229,7 @@ export default function HomePage() {
|
||||
|
||||
{/* Streak Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{streaks?.map((streak: any, index) => (
|
||||
{streaks?.map((streak: UserStreak, index: number) => (
|
||||
<StreakCard
|
||||
key={index}
|
||||
window={streak.window as 'DAY' | 'WEEK'}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, FormEvent, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -12,8 +13,17 @@ export default function LoginPage() {
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled =
|
||||
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
|
||||
? infoData.data.registration_enabled
|
||||
: false;
|
||||
|
||||
// Redirect to home if already logged in
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
@@ -76,7 +86,15 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
<div className="py-12 text-center">
|
||||
<p className="mt-4">
|
||||
{registrationEnabled && (
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-semibold underline">
|
||||
Register here.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<p className={registrationEnabled ? 'mt-4' : ''}>
|
||||
<a href="/local" className="font-semibold underline">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
@@ -84,7 +102,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-fader relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
|
||||
<span className="text-gray-500">AnthoLume</span>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
|
||||
export default function ProgressPage() {
|
||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||
const progress = data?.data?.progress;
|
||||
const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_: any, row: any) => (
|
||||
render: (_value: Progress['document_id'], row: Progress) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -22,17 +23,18 @@ export default function ProgressPage() {
|
||||
{
|
||||
key: 'device_name' as const,
|
||||
header: 'Device Name',
|
||||
render: (value: any) => value || 'Unknown',
|
||||
render: (value: Progress['device_name']) => value || 'Unknown',
|
||||
},
|
||||
{
|
||||
key: 'percentage' as const,
|
||||
header: 'Percentage',
|
||||
render: (value: any) => (value ? `${Math.round(value)}%` : '0%'),
|
||||
render: (value: Progress['percentage']) => (value ? `${Math.round(value)}%` : '0%'),
|
||||
},
|
||||
{
|
||||
key: 'created_at' as const,
|
||||
header: 'Created At',
|
||||
render: (value: any) => (value ? new Date(value).toLocaleDateString() : 'N/A'),
|
||||
render: (value: Progress['created_at']) =>
|
||||
value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
116
frontend/src/pages/RegisterPage.tsx
Normal file
116
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, FormEvent, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { register, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled =
|
||||
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
|
||||
? infoData.data.registration_enabled
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLoadingInfo && !registrationEnabled) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await register(username, password);
|
||||
} catch (_err) {
|
||||
showError(registrationEnabled ? 'Registration failed' : 'Registration is disabled');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
<p className="text-center text-3xl">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-none focus:ring-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Registering...' : 'Register'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="py-12 text-center">
|
||||
<p>
|
||||
Trying to login?{' '}
|
||||
<Link to="/login" className="font-semibold underline">
|
||||
Login here.
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<a href="/local" className="font-semibold underline">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
|
||||
<span className="text-gray-500">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import type { SearchItem } from '../generated/model';
|
||||
import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
@@ -9,7 +10,7 @@ export default function SearchPage() {
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
|
||||
const { data, isLoading } = useGetSearch({ query, source });
|
||||
const results = data?.data?.results;
|
||||
const results = data?.status === 200 ? data.data.results : [];
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -97,7 +98,7 @@ export default function SearchPage() {
|
||||
)}
|
||||
{!isLoading &&
|
||||
results &&
|
||||
results.map((item: any) => (
|
||||
results.map((item: SearchItem) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
||||
<button className="hover:text-purple-600" title="Download">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||
import type { Device, SettingsResponse } from '../generated/model';
|
||||
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = data;
|
||||
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -15,8 +17,8 @@ export default function SettingsPage() {
|
||||
const [timezone, setTimezone] = useState('UTC');
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData?.data.timezone && settingsData.data.timezone.trim() !== '') {
|
||||
setTimezone(settingsData.data.timezone);
|
||||
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
|
||||
setTimezone(settingsData.timezone);
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
@@ -38,11 +40,8 @@ export default function SettingsPage() {
|
||||
showInfo('Password updated successfully');
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
} catch (error: any) {
|
||||
showError(
|
||||
'Failed to update password: ' +
|
||||
(error.response?.data?.message || error.message || 'Unknown error')
|
||||
);
|
||||
} catch (error) {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,11 +55,8 @@ export default function SettingsPage() {
|
||||
},
|
||||
});
|
||||
showInfo('Timezone updated successfully');
|
||||
} catch (error: any) {
|
||||
showError(
|
||||
'Failed to update timezone: ' +
|
||||
(error.response?.data?.message || error.message || 'Unknown error')
|
||||
);
|
||||
} catch (error) {
|
||||
showError('Failed to update timezone: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,7 +105,7 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white">
|
||||
<UserIcon size={60} />
|
||||
<p className="text-lg">{settingsData?.data.user.username || 'N/A'}</p>
|
||||
<p className="text-lg">{settingsData?.user.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,14 +201,14 @@ export default function SettingsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
{!settingsData?.data.devices || settingsData.data.devices.length === 0 ? (
|
||||
{!settingsData?.devices || settingsData.devices.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={3}>
|
||||
No Results
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
settingsData.data.devices.map((device: any) => (
|
||||
settingsData.devices.map((device: Device) => (
|
||||
<tr key={device.id}>
|
||||
<td className="p-3 pl-0">
|
||||
<p>{device.device_name || 'Unknown'}</p>
|
||||
|
||||
27
frontend/src/utils/errors.ts
Normal file
27
frontend/src/utils/errors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const errorWithResponse = error as {
|
||||
message?: unknown;
|
||||
response?: {
|
||||
data?: {
|
||||
message?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const responseMessage = errorWithResponse.response?.data?.message;
|
||||
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
if (typeof errorWithResponse.message === 'string' && errorWithResponse.message.trim() !== '') {
|
||||
return errorWithResponse.message;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user