This commit is contained in:
2026-03-16 11:00:16 -04:00
parent 7c47f2d2eb
commit b1b8eb297e
45 changed files with 3701 additions and 3736 deletions

View File

@@ -9,10 +9,8 @@ import eslintConfigPrettier from "eslint-config-prettier";
export default [ export default [
js.configs.recommended, js.configs.recommended,
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat.recommended,
{ {
files: ["**/*.ts", "**/*.tsx", "**/*.css"], files: ["**/*.ts", "**/*.tsx"],
ignores: ["**/generated/**"], ignores: ["**/generated/**"],
languageOptions: { languageOptions: {
parser: typescriptParser, parser: typescriptParser,
@@ -22,10 +20,36 @@ export default [
ecmaFeatures: { ecmaFeatures: {
jsx: true, jsx: true,
}, },
projectService: true,
},
globals: {
localStorage: "readonly",
sessionStorage: "readonly",
document: "readonly",
window: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
HTMLElement: "readonly",
HTMLDivElement: "readonly",
HTMLButtonElement: "readonly",
HTMLAnchorElement: "readonly",
MouseEvent: "readonly",
Node: "readonly",
File: "readonly",
Blob: "readonly",
FormData: "readonly",
alert: "readonly",
confirm: "readonly",
prompt: "readonly",
React: "readonly",
}, },
}, },
plugins: { plugins: {
"@typescript-eslint": typescriptPlugin, "@typescript-eslint": typescriptPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
tailwindcss, tailwindcss,
prettier, prettier,
}, },
@@ -35,11 +59,19 @@ export default [
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/prop-types": "off", "react/prop-types": "off",
"no-console": ["warn", { allow: ["warn", "error"] }], "no-console": ["warn", { allow: ["warn", "error"] }],
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",
{ argsIgnorePattern: "^_" }, {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
], ],
"no-useless-catch": "off",
}, },
settings: { settings: {
react: { react: {

File diff suppressed because it is too large Load Diff

View File

@@ -24,21 +24,22 @@
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.8", "@types/react-dom": "^19.0.8",
"@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.57.0", "@typescript-eslint/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.39.4", "eslint": "^9.17.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.5.5", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"orval": "^7.5.0", "orval": "^8.5.3",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.8.1", "prettier": "^3.3.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5" "vite": "^6.0.5"

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1'; import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1';
@@ -9,7 +9,7 @@ interface AuthState {
} }
interface AuthContextType extends AuthState { interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<void>; login: (_username: string, _password: string) => Promise<void>;
logout: () => void; logout: () => void;
} }
@@ -32,50 +32,56 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Update auth state based on /me endpoint response // Update auth state based on /me endpoint response
useEffect(() => { useEffect(() => {
if (meLoading) { setAuthState(prev => {
// Still checking authentication if (meLoading) {
setAuthState((prev) => ({ ...prev, isCheckingAuth: true })); // Still checking authentication
} else if (meData?.data) { return { ...prev, isCheckingAuth: true };
// User is authenticated } else if (meData?.data) {
setAuthState({ // User is authenticated
isAuthenticated: true, return {
user: meData.data, isAuthenticated: true,
isCheckingAuth: false, user: meData.data,
}); isCheckingAuth: false,
} else if (meError) { };
// User is not authenticated or error occurred } else if (meError) {
setAuthState({ // User is not authenticated or error occurred
isAuthenticated: false, return {
user: null, isAuthenticated: false,
isCheckingAuth: false, user: null,
}); isCheckingAuth: false,
} };
}
return prev;
});
}, [meData, meError, meLoading]); }, [meData, meError, meLoading]);
const login = async (username: string, password: string) => { const login = useCallback(
try { async (username: string, password: string) => {
const response = await loginMutation.mutateAsync({ try {
data: { const response = await loginMutation.mutateAsync({
username, data: {
password, username,
}, password,
}); },
});
// The backend uses session-based authentication, so no token to store // The backend uses session-based authentication, so no token to store
// The session cookie is automatically set by the browser // The session cookie is automatically set by the browser
setAuthState({ setAuthState({
isAuthenticated: true, isAuthenticated: true,
user: response.data, user: response.data,
isCheckingAuth: false, isCheckingAuth: false,
}); });
navigate('/'); navigate('/');
} catch (err) { } catch (_error) {
throw new Error('Login failed'); throw new Error('Login failed');
} }
}; },
[loginMutation, navigate]
);
const logout = () => { const logout = useCallback(() => {
logoutMutation.mutate(undefined, { logoutMutation.mutate(undefined, {
onSuccess: () => { onSuccess: () => {
setAuthState({ setAuthState({
@@ -86,12 +92,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
navigate('/login'); navigate('/login');
}, },
}); });
}; }, [logoutMutation, navigate]);
return ( return (
<AuthContext.Provider value={{ ...authState, login, logout }}> <AuthContext.Provider value={{ ...authState, login, logout }}>{children}</AuthContext.Provider>
{children}
</AuthContext.Provider>
); );
} }

View File

@@ -4,24 +4,24 @@ const TOKEN_KEY = 'antholume_token';
// Request interceptor to add auth token to requests // Request interceptor to add auth token to requests
axios.interceptors.request.use( axios.interceptors.request.use(
(config) => { config => {
const token = localStorage.getItem(TOKEN_KEY); const token = localStorage.getItem(TOKEN_KEY);
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;
}, },
(error) => { error => {
return Promise.reject(error); return Promise.reject(error);
} }
); );
// Response interceptor to handle auth errors // Response interceptor to handle auth errors
axios.interceptors.response.use( axios.interceptors.response.use(
(response) => { response => {
return response; return response;
}, },
(error) => { error => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Clear token on auth failure // Clear token on auth failure
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);

View File

@@ -10,7 +10,8 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }; type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass = 'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white'; const baseClass =
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white';
if (variant === 'secondary') { if (variant === 'secondary') {
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`; return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white`;
@@ -22,11 +23,7 @@ const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'default', children, className = '', ...props }, ref) => { ({ variant = 'default', children, className = '', ...props }, ref) => {
return ( return (
<button <button ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
ref={ref}
className={`${getVariantClasses(variant)} ${className}`.trim()}
{...props}
>
{children} {children}
</button> </button>
); );
@@ -38,11 +35,7 @@ Button.displayName = 'Button';
export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>( export const ButtonLink = forwardRef<HTMLAnchorElement, LinkProps>(
({ variant = 'default', children, className = '', ...props }, ref) => { ({ variant = 'default', children, className = '', ...props }, ref) => {
return ( return (
<a <a ref={ref} className={`${getVariantClasses(variant)} ${className}`.trim()} {...props}>
ref={ref}
className={`${getVariantClasses(variant)} ${className}`.trim()}
{...props}
>
{children} {children}
</a> </a>
); );

View File

@@ -44,32 +44,35 @@ export default function HamburgerMenu() {
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden" className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
id="mobile-nav-checkbox" id="mobile-nav-checkbox"
checked={isOpen} checked={isOpen}
onChange={(e) => setIsOpen(e.target.checked)} onChange={e => setIsOpen(e.target.checked)}
/> />
{/* Hamburger icon lines with CSS animations - hidden on desktop */} {/* Hamburger icon lines with CSS animations - hidden on desktop */}
<span <span
className="transition-background z-40 mt-0.5 h-0.5 w-7 bg-black transition-opacity transition-transform duration-500 lg:hidden dark:bg-white" className="z-40 mt-0.5 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
style={{ style={{
transformOrigin: '5px 0px', 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', 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', transform: isOpen ? 'rotate(45deg) translate(2px, -2px)' : 'none',
}} }}
/> />
<span <span
className="transition-background z-40 mt-1 h-0.5 w-7 bg-black transition-opacity transition-transform duration-500 lg:hidden dark:bg-white" className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
style={{ style={{
transformOrigin: '0% 100%', 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', 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, opacity: isOpen ? 0 : 1,
transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none', transform: isOpen ? 'rotate(0deg) scale(0.2, 0.2)' : 'none',
}} }}
/> />
<span <span
className="transition-background z-40 mt-1 h-0.5 w-7 bg-black transition-opacity transition-transform duration-500 lg:hidden dark:bg-white" className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
style={{ style={{
transformOrigin: '0% 0%', 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', 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', transform: isOpen ? 'rotate(-45deg) translate(0, 6px)' : 'none',
}} }}
/> />
@@ -102,7 +105,7 @@ export default function HamburgerMenu() {
</p> </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}
@@ -120,11 +123,13 @@ export default function HamburgerMenu() {
{/* Admin section - only visible for admins */} {/* Admin section - only visible for admins */}
{isAdmin && ( {isAdmin && (
<div className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${ <div
hasPrefix(location.pathname, '/admin') className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
? 'border-purple-500 dark:text-white' hasPrefix(location.pathname, '/admin')
: 'border-transparent text-gray-400' ? 'border-purple-500 dark:text-white'
}`}> : 'border-transparent text-gray-400'
}`}
>
{/* Admin header - always shown */} {/* Admin header - always shown */}
<Link <Link
to="/admin" to="/admin"
@@ -141,7 +146,7 @@ export default function HamburgerMenu() {
{hasPrefix(location.pathname, '/admin') && ( {hasPrefix(location.pathname, '/admin') && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{adminSubItems.map((item) => ( {adminSubItems.map(item => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
@@ -164,7 +169,8 @@ export default function HamburgerMenu() {
<a <a
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-black dark:text-white" className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-black dark:text-white"
target="_blank" target="_blank"
href="https://gitea.va.reichard.io/evan/AnthoLume" rel="noreferrer" href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer"
> >
<span className="text-xs">v1.0.0</span> <span className="text-xs">v1.0.0</span>
</a> </a>

View File

@@ -41,7 +41,8 @@ export default function Layout() {
{ path: '/search', title: 'Search' }, { path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' }, { path: '/settings', title: 'Settings' },
]; ];
const currentPageTitle = navItems.find(item => location.pathname === item.path)?.title || 'Documents'; const currentPageTitle =
navItems.find(item => location.pathname === item.path)?.title || 'Documents';
// Show loading while checking authentication status // Show loading while checking authentication status
if (isCheckingAuth) { if (isCheckingAuth) {
@@ -61,12 +62,13 @@ export default function Layout() {
<HamburgerMenu /> <HamburgerMenu />
{/* Header Title */} {/* Header Title */}
<h1 className="px-6 text-xl font-bold lg:ml-44 dark:text-white"> <h1 className="px-6 text-xl font-bold lg:ml-44 dark:text-white">{currentPageTitle}</h1>
{currentPageTitle}
</h1>
{/* User Dropdown */} {/* User Dropdown */}
<div className="relative flex w-full items-center justify-end space-x-4 p-4" ref={dropdownRef}> <div
className="relative flex w-full items-center justify-end space-x-4 p-4"
ref={dropdownRef}
>
<button <button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="relative block text-gray-800 dark:text-gray-200" className="relative block text-gray-800 dark:text-gray-200"
@@ -86,7 +88,7 @@ export default function Layout() {
<Link <Link
to="/settings" to="/settings"
onClick={() => setIsUserDropdownOpen(false)} onClick={() => setIsUserDropdownOpen(false)}
className="text-md block px-4 py-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" className="block px-4 py-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem" role="menuitem"
> >
<span className="flex flex-col"> <span className="flex flex-col">
@@ -95,7 +97,7 @@ export default function Layout() {
</Link> </Link>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-md block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" className="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem" role="menuitem"
> >
<span className="flex flex-col"> <span className="flex flex-col">
@@ -109,10 +111,13 @@ export default function Layout() {
<button <button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="text-md flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white" className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
> >
<span>{userData?.username || 'User'}</span> <span>{userData?.username || 'User'}</span>
<span className="text-gray-800 transition-transform duration-200 dark:text-gray-200" style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}> <span
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronDown size={20} /> <ChevronDown size={20} />
</span> </span>
</button> </button>
@@ -120,8 +125,15 @@ export default function Layout() {
</div> </div>
{/* Main Content */} {/* Main Content */}
<main className="relative overflow-hidden" style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}> <main
<div id="container" className="h-dvh overflow-auto px-4 md:px-6 lg:ml-48" style={{ paddingBottom: 'calc(5em + env(safe-area-inset-bottom) * 2)' }}> className="relative overflow-hidden"
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}
>
<div
id="container"
className="h-dvh overflow-auto px-4 md:px-6 lg:ml-48"
style={{ paddingBottom: 'calc(5em + env(safe-area-inset-bottom) * 2)' }}
>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View File

@@ -183,12 +183,7 @@ function DocumentList() {
return <SkeletonTable rows={10} columns={5} />; return <SkeletonTable rows={10} columns={5} />;
} }
return ( return <Table columns={columns} data={data?.documents || []} />;
<Table
columns={columns}
data={data?.documents || []}
/>
);
} }
``` ```

View File

@@ -32,17 +32,13 @@ export function Skeleton({
const style = { const style = {
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined, width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined, height:
height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
}; };
return ( return (
<div <div
className={cn( className={cn(baseClasses, variantClasses[variant], animationClasses[animation], className)}
baseClasses,
variantClasses[variant],
animationClasses[animation],
className
)}
style={style} style={style}
/> />
); );
@@ -61,10 +57,7 @@ export function SkeletonText({ lines = 3, className = '', lineClassName = '' }:
<Skeleton <Skeleton
key={i} key={i}
variant="text" variant="text"
className={cn( className={cn(lineClassName, i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')}
lineClassName,
i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full'
)}
/> />
))} ))}
</div> </div>
@@ -85,14 +78,7 @@ export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarPr
const pixelSize = typeof size === 'number' ? size : sizeMap[size]; const pixelSize = typeof size === 'number' ? size : sizeMap[size];
return ( return <Skeleton variant="circular" width={pixelSize} height={pixelSize} className={className} />;
<Skeleton
variant="circular"
width={pixelSize}
height={pixelSize}
className={className}
/>
);
} }
interface SkeletonCardProps { interface SkeletonCardProps {
@@ -111,7 +97,12 @@ export function SkeletonCard({
textLines = 3, textLines = 3,
}: SkeletonCardProps) { }: SkeletonCardProps) {
return ( return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600', className)}> <div
className={cn(
'bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600',
className
)}
>
{showAvatar && ( {showAvatar && (
<div className="mb-4 flex items-start gap-4"> <div className="mb-4 flex items-start gap-4">
<SkeletonAvatar /> <SkeletonAvatar />
@@ -121,12 +112,8 @@ export function SkeletonCard({
</div> </div>
</div> </div>
)} )}
{showTitle && ( {showTitle && <Skeleton variant="text" className="mb-4 h-6 w-1/2" />}
<Skeleton variant="text" className="mb-4 h-6 w-1/2" /> {showText && <SkeletonText lines={textLines} />}
)}
{showText && (
<SkeletonText lines={textLines} />
)}
</div> </div>
); );
} }
@@ -163,7 +150,10 @@ export function SkeletonTable({
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600"> <tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
{Array.from({ length: columns }).map((_, colIndex) => ( {Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3"> <td key={colIndex} className="p-3">
<Skeleton variant="text" className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} /> <Skeleton
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td> </td>
))} ))}
</tr> </tr>
@@ -220,11 +210,12 @@ export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps)
return ( return (
<div className={cn('flex items-center justify-center', className)}> <div className={cn('flex items-center justify-center', className)}>
<div className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-blue-500 dark:border-gray-600`} /> <div
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-blue-500 dark:border-gray-600`}
/>
</div> </div>
); );
} }
// Re-export SkeletonTable for backward compatibility // Re-export SkeletonTable for backward compatibility
export { SkeletonTable as SkeletonTableExport }; export { SkeletonTable as SkeletonTableExport };

View File

@@ -5,7 +5,7 @@ import { cn } from '../utils/cn';
export interface Column<T> { export interface Column<T> {
key: keyof T; key: keyof T;
header: string; header: string;
render?: (value: any, row: T, index: number) => React.ReactNode; render?: (value: any, _row: T, _index: number) => React.ReactNode;
className?: string; className?: string;
} }
@@ -17,6 +17,47 @@ export interface TableProps<T> {
rowKey?: keyof T | ((row: T) => string); rowKey?: keyof T | ((row: T) => string);
} }
// Skeleton table component for loading state
function SkeletonTable({
rows = 5,
columns = 4,
className = '',
}: {
rows?: number;
columns?: number;
className?: string;
}) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b dark:border-gray-600">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
variant="text"
className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function Table<T extends Record<string, any>>({ export function Table<T extends Record<string, any>>({
columns, columns,
data, data,
@@ -24,50 +65,18 @@ export function Table<T extends Record<string, any>>({
emptyMessage = 'No Results', emptyMessage = 'No Results',
rowKey, rowKey,
}: TableProps<T>) { }: TableProps<T>) {
const getRowKey = (row: T, index: number): string => { const getRowKey = (_row: T, index: number): string => {
if (typeof rowKey === 'function') { if (typeof rowKey === 'function') {
return rowKey(row); return rowKey(_row);
} }
if (rowKey) { if (rowKey) {
return String(row[rowKey] ?? index); return String(_row[rowKey] ?? index);
} }
return `row-${index}`; return `row-${index}`;
}; };
// Skeleton table component for loading state
function SkeletonTable({ rows = 5, columns = 4, className = '' }: { rows?: number; columns?: number; className?: string }) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b dark:border-gray-600">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton variant="text" className={colIndex === columns - 1 ? 'w-1/2' : 'w-full'} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (loading) { if (loading) {
return ( return <SkeletonTable rows={5} columns={columns.length} />;
<SkeletonTable rows={5} columns={columns.length} />
);
} }
return ( return (
@@ -76,7 +85,7 @@ export function Table<T extends Record<string, any>>({
<table className="min-w-full bg-white dark:bg-gray-700"> <table className="min-w-full bg-white dark:bg-gray-700">
<thead> <thead>
<tr className="border-b dark:border-gray-600"> <tr className="border-b dark:border-gray-600">
{columns.map((column) => ( {columns.map(column => (
<th <th
key={String(column.key)} key={String(column.key)}
className={`p-3 text-left text-gray-500 dark:text-white ${column.className || ''}`} className={`p-3 text-left text-gray-500 dark:text-white ${column.className || ''}`}
@@ -98,18 +107,13 @@ export function Table<T extends Record<string, any>>({
</tr> </tr>
) : ( ) : (
data.map((row, index) => ( data.map((row, index) => (
<tr <tr key={getRowKey(row, index)} className="border-b dark:border-gray-600">
key={getRowKey(row, index)} {columns.map(column => (
className="border-b dark:border-gray-600"
>
{columns.map((column) => (
<td <td
key={`${getRowKey(row, index)}-${String(column.key)}`} key={`${getRowKey(row, index)}-${String(column.key)}`}
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`} className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
> >
{column.render {column.render ? column.render(row[column.key], row, index) : row[column.key]}
? column.render(row[column.key], row, index)
: row[column.key]}
</td> </td>
))} ))}
</tr> </tr>

View File

@@ -11,8 +11,9 @@ export interface ToastProps {
onClose?: (id: string) => void; onClose?: (id: string) => void;
} }
const getToastStyles = (type: ToastType) => { const getToastStyles = (_type: ToastType) => {
const baseStyles = 'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300'; const baseStyles =
'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300';
const typeStyles = { const typeStyles = {
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400', info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
@@ -69,15 +70,11 @@ export function Toast({ id, type, message, duration = 5000, onClose }: ToastProp
return ( return (
<div <div
className={`${baseStyles} ${typeStyles[type]} ${ className={`${baseStyles} ${typeStyles[type]} ${
isAnimatingOut isAnimatingOut ? 'translate-x-full opacity-0' : 'animate-slideInRight opacity-100'
? 'translate-x-full opacity-0'
: 'animate-slideInRight opacity-100'
}`} }`}
> >
{icons[type]} {icons[type]}
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}> <p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>{message}</p>
{message}
</p>
<button <button
onClick={handleClose} onClick={handleClose}
className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`} className={`ml-2 opacity-70 transition-opacity hover:opacity-100 ${textStyles[type]}`}

View File

@@ -16,33 +16,50 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]); const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
const removeToast = useCallback((id: string) => { const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id)); setToasts(prev => prev.filter(toast => toast.id !== id));
}, []); }, []);
const showToast = useCallback((message: string, type: ToastType = 'info', duration?: number): string => { const showToast = useCallback(
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; (message: string, _type: ToastType = 'info', _duration?: number): string => {
setToasts((prev) => [...prev, { id, type, message, duration, onClose: removeToast }]); const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return id; setToasts(prev => [
}, [removeToast]); ...prev,
{ id, type: _type, message, duration: _duration, onClose: removeToast },
]);
return id;
},
[removeToast]
);
const showInfo = useCallback((message: string, duration?: number) => { const showInfo = useCallback(
return showToast(message, 'info', duration); (message: string, _duration?: number) => {
}, [showToast]); return showToast(message, 'info', _duration);
},
[showToast]
);
const showWarning = useCallback((message: string, duration?: number) => { const showWarning = useCallback(
return showToast(message, 'warning', duration); (message: string, _duration?: number) => {
}, [showToast]); return showToast(message, 'warning', _duration);
},
[showToast]
);
const showError = useCallback((message: string, duration?: number) => { const showError = useCallback(
return showToast(message, 'error', duration); (message: string, _duration?: number) => {
}, [showToast]); return showToast(message, 'error', _duration);
},
[showToast]
);
const clearToasts = useCallback(() => { const clearToasts = useCallback(() => {
setToasts([]); setToasts([]);
}, []); }, []);
return ( return (
<ToastContext.Provider value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}> <ToastContext.Provider
value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}
>
{children} {children}
<ToastContainer toasts={toasts} /> <ToastContainer toasts={toasts} />
</ToastContext.Provider> </ToastContext.Provider>
@@ -61,7 +78,7 @@ function ToastContainer({ toasts }: ToastContainerProps) {
return ( return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2"> <div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
<div className="pointer-events-auto"> <div className="pointer-events-auto">
{toasts.map((toast) => ( {toasts.map(toast => (
<Toast key={toast.id} {...toast} /> <Toast key={toast.id} {...toast} />
))} ))}
</div> </div>

View File

@@ -12,5 +12,5 @@ export {
SkeletonTable, SkeletonTable,
SkeletonButton, SkeletonButton,
PageLoader, PageLoader,
InlineLoader InlineLoader,
} from './Skeleton'; } from './Skeleton';

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export type BackupType = typeof BackupType[keyof typeof BackupType]; export type BackupType = (typeof BackupType)[keyof typeof BackupType];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const BackupType = { export const BackupType = {

View File

@@ -7,8 +7,8 @@
*/ */
export type GetActivityParams = { export type GetActivityParams = {
doc_filter?: boolean; doc_filter?: boolean;
document_id?: string; document_id?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
}; };

View File

@@ -7,7 +7,7 @@
*/ */
export type GetDocumentsParams = { export type GetDocumentsParams = {
page?: number; page?: number;
limit?: number; limit?: number;
search?: string; search?: string;
}; };

View File

@@ -7,6 +7,6 @@
*/ */
export type GetImportDirectoryParams = { export type GetImportDirectoryParams = {
directory?: string; directory?: string;
select?: string; select?: string;
}; };

View File

@@ -7,5 +7,5 @@
*/ */
export type GetLogsParams = { export type GetLogsParams = {
filter?: string; filter?: string;
}; };

View File

@@ -7,7 +7,7 @@
*/ */
export type GetProgressListParams = { export type GetProgressListParams = {
page?: number; page?: number;
limit?: number; limit?: number;
document?: string; document?: string;
}; };

View File

@@ -8,6 +8,6 @@
import type { GetSearchSource } from './getSearchSource'; import type { GetSearchSource } from './getSearchSource';
export type GetSearchParams = { export type GetSearchParams = {
query: string; query: string;
source: GetSearchSource; source: GetSearchSource;
}; };

View File

@@ -6,8 +6,7 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export type GetSearchSource = typeof GetSearchSource[keyof typeof GetSearchSource]; export type GetSearchSource = (typeof GetSearchSource)[keyof typeof GetSearchSource];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetSearchSource = { export const GetSearchSource = {

View File

@@ -6,8 +6,7 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export type ImportResultStatus = typeof ImportResultStatus[keyof typeof ImportResultStatus]; export type ImportResultStatus = (typeof ImportResultStatus)[keyof typeof ImportResultStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const ImportResultStatus = { export const ImportResultStatus = {

View File

@@ -6,8 +6,7 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export type ImportType = typeof ImportType[keyof typeof ImportType]; export type ImportType = (typeof ImportType)[keyof typeof ImportType];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const ImportType = { export const ImportType = {

View File

@@ -6,8 +6,7 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export type OperationType = typeof OperationType[keyof typeof OperationType]; export type OperationType = (typeof OperationType)[keyof typeof OperationType];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const OperationType = { export const OperationType = {

View File

@@ -6,8 +6,8 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export type PostAdminActionBodyAction = typeof PostAdminActionBodyAction[keyof typeof PostAdminActionBodyAction]; export type PostAdminActionBodyAction =
(typeof PostAdminActionBodyAction)[keyof typeof PostAdminActionBodyAction];
// eslint-disable-next-line @typescript-eslint/no-redeclare // eslint-disable-next-line @typescript-eslint/no-redeclare
export const PostAdminActionBodyAction = { export const PostAdminActionBodyAction = {

View File

@@ -11,8 +11,7 @@ body {
html { html {
height: calc(100% + env(safe-area-inset-bottom)); height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
env(safe-area-inset-left);
} }
main { main {

View File

@@ -42,14 +42,14 @@ export default function AdminImportPage() {
}, },
}, },
{ {
onSuccess: (response) => { onSuccess: _response => {
showInfo('Import completed successfully'); showInfo('Import completed successfully');
// Redirect to import results page after a short delay // Redirect to import results page after a short delay
setTimeout(() => { setTimeout(() => {
window.location.href = '/admin/import-results'; window.location.href = '/admin/import-results';
}, 1500); }, 1500);
}, },
onError: (error) => { onError: error => {
showError('Import failed: ' + (error as any).message); showError('Import failed: ' + (error as any).message);
}, },
} }
@@ -68,19 +68,13 @@ export default function AdminImportPage() {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<div <div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white" <p className="text-lg font-semibold text-gray-500">Selected Import Directory</p>
>
<p className="text-lg font-semibold text-gray-500">
Selected Import Directory
</p>
<form className="flex flex-col gap-4" onSubmit={handleImport}> <form className="flex flex-col gap-4" onSubmit={handleImport}>
<div className="flex w-full justify-between gap-4"> <div className="flex w-full justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<FolderOpen size={20} /> <FolderOpen size={20} />
<p className="break-all text-lg font-medium"> <p className="break-all text-lg font-medium">{selectedDirectory}</p>
{selectedDirectory}
</p>
</div> </div>
<div className="mr-4 flex flex-col justify-around gap-2"> <div className="mr-4 flex flex-col justify-around gap-2">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
@@ -126,17 +120,11 @@ export default function AdminImportPage() {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table <table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700"
>
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th <th className="w-12 border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800"></th>
className="w-12 border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800" <th className="break-all border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800">
></th>
<th
className="break-all border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800"
>
{currentPath} {currentPath}
</th> </th>
</tr> </tr>
@@ -144,9 +132,7 @@ export default function AdminImportPage() {
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
{currentPath !== '/' && ( {currentPath !== '/' && (
<tr> <tr>
<td <td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"></td>
className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"
></td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-gray-200 p-3">
<button onClick={handleNavigateUp}> <button onClick={handleNavigateUp}>
<p>../</p> <p>../</p>
@@ -156,14 +142,14 @@ export default function AdminImportPage() {
)} )}
{directories.length === 0 ? ( {directories.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={2}>No Folders</td> <td className="p-3 text-center" colSpan={2}>
No Folders
</td>
</tr> </tr>
) : ( ) : (
directories.map((item) => ( directories.map(item => (
<tr key={item.name}> <tr key={item.name}>
<td <td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"
>
<button onClick={() => item.name && handleSelectDirectory(item.name)}> <button onClick={() => item.name && handleSelectDirectory(item.name)}>
<FolderOpen size={20} /> <FolderOpen size={20} />
</button> </button>

View File

@@ -13,24 +13,16 @@ export default function AdminImportResultsPage() {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table <table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700"
>
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Document Document
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Status Status
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Error Error
</th> </th>
</tr> </tr>
@@ -38,7 +30,9 @@ export default function AdminImportResultsPage() {
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
{results.length === 0 ? ( {results.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={3}>No Results</td> <td className="p-3 text-center" colSpan={3}>
No Results
</td>
</tr> </tr>
) : ( ) : (
results.map((result: ImportResult, index: number) => ( results.map((result: ImportResult, index: number) => (

View File

@@ -6,9 +6,7 @@ import { Search } from 'lucide-react';
export default function AdminLogsPage() { export default function AdminLogsPage() {
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const { data: logsData, isLoading, refetch } = useGetLogs( const { data: logsData, isLoading, refetch } = useGetLogs(filter ? { filter } : {});
filter ? { filter } : {}
);
const logs = logsData?.data?.logs || []; const logs = logsData?.data?.logs || [];
@@ -24,28 +22,26 @@ export default function AdminLogsPage() {
return ( return (
<div> <div>
{/* Filter Form */} {/* Filter Form */}
<div <div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleFilterSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Search size={15} /> <Search size={15} />
</span> </span>
<input <input
type="text" type="text"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={e => setFilter(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-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" className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-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="JQ Filter" placeholder="JQ Filter"
/> />
</div> </div>
</div> </div>
<div className="lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">Filter</Button> <Button variant="secondary" type="submit">
Filter
</Button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -33,18 +33,21 @@ export default function AdminPage() {
}, },
}, },
{ {
onSuccess: (response) => { onSuccess: response => {
// Handle file download // Handle file download
const url = window.URL.createObjectURL(new Blob([response.data])); const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.setAttribute('download', `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`); link.setAttribute(
'download',
`AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`
);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
showInfo('Backup completed successfully'); showInfo('Backup completed successfully');
}, },
onError: (error) => { onError: error => {
showError('Backup failed: ' + (error as any).message); showError('Backup failed: ' + (error as any).message);
}, },
} }
@@ -67,7 +70,7 @@ export default function AdminPage() {
onSuccess: () => { onSuccess: () => {
showInfo('Restore completed successfully'); showInfo('Restore completed successfully');
}, },
onError: (error) => { onError: error => {
showError('Restore failed: ' + (error as any).message); showError('Restore failed: ' + (error as any).message);
}, },
} }
@@ -85,7 +88,7 @@ export default function AdminPage() {
onSuccess: () => { onSuccess: () => {
showInfo('Metadata matching started'); showInfo('Metadata matching started');
}, },
onError: (error) => { onError: error => {
showError('Metadata matching failed: ' + (error as any).message); showError('Metadata matching failed: ' + (error as any).message);
}, },
} }
@@ -103,7 +106,7 @@ export default function AdminPage() {
onSuccess: () => { onSuccess: () => {
showInfo('Cache tables started'); showInfo('Cache tables started');
}, },
onError: (error) => { onError: error => {
showError('Cache tables failed: ' + (error as any).message); showError('Cache tables failed: ' + (error as any).message);
}, },
} }
@@ -117,9 +120,7 @@ export default function AdminPage() {
return ( return (
<div className="flex w-full grow flex-col gap-4"> <div className="flex w-full grow flex-col gap-4">
{/* Backup & Restore Card */} {/* Backup & Restore Card */}
<div <div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<p className="mb-2 text-lg font-semibold">Backup & Restore</p> <p className="mb-2 text-lg font-semibold">Backup & Restore</p>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Backup Form */} {/* Backup Form */}
@@ -130,7 +131,7 @@ export default function AdminPage() {
type="checkbox" type="checkbox"
id="backup_covers" id="backup_covers"
checked={backupTypes.covers} checked={backupTypes.covers}
onChange={(e) => setBackupTypes({ ...backupTypes, covers: e.target.checked })} onChange={e => setBackupTypes({ ...backupTypes, covers: e.target.checked })}
/> />
<label htmlFor="backup_covers">Covers</label> <label htmlFor="backup_covers">Covers</label>
</div> </div>
@@ -139,40 +140,39 @@ export default function AdminPage() {
type="checkbox" type="checkbox"
id="backup_documents" id="backup_documents"
checked={backupTypes.documents} checked={backupTypes.documents}
onChange={(e) => setBackupTypes({ ...backupTypes, documents: e.target.checked })} onChange={e => setBackupTypes({ ...backupTypes, documents: e.target.checked })}
/> />
<label htmlFor="backup_documents">Documents</label> <label htmlFor="backup_documents">Documents</label>
</div> </div>
</div> </div>
<div className="h-10 w-40"> <div className="h-10 w-40">
<Button variant="secondary" type="submit">Backup</Button> <Button variant="secondary" type="submit">
Backup
</Button>
</div> </div>
</form> </form>
{/* Restore Form */} {/* Restore Form */}
<form <form onSubmit={handleRestoreSubmit} className="flex grow justify-between">
onSubmit={handleRestoreSubmit}
className="flex grow justify-between"
>
<div className="flex w-1/2 items-center"> <div className="flex w-1/2 items-center">
<input <input
type="file" type="file"
accept=".zip" accept=".zip"
onChange={(e) => setRestoreFile(e.target.files?.[0] || null)} onChange={e => setRestoreFile(e.target.files?.[0] || null)}
className="w-full" className="w-full"
/> />
</div> </div>
<div className="h-10 w-40"> <div className="h-10 w-40">
<Button variant="secondary" type="submit">Restore</Button> <Button variant="secondary" type="submit">
Restore
</Button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{/* Tasks Card */} {/* Tasks Card */}
<div <div className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<p className="text-lg font-semibold">Tasks</p> <p className="text-lg font-semibold">Tasks</p>
<table className="min-w-full bg-white text-sm dark:bg-gray-700"> <table className="min-w-full bg-white text-sm dark:bg-gray-700">
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
@@ -182,7 +182,9 @@ export default function AdminPage() {
</td> </td>
<td className="float-right py-2"> <td className="float-right py-2">
<div className="h-10 w-40 text-base"> <div className="h-10 w-40 text-base">
<Button variant="secondary" onClick={handleMetadataMatch}>Run</Button> <Button variant="secondary" onClick={handleMetadataMatch}>
Run
</Button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -192,7 +194,9 @@ export default function AdminPage() {
</td> </td>
<td className="float-right py-2"> <td className="float-right py-2">
<div className="h-10 w-40 text-base"> <div className="h-10 w-40 text-base">
<Button variant="secondary" onClick={handleCacheTables}>Run</Button> <Button variant="secondary" onClick={handleCacheTables}>
Run
</Button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -118,19 +118,21 @@ export default function AdminUsersPage() {
{/* Add User Form */} {/* Add User Form */}
{showAddForm && ( {showAddForm && (
<div className="absolute left-10 top-10 rounded bg-gray-200 p-3 shadow-lg shadow-gray-500 transition-all duration-200 dark:bg-gray-600 dark:shadow-gray-900"> <div className="absolute left-10 top-10 rounded bg-gray-200 p-3 shadow-lg shadow-gray-500 transition-all duration-200 dark:bg-gray-600 dark:shadow-gray-900">
<form onSubmit={handleCreateUser} <form
className="flex flex-col gap-2 text-sm text-black dark:text-white"> onSubmit={handleCreateUser}
className="flex flex-col gap-2 text-sm text-black dark:text-white"
>
<input <input
type="text" type="text"
value={newUsername} value={newUsername}
onChange={(e) => setNewUsername(e.target.value)} onChange={e => setNewUsername(e.target.value)}
placeholder="Username" placeholder="Username"
className="bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white" className="bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
/> />
<input <input
type="password" type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}
placeholder="Password" placeholder="Password"
className="bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white" className="bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
/> />
@@ -139,7 +141,7 @@ export default function AdminUsersPage() {
type="checkbox" type="checkbox"
id="new_is_admin" id="new_is_admin"
checked={newIsAdmin} checked={newIsAdmin}
onChange={(e) => setNewIsAdmin(e.target.checked)} onChange={e => setNewIsAdmin(e.target.checked)}
/> />
<label htmlFor="new_is_admin">Admin</label> <label htmlFor="new_is_admin">Admin</label>
</div> </div>
@@ -163,21 +165,29 @@ export default function AdminUsersPage() {
<Plus size={20} /> <Plus size={20} />
</button> </button>
</th> </th>
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">User</th> <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">Password</th> User
<th className="border-b border-gray-200 p-3 text-left text-center font-normal uppercase dark:border-gray-800"> </th>
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
Password
</th>
<th className="border-b border-gray-200 p-3 text-center font-normal uppercase dark:border-gray-800">
Permissions Permissions
</th> </th>
<th className="w-48 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">Created</th> <th className="w-48 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
Created
</th>
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
{users.length === 0 ? ( {users.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={5}>No Results</td> <td className="p-3 text-center" colSpan={5}>
No Results
</td>
</tr> </tr>
) : ( ) : (
users.map((user) => ( users.map(user => (
<tr key={user.id}> <tr key={user.id}>
{/* Delete Button */} {/* Delete Button */}
<td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"> <td className="relative cursor-pointer border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">

View File

@@ -8,7 +8,7 @@ import {
SkeletonTable, SkeletonTable,
SkeletonButton, SkeletonButton,
PageLoader, PageLoader,
InlineLoader InlineLoader,
} from '../components/Skeleton'; } from '../components/Skeleton';
export default function ComponentDemoPage() { export default function ComponentDemoPage() {

View File

@@ -62,7 +62,9 @@ export default function DocumentPage() {
const document = docData?.data?.document as Document; const document = docData?.data?.document as Document;
const progressDataArray = progressData?.data?.progress; const progressDataArray = progressData?.data?.progress;
const progress = Array.isArray(progressDataArray) ? progressDataArray[0] as Progress : undefined; const progress = Array.isArray(progressDataArray)
? (progressDataArray[0] as Progress)
: undefined;
if (!document) { if (!document) {
return <div className="text-gray-500 dark:text-white">Document not found</div>; return <div className="text-gray-500 dark:text-white">Document not found</div>;
@@ -75,13 +77,9 @@ export default function DocumentPage() {
return ( return (
<div className="relative size-full"> <div className="relative size-full">
<div <div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white"
>
{/* Document Info - Left Column */} {/* Document Info - Left Column */}
<div <div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80"
>
{/* Cover Image */} {/* Cover Image */}
{document.filepath && ( {document.filepath && (
<div className="h-60 w-full rounded bg-gray-200 object-fill dark:bg-gray-600"> <div className="h-60 w-full rounded bg-gray-200 object-fill dark:bg-gray-600">
@@ -124,7 +122,12 @@ export default function DocumentPage() {
title="Download" title="Download"
> >
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003-3h4a3 3 0 003 3v1m0-3l-3 3m0 0L4 20" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003-3h4a3 3 0 003 3v1m0-3l-3 3m0 0L4 20"
/>
</svg> </svg>
</a> </a>
)} )}
@@ -192,15 +195,11 @@ export default function DocumentPage() {
</div> </div>
<div> <div>
<p className="text-gray-500">Created</p> <p className="text-gray-500">Created</p>
<p className="font-medium"> <p className="font-medium">{new Date(document.created_at).toLocaleDateString()}</p>
{new Date(document.created_at).toLocaleDateString()}
</p>
</div> </div>
<div> <div>
<p className="text-gray-500">Updated</p> <p className="text-gray-500">Updated</p>
<p className="font-medium"> <p className="font-medium">{new Date(document.updated_at).toLocaleDateString()}</p>
{new Date(document.updated_at).toLocaleDateString()}
</p>
</div> </div>
</div> </div>
@@ -213,9 +212,7 @@ export default function DocumentPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-gray-500">Est. Time Left:</p> <p className="text-gray-500">Est. Time Left:</p>
<p className="whitespace-nowrap font-medium"> <p className="whitespace-nowrap font-medium">{niceSeconds(totalTimeLeftSeconds)}</p>
{niceSeconds(totalTimeLeftSeconds)}
</p>
</div> </div>
</div> </div>
)} )}

View File

@@ -35,9 +35,7 @@ function DocumentCard({ doc }: DocumentCardProps) {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<div <div className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
className="flex size-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700"
>
<div className="relative my-auto h-48 min-w-fit"> <div className="relative my-auto h-48 min-w-fit">
<Link to={`/documents/${doc.id}`}> <Link to={`/documents/${doc.id}`}>
<img <img
@@ -51,13 +49,13 @@ function DocumentCard({ doc }: DocumentCardProps) {
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
<div> <div>
<p className="text-gray-400">Title</p> <p className="text-gray-400">Title</p>
<p className="font-medium">{doc.title || "Unknown"}</p> <p className="font-medium">{doc.title || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
<div> <div>
<p className="text-gray-400">Author</p> <p className="text-gray-400">Author</p>
<p className="font-medium">{doc.author || "Unknown"}</p> <p className="font-medium">{doc.author || 'Unknown'}</p>
</div> </div>
</div> </div>
<div className="inline-flex shrink-0 items-center"> <div className="inline-flex shrink-0 items-center">
@@ -73,9 +71,7 @@ function DocumentCard({ doc }: DocumentCardProps) {
</div> </div>
</div> </div>
</div> </div>
<div <div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400"
>
<Link to={`/activity?document=${doc.id}`}> <Link to={`/activity?document=${doc.id}`}>
<Activity size={20} /> <Activity size={20} />
</Link> </Link>
@@ -92,10 +88,6 @@ function DocumentCard({ doc }: DocumentCardProps) {
); );
} }
export default function DocumentsPage() { export default function DocumentsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -152,21 +144,17 @@ export default function DocumentsPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Search Form */} {/* Search Form */}
<div <div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Search size={15} /> <Search size={15} />
</span> </span>
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-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" className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-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="Search Author / Title" placeholder="Search Author / Title"
name="search" name="search"
@@ -174,7 +162,9 @@ export default function DocumentsPage() {
</div> </div>
</div> </div>
<div className="lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">Search</Button> <Button variant="secondary" type="submit">
Search
</Button>
</div> </div>
</form> </form>
</div> </div>
@@ -207,9 +197,7 @@ export default function DocumentsPage() {
</div> </div>
{/* Upload Button */} {/* Upload Button */}
<div <div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
className="fixed bottom-6 right-6 flex items-center justify-center rounded-full"
>
<input <input
type="checkbox" type="checkbox"
id="upload-file-button" id="upload-file-button"
@@ -220,11 +208,7 @@ export default function DocumentsPage() {
<div <div
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-gray-800 p-4 text-sm text-white transition-opacity duration-200 dark:bg-gray-200 dark:text-black ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`} className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-gray-800 p-4 text-sm text-white transition-opacity duration-200 dark:bg-gray-200 dark:text-black ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
> >
<form <form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
method="POST"
encType="multipart/form-data"
className="flex flex-col gap-2"
>
<input <input
type="file" type="file"
accept=".epub" accept=".epub"
@@ -236,7 +220,7 @@ export default function DocumentsPage() {
<button <button
className="bg-gray-500 px-2 py-1 font-medium text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800" className="bg-gray-500 px-2 py-1 font-medium text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800"
type="submit" type="submit"
onClick={(e) => { onClick={e => {
e.preventDefault(); e.preventDefault();
handleFileChange({ target: { files: fileInputRef.current?.files } } as any); handleFileChange({ target: { files: fileInputRef.current?.files } } as any);
}} }}

View File

@@ -44,7 +44,15 @@ interface StreakCardProps {
maxStreakEndDate: string; maxStreakEndDate: string;
} }
function StreakCard({ window, currentStreak, currentStreakStartDate, currentStreakEndDate, maxStreak, maxStreakStartDate, maxStreakEndDate }: StreakCardProps) { function StreakCard({
window,
currentStreak,
currentStreakStartDate,
currentStreakEndDate,
maxStreak,
maxStreakStartDate,
maxStreakEndDate,
}: StreakCardProps) {
return ( return (
<div className="w-full"> <div className="w-full">
<div className="relative w-full rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700"> <div className="relative w-full rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
@@ -107,7 +115,9 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
{data.all.length === 0 ? ( {data.all.length === 0 ? (
<p className="text-5xl font-bold text-black dark:text-white">N/A</p> <p className="text-5xl font-bold text-black dark:text-white">N/A</p>
) : ( ) : (
<p className="text-5xl font-bold text-black dark:text-white">{data.all[0]?.user_id || 'N/A'}</p> <p className="text-5xl font-bold text-black dark:text-white">
{data.all[0]?.user_id || 'N/A'}
</p>
)} )}
</div> </div>
@@ -186,25 +196,10 @@ export default function HomePage() {
{/* Info Cards */} {/* Info Cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<InfoCard <InfoCard title="Documents" size={dbInfo?.documents_size || 0} link="./documents" />
title="Documents" <InfoCard title="Activity Records" size={dbInfo?.activity_size || 0} link="./activity" />
size={dbInfo?.documents_size || 0} <InfoCard title="Progress Records" size={dbInfo?.progress_size || 0} link="./progress" />
link="./documents" <InfoCard title="Devices" size={dbInfo?.devices_size || 0} />
/>
<InfoCard
title="Activity Records"
size={dbInfo?.activity_size || 0}
link="./activity"
/>
<InfoCard
title="Progress Records"
size={dbInfo?.progress_size || 0}
link="./progress"
/>
<InfoCard
title="Devices"
size={dbInfo?.devices_size || 0}
/>
</div> </div>
{/* Streak Cards */} {/* Streak Cards */}
@@ -227,15 +222,15 @@ export default function HomePage() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<LeaderboardCard <LeaderboardCard
name="WPM" name="WPM"
data={userStats?.wpm || { all: [], year: [], month: [], week: []}} data={userStats?.wpm || { all: [], year: [], month: [], week: [] }}
/> />
<LeaderboardCard <LeaderboardCard
name="Duration" name="Duration"
data={userStats?.duration || { all: [], year: [], month: [], week: []}} data={userStats?.duration || { all: [], year: [], month: [], week: [] }}
/> />
<LeaderboardCard <LeaderboardCard
name="Words" name="Words"
data={userStats?.words || { all: [], year: [], month: [], week: []}} data={userStats?.words || { all: [], year: [], month: [], week: [] }}
/> />
</div> </div>

View File

@@ -26,7 +26,7 @@ export default function LoginPage() {
try { try {
await login(username, password); await login(username, password);
} catch (err) { } catch (_err) {
showError('Invalid credentials'); showError('Invalid credentials');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -37,9 +37,7 @@ export default function LoginPage() {
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white"> <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-wrap">
<div className="flex w-full flex-col md:w-1/2"> <div className="flex w-full flex-col md:w-1/2">
<div <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">
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> <p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}> <form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
<div className="flex flex-col pt-4"> <div className="flex flex-col pt-4">
@@ -47,7 +45,7 @@ export default function LoginPage() {
<input <input
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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" 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" placeholder="Username"
required required
@@ -60,7 +58,7 @@ export default function LoginPage() {
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" 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" placeholder="Password"
required required

View File

@@ -20,35 +20,29 @@ export default function SearchPage() {
<div className="flex w-full flex-col gap-4 md:flex-row"> <div className="flex w-full flex-col gap-4 md:flex-row">
<div className="flex grow flex-col gap-4"> <div className="flex grow flex-col gap-4">
{/* Search Form */} {/* Search Form */}
<div <div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}> <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
<div className="flex w-full grow flex-col"> <div className="flex w-full grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Search size={15} /> <Search size={15} />
</span> </span>
<input <input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={e => setQuery(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" 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="Query" placeholder="Query"
/> />
</div> </div>
</div> </div>
<div className="relative flex min-w-[12em]"> <div className="relative flex min-w-[12em]">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Book size={15} /> <Book size={15} />
</span> </span>
<select <select
value={source} value={source}
onChange={(e) => setSource(e.target.value as GetSearchSource)} onChange={e => setSource(e.target.value as GetSearchSource)}
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" 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"
> >
<option value="LibGen">Library Genesis</option> <option value="LibGen">Library Genesis</option>
@@ -56,44 +50,32 @@ export default function SearchPage() {
</select> </select>
</div> </div>
<div className="lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">Search</Button> <Button variant="secondary" type="submit">
Search
</Button>
</div> </div>
</form> </form>
</div> </div>
{/* Search Results Table */} {/* Search Results Table */}
<div className="inline-block min-w-full overflow-hidden rounded shadow"> <div className="inline-block min-w-full overflow-hidden rounded shadow">
<table <table className="min-w-full bg-white text-sm leading-normal md:text-sm dark:bg-gray-700">
className="min-w-full bg-white text-sm leading-normal md:text-sm dark:bg-gray-700"
>
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th <th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"></th>
className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800" <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
></th>
<th
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Document Document
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Series Series
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Type Type
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Size Size
</th> </th>
<th <th className="hidden border-b border-gray-200 p-3 text-left font-normal uppercase md:block dark:border-gray-800">
className="hidden border-b border-gray-200 p-3 text-left font-normal uppercase md:block dark:border-gray-800"
>
Date Date
</th> </th>
</tr> </tr>
@@ -101,43 +83,44 @@ export default function SearchPage() {
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
{isLoading && ( {isLoading && (
<tr> <tr>
<td className="p-3 text-center" colSpan={6}>Loading...</td> <td className="p-3 text-center" colSpan={6}>
Loading...
</td>
</tr> </tr>
)} )}
{!isLoading && !results && ( {!isLoading && !results && (
<tr> <tr>
<td className="p-3 text-center" colSpan={6}>No Results</td> <td className="p-3 text-center" colSpan={6}>
No Results
</td>
</tr> </tr>
)} )}
{!isLoading && results && results.map((item: any) => ( {!isLoading &&
<tr key={item.id}> results &&
<td results.map((item: any) => (
className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500" <tr key={item.id}>
> <td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
<button <button className="hover:text-purple-600" title="Download">
className="hover:text-purple-600" <Download size={15} />
title="Download" </button>
> </td>
<Download size={15} /> <td className="border-b border-gray-200 p-3">
</button> {item.author || 'N/A'} - {item.title || 'N/A'}
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-gray-200 p-3">
{item.author || 'N/A'} - {item.title || 'N/A'} <p>{item.series || 'N/A'}</p>
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-gray-200 p-3">
<p>{item.series || 'N/A'}</p> <p>{item.file_type || 'N/A'}</p>
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="border-b border-gray-200 p-3">
<p>{item.file_type || 'N/A'}</p> <p>{item.file_size || 'N/A'}</p>
</td> </td>
<td className="border-b border-gray-200 p-3"> <td className="hidden border-b border-gray-200 p-3 md:table-cell">
<p>{item.file_size || 'N/A'}</p> <p>{item.upload_date || 'N/A'}</p>
</td> </td>
<td className="hidden border-b border-gray-200 p-3 md:table-cell"> </tr>
<p>{item.upload_date || 'N/A'}</p> ))}
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -39,7 +39,10 @@ export default function SettingsPage() {
setPassword(''); setPassword('');
setNewPassword(''); setNewPassword('');
} catch (error: any) { } catch (error: any) {
showError('Failed to update password: ' + (error.response?.data?.message || error.message || 'Unknown error')); showError(
'Failed to update password: ' +
(error.response?.data?.message || error.message || 'Unknown error')
);
} }
}; };
@@ -54,7 +57,10 @@ export default function SettingsPage() {
}); });
showInfo('Timezone updated successfully'); showInfo('Timezone updated successfully');
} catch (error: any) { } catch (error: any) {
showError('Failed to update timezone: ' + (error.response?.data?.message || error.message || 'Unknown error')); showError(
'Failed to update timezone: ' +
(error.response?.data?.message || error.message || 'Unknown error')
);
} }
}; };
@@ -101,35 +107,26 @@ export default function SettingsPage() {
<div className="flex w-full flex-col gap-4 md:flex-row"> <div className="flex w-full flex-col gap-4 md:flex-row">
{/* User Profile Card */} {/* User Profile Card */}
<div> <div>
<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">
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"
>
<User size={60} /> <User size={60} />
<p className="text-lg">{settingsData?.data.user.username || "N/A"}</p> <p className="text-lg">{settingsData?.data.user.username || 'N/A'}</p>
</div> </div>
</div> </div>
<div className="flex grow flex-col gap-4"> <div className="flex grow flex-col gap-4">
{/* Change Password Form */} {/* Change Password Form */}
<div <div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<p className="mb-2 text-lg font-semibold">Change Password</p> <p className="mb-2 text-lg font-semibold">Change Password</p>
<form <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
className="flex flex-col gap-4 lg:flex-row"
onSubmit={handlePasswordSubmit}
>
<div className="flex grow flex-col"> <div className="flex grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Lock size={15} /> <Lock size={15} />
</span> </span>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" 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" placeholder="Password"
/> />
@@ -137,44 +134,37 @@ export default function SettingsPage() {
</div> </div>
<div className="flex grow flex-col"> <div className="flex grow flex-col">
<div className="relative flex"> <div className="relative flex">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Lock size={15} /> <Lock size={15} />
</span> </span>
<input <input
type="password" type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={e => setNewPassword(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" 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="New Password" placeholder="New Password"
/> />
</div> </div>
</div> </div>
<div className="lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">Submit</Button> <Button variant="secondary" type="submit">
Submit
</Button>
</div> </div>
</form> </form>
</div> </div>
{/* Change Timezone Form */} {/* Change Timezone Form */}
<div <div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<p className="mb-2 text-lg font-semibold">Change Timezone</p> <p className="mb-2 text-lg font-semibold">Change Timezone</p>
<form <form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
className="flex flex-col gap-4 lg:flex-row"
onSubmit={handleTimezoneSubmit}
>
<div className="relative flex grow"> <div className="relative flex grow">
<span <span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm"
>
<Clock size={15} /> <Clock size={15} />
</span> </span>
<select <select
value={timezone || 'UTC'} value={timezone || 'UTC'}
onChange={(e) => setTimezone(e.target.value)} onChange={e => setTimezone(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" 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"
> >
<option value="UTC">UTC</option> <option value="UTC">UTC</option>
@@ -190,32 +180,26 @@ export default function SettingsPage() {
</select> </select>
</div> </div>
<div className="lg:w-60"> <div className="lg:w-60">
<Button variant="secondary" type="submit">Submit</Button> <Button variant="secondary" type="submit">
Submit
</Button>
</div> </div>
</form> </form>
</div> </div>
{/* Devices Table */} {/* Devices Table */}
<div <div className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white"
>
<p className="text-lg font-semibold">Devices</p> <p className="text-lg font-semibold">Devices</p>
<table className="min-w-full bg-white text-sm dark:bg-gray-700"> <table className="min-w-full bg-white text-sm dark:bg-gray-700">
<thead className="text-gray-800 dark:text-gray-400"> <thead className="text-gray-800 dark:text-gray-400">
<tr> <tr>
<th <th className="border-b border-gray-200 p-3 pl-0 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 pl-0 text-left font-normal uppercase dark:border-gray-800"
>
Name Name
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Last Sync Last Sync
</th> </th>
<th <th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"
>
Created Created
</th> </th>
</tr> </tr>
@@ -223,7 +207,9 @@ export default function SettingsPage() {
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
{!settingsData?.data.devices || settingsData.data.devices.length === 0 ? ( {!settingsData?.data.devices || settingsData.data.devices.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={3}>No Results</td> <td className="p-3 text-center" colSpan={3}>
No Results
</td>
</tr> </tr>
) : ( ) : (
settingsData.data.devices.map((device: any) => ( settingsData.data.devices.map((device: any) => (
@@ -232,10 +218,14 @@ export default function SettingsPage() {
<p>{device.device_name || 'Unknown'}</p> <p>{device.device_name || 'Unknown'}</p>
</td> </td>
<td className="p-3"> <td className="p-3">
<p>{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}</p> <p>
{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}
</p>
</td> </td>
<td className="p-3"> <td className="p-3">
<p>{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}</p> <p>
{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}
</p>
</td> </td>
</tr> </tr>
)) ))