wip 7
This commit is contained in:
208
frontend/src/components/README.md
Normal file
208
frontend/src/components/README.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# UI Components
|
||||
|
||||
This directory contains reusable UI components for the AnthoLume application.
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
### Usage
|
||||
|
||||
The toast system provides info, warning, and error notifications that respect the current theme and dark/light mode.
|
||||
|
||||
```tsx
|
||||
import { useToasts } from './components/ToastContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { showInfo, showWarning, showError, showToast } = useToasts();
|
||||
|
||||
const handleAction = async () => {
|
||||
try {
|
||||
// Do something
|
||||
showInfo('Operation completed successfully!');
|
||||
} catch (error) {
|
||||
showError('An error occurred while processing your request.');
|
||||
}
|
||||
};
|
||||
|
||||
return <button onClick={handleAction}>Click me</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
- `showToast(message: string, type?: 'info' | 'warning' | 'error', duration?: number): string`
|
||||
- Shows a toast notification
|
||||
- Returns the toast ID for manual removal
|
||||
- Default type: 'info'
|
||||
- Default duration: 5000ms (0 = no auto-dismiss)
|
||||
|
||||
- `showInfo(message: string, duration?: number): string`
|
||||
- Shortcut for showing an info toast
|
||||
|
||||
- `showWarning(message: string, duration?: number): string`
|
||||
- Shortcut for showing a warning toast
|
||||
|
||||
- `showError(message: string, duration?: number): string`
|
||||
- Shortcut for showing an error toast
|
||||
|
||||
- `removeToast(id: string): void`
|
||||
- Manually remove a toast by ID
|
||||
|
||||
- `clearToasts(): void`
|
||||
- Clear all active toasts
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
// Info toast (auto-dismisses after 5 seconds)
|
||||
showInfo('Document saved successfully!');
|
||||
|
||||
// Warning toast (auto-dismisses after 10 seconds)
|
||||
showWarning('Low disk space warning', 10000);
|
||||
|
||||
// Error toast (no auto-dismiss)
|
||||
showError('Failed to load data', 0);
|
||||
|
||||
// Generic toast
|
||||
showToast('Custom message', 'warning', 3000);
|
||||
```
|
||||
|
||||
## Skeleton Loading
|
||||
|
||||
### Usage
|
||||
|
||||
Skeleton components provide placeholder content while data is loading. They automatically adapt to dark/light mode.
|
||||
|
||||
### Components
|
||||
|
||||
#### `Skeleton`
|
||||
|
||||
Basic skeleton element with various variants:
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from './components/Skeleton';
|
||||
|
||||
// Default (rounded rectangle)
|
||||
<Skeleton className="w-full h-8" />
|
||||
|
||||
// Text variant
|
||||
<Skeleton variant="text" className="w-3/4" />
|
||||
|
||||
// Circular variant (for avatars)
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
|
||||
// Rectangular variant
|
||||
<Skeleton variant="rectangular" width="100%" height={200} />
|
||||
```
|
||||
|
||||
#### `SkeletonText`
|
||||
|
||||
Multiple lines of text skeleton:
|
||||
|
||||
```tsx
|
||||
<SkeletonText lines={3} />
|
||||
<SkeletonText lines={5} className="max-w-md" />
|
||||
```
|
||||
|
||||
#### `SkeletonAvatar`
|
||||
|
||||
Avatar placeholder:
|
||||
|
||||
```tsx
|
||||
<SkeletonAvatar size="md" />
|
||||
<SkeletonAvatar size={56} />
|
||||
```
|
||||
|
||||
#### `SkeletonCard`
|
||||
|
||||
Card placeholder with optional elements:
|
||||
|
||||
```tsx
|
||||
// Default card
|
||||
<SkeletonCard />
|
||||
|
||||
// With avatar
|
||||
<SkeletonCard showAvatar />
|
||||
|
||||
// Custom configuration
|
||||
<SkeletonCard
|
||||
showAvatar
|
||||
showTitle
|
||||
showText
|
||||
textLines={4}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
```
|
||||
|
||||
#### `SkeletonTable`
|
||||
|
||||
Table placeholder:
|
||||
|
||||
```tsx
|
||||
<SkeletonTable rows={5} columns={4} />
|
||||
<SkeletonTable rows={10} columns={6} showHeader={false} />
|
||||
```
|
||||
|
||||
#### `SkeletonButton`
|
||||
|
||||
Button placeholder:
|
||||
|
||||
```tsx
|
||||
<SkeletonButton width={120} />
|
||||
<SkeletonButton className="w-full" />
|
||||
```
|
||||
|
||||
#### `PageLoader`
|
||||
|
||||
Full-page loading indicator:
|
||||
|
||||
```tsx
|
||||
<PageLoader message="Loading your documents..." />
|
||||
```
|
||||
|
||||
#### `InlineLoader`
|
||||
|
||||
Small inline loading spinner:
|
||||
|
||||
```tsx
|
||||
<InlineLoader size="sm" />
|
||||
<InlineLoader size="md" />
|
||||
<InlineLoader size="lg" />
|
||||
```
|
||||
|
||||
## Integration with Table Component
|
||||
|
||||
The Table component now supports skeleton loading:
|
||||
|
||||
```tsx
|
||||
import { Table, SkeletonTable } from './components/Table';
|
||||
|
||||
function DocumentList() {
|
||||
const { data, isLoading } = useGetDocuments();
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonTable rows={10} columns={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data?.documents || []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Theme Support
|
||||
|
||||
All components automatically adapt to the current theme:
|
||||
|
||||
- **Light mode**: Uses gray tones for skeletons, appropriate colors for toasts
|
||||
- **Dark mode**: Uses darker gray tones for skeletons, adjusted colors for toasts
|
||||
|
||||
The theme is controlled via Tailwind's `dark:` classes, which respond to the system preference or manual theme toggles.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `clsx` - Utility for constructing className strings
|
||||
- `tailwind-merge` - Merges Tailwind CSS classes intelligently
|
||||
- `lucide-react` - Icon library used by Toast component
|
||||
230
frontend/src/components/Skeleton.tsx
Normal file
230
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'text' | 'circular' | 'rectangular';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animation?: 'pulse' | 'wave' | 'none';
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className = '',
|
||||
variant = 'default',
|
||||
width,
|
||||
height,
|
||||
animation = 'pulse',
|
||||
}: SkeletonProps) {
|
||||
const baseClasses = 'bg-gray-200 dark:bg-gray-600';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'rounded',
|
||||
text: 'rounded-md h-4',
|
||||
circular: 'rounded-full',
|
||||
rectangular: 'rounded-none',
|
||||
};
|
||||
|
||||
const animationClasses = {
|
||||
pulse: 'animate-pulse',
|
||||
wave: 'animate-wave',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const style = {
|
||||
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
|
||||
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
animationClasses[animation],
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTextProps {
|
||||
lines?: number;
|
||||
className?: string;
|
||||
lineClassName?: string;
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 3, className = '', lineClassName = '' }: SkeletonTextProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
className={cn(
|
||||
lineClassName,
|
||||
i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonAvatarProps {
|
||||
size?: number | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonAvatar({ size = 'md', className = '' }: SkeletonAvatarProps) {
|
||||
const sizeMap = {
|
||||
sm: 32,
|
||||
md: 40,
|
||||
lg: 56,
|
||||
};
|
||||
|
||||
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
width={pixelSize}
|
||||
height={pixelSize}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
className?: string;
|
||||
showAvatar?: boolean;
|
||||
showTitle?: boolean;
|
||||
showText?: boolean;
|
||||
textLines?: number;
|
||||
}
|
||||
|
||||
export function SkeletonCard({
|
||||
className = '',
|
||||
showAvatar = false,
|
||||
showTitle = true,
|
||||
showText = true,
|
||||
textLines = 3,
|
||||
}: SkeletonCardProps) {
|
||||
return (
|
||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600', className)}>
|
||||
{showAvatar && (
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<SkeletonAvatar />
|
||||
<div className="flex-1">
|
||||
<Skeleton variant="text" className="w-3/4 mb-2" />
|
||||
<Skeleton variant="text" className="w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showTitle && (
|
||||
<Skeleton variant="text" className="w-1/2 mb-4 h-6" />
|
||||
)}
|
||||
{showText && (
|
||||
<SkeletonText lines={textLines} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTableProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function SkeletonTable({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
className = '',
|
||||
showHeader = true,
|
||||
}: SkeletonTableProps) {
|
||||
return (
|
||||
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
|
||||
<table className="min-w-full">
|
||||
{showHeader && (
|
||||
<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="w-3/4 h-5" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b dark:border-gray-600 last:border-0">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonButtonProps {
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
}
|
||||
|
||||
export function SkeletonButton({ className = '', width }: SkeletonButtonProps) {
|
||||
return (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
height={36}
|
||||
width={width || '100%'}
|
||||
className={cn('rounded', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageLoaderProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center min-h-[400px] gap-4', className)}>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 border-4 border-gray-200 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm font-medium">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineLoaderProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
|
||||
const sizeMap = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-6 h-6 border-3',
|
||||
lg: 'w-8 h-8 border-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<div className={`${sizeMap[size]} border-gray-200 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export SkeletonTable for backward compatibility
|
||||
export { SkeletonTable as SkeletonTableExport };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export interface Column<T> {
|
||||
key: keyof T;
|
||||
@@ -32,9 +34,39 @@ export function Table<T extends Record<string, any>>({
|
||||
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="w-3/4 h-5" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b dark:border-gray-600 last:border-0">
|
||||
{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) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-white p-4">Loading...</div>
|
||||
<SkeletonTable rows={5} columns={columns.length} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
86
frontend/src/components/Toast.tsx
Normal file
86
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Info, AlertTriangle, XCircle, X } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
onClose?: (id: string) => void;
|
||||
}
|
||||
|
||||
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 typeStyles = {
|
||||
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400',
|
||||
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
info: 'text-blue-600 dark:text-blue-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-400',
|
||||
error: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
|
||||
const textStyles = {
|
||||
info: 'text-blue-800 dark:text-blue-200',
|
||||
warning: 'text-yellow-800 dark:text-yellow-200',
|
||||
error: 'text-red-800 dark:text-red-200',
|
||||
};
|
||||
|
||||
return { baseStyles, typeStyles, iconStyles, textStyles };
|
||||
};
|
||||
|
||||
export function Toast({ id, type, message, duration = 5000, onClose }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||
|
||||
const { baseStyles, typeStyles, iconStyles, textStyles } = getToastStyles(type);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.(id);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(handleClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: <Info size={20} className={iconStyles[type]} />,
|
||||
warning: <AlertTriangle size={20} className={iconStyles[type]} />,
|
||||
error: <XCircle size={20} className={iconStyles[type]} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseStyles} ${typeStyles[type]} ${isAnimatingOut ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0'}`}
|
||||
>
|
||||
{icons[type]}
|
||||
<p className={`flex-1 text-sm font-medium ${textStyles[type]}`}>
|
||||
{message}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`ml-2 opacity-70 hover:opacity-100 transition-opacity ${textStyles[type]}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/ToastContext.tsx
Normal file
78
frontend/src/components/ToastContext.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { Toast, ToastType, ToastProps } from './Toast';
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => string;
|
||||
showInfo: (message: string, duration?: number) => string;
|
||||
showWarning: (message: string, duration?: number) => string;
|
||||
showError: (message: string, duration?: number) => string;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<(ToastProps & { id: string })[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info', duration?: number): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
setToasts((prev) => [...prev, { id, type, message, duration, onClose: removeToast }]);
|
||||
return id;
|
||||
}, [removeToast]);
|
||||
|
||||
const showInfo = useCallback((message: string, duration?: number) => {
|
||||
return showToast(message, 'info', duration);
|
||||
}, [showToast]);
|
||||
|
||||
const showWarning = useCallback((message: string, duration?: number) => {
|
||||
return showToast(message, 'warning', duration);
|
||||
}, [showToast]);
|
||||
|
||||
const showError = useCallback((message: string, duration?: number) => {
|
||||
return showToast(message, 'error', duration);
|
||||
}, [showToast]);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showInfo, showWarning, showError, removeToast, clearToasts }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: (ToastProps & { id: string })[];
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts }: ToastContainerProps) {
|
||||
if (toasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
{toasts.map((toast) => (
|
||||
<Toast key={toast.id} {...toast} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToasts() {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useToasts must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
16
frontend/src/components/index.ts
Normal file
16
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Toast components
|
||||
export { Toast } from './Toast';
|
||||
export { ToastProvider, useToasts } from './ToastContext';
|
||||
export type { ToastType, ToastProps } from './Toast';
|
||||
|
||||
// Skeleton components
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonButton,
|
||||
PageLoader,
|
||||
InlineLoader
|
||||
} from './Skeleton';
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
SearchResponse,
|
||||
SettingsResponse,
|
||||
StreaksResponse,
|
||||
UpdateSettingsRequest,
|
||||
UpdateUserBody,
|
||||
UserStatisticsResponse,
|
||||
UsersResponse
|
||||
@@ -684,6 +685,68 @@ export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>,
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary Update user settings
|
||||
*/
|
||||
export const updateSettings = (
|
||||
updateSettingsRequest: UpdateSettingsRequest, options?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<SettingsResponse>> => {
|
||||
|
||||
|
||||
return axios.default.put(
|
||||
`/api/v1/settings`,
|
||||
updateSettingsRequest,options
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getUpdateSettingsMutationOptions = <TError = AxiosError<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: UpdateSettingsRequest}, TContext>, axios?: AxiosRequestConfig}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: UpdateSettingsRequest}, TContext> => {
|
||||
|
||||
const mutationKey = ['updateSettings'];
|
||||
const {mutation: mutationOptions, axios: axiosOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, axios: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateSettings>>, {data: UpdateSettingsRequest}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return updateSettings(data,axiosOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UpdateSettingsMutationResult = NonNullable<Awaited<ReturnType<typeof updateSettings>>>
|
||||
export type UpdateSettingsMutationBody = UpdateSettingsRequest
|
||||
export type UpdateSettingsMutationError = AxiosError<ErrorResponse>
|
||||
|
||||
/**
|
||||
* @summary Update user settings
|
||||
*/
|
||||
export const useUpdateSettings = <TError = AxiosError<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: UpdateSettingsRequest}, TContext>, axios?: AxiosRequestConfig}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSettings>>,
|
||||
TError,
|
||||
{data: UpdateSettingsRequest},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getUpdateSettingsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary User login
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description?: string;
|
||||
isbn10?: string;
|
||||
isbn13?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted: boolean;
|
||||
@@ -17,4 +20,7 @@ export interface Document {
|
||||
filepath?: string;
|
||||
percentage?: number;
|
||||
total_time_seconds?: number;
|
||||
wpm?: number;
|
||||
seconds_per_percent?: number;
|
||||
last_read?: string;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export * from './searchResponse';
|
||||
export * from './setting';
|
||||
export * from './settingsResponse';
|
||||
export * from './streaksResponse';
|
||||
export * from './updateSettingsRequest';
|
||||
export * from './updateUserBody';
|
||||
export * from './user';
|
||||
export * from './userData';
|
||||
|
||||
13
frontend/src/generated/model/updateSettingsRequest.ts
Normal file
13
frontend/src/generated/model/updateSettingsRequest.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* AnthoLume API v1
|
||||
* REST API for AnthoLume document management system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
password?: string;
|
||||
new_password?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
@@ -98,4 +98,35 @@ main {
|
||||
#menu {
|
||||
transform: translate(calc(-1 * (env(safe-area-inset-left) + 100%)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton Wave Animation */
|
||||
@keyframes wave {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-wave {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(229, 231, 235) 0%,
|
||||
rgb(243, 244, 246) 50%,
|
||||
rgb(229, 231, 235) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: wave 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .animate-wave {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(75, 85, 99) 0%,
|
||||
rgb(107, 114, 128) 50%,
|
||||
rgb(75, 85, 99) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
import './auth/authInterceptor';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
@@ -22,7 +23,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useState } from 'react';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
|
||||
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
const { data: directoryData, isLoading } = useGetImportDirectory(
|
||||
currentPath ? { directory: currentPath } : {}
|
||||
@@ -41,13 +43,14 @@ export default function AdminImportPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
console.log('Import completed:', response.data);
|
||||
// Redirect to import results page
|
||||
window.location.href = '/admin/import-results';
|
||||
showInfo('Import completed successfully');
|
||||
// Redirect to import results page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/import-results';
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Import failed:', error);
|
||||
alert('Import failed: ' + (error as any).message);
|
||||
showError('Import failed: ' + (error as any).message);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
interface BackupTypes {
|
||||
covers: boolean;
|
||||
@@ -10,14 +11,13 @@ interface BackupTypes {
|
||||
export default function AdminPage() {
|
||||
const { isLoading } = useGetAdmin();
|
||||
const postAdminAction = usePostAdminAction();
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
|
||||
covers: false,
|
||||
documents: false,
|
||||
});
|
||||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleBackupSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -42,12 +42,10 @@ export default function AdminPage() {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setMessage('Backup completed successfully');
|
||||
setErrorMessage(null);
|
||||
showInfo('Backup completed successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage('Backup failed: ' + (error as any).message);
|
||||
setMessage(null);
|
||||
showError('Backup failed: ' + (error as any).message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -67,12 +65,10 @@ export default function AdminPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMessage('Restore completed successfully');
|
||||
setErrorMessage(null);
|
||||
showInfo('Restore completed successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage('Restore failed: ' + (error as any).message);
|
||||
setMessage(null);
|
||||
showError('Restore failed: ' + (error as any).message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -87,12 +83,10 @@ export default function AdminPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMessage('Metadata matching started');
|
||||
setErrorMessage(null);
|
||||
showInfo('Metadata matching started');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage('Metadata matching failed: ' + (error as any).message);
|
||||
setMessage(null);
|
||||
showError('Metadata matching failed: ' + (error as any).message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -107,12 +101,10 @@ export default function AdminPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMessage('Cache tables started');
|
||||
setErrorMessage(null);
|
||||
showInfo('Cache tables started');
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage('Cache tables failed: ' + (error as any).message);
|
||||
setMessage(null);
|
||||
showError('Cache tables failed: ' + (error as any).message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -175,12 +167,6 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<span className="text-red-400 text-xs">{errorMessage}</span>
|
||||
)}
|
||||
{message && (
|
||||
<span className="text-green-400 text-xs">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks Card */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||
const updateUser = useUpdateUser();
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
@@ -16,7 +18,7 @@ export default function AdminUsersPage() {
|
||||
const handleCreateUser = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newUsername || !newPassword) return;
|
||||
|
||||
|
||||
updateUser.mutate(
|
||||
{
|
||||
data: {
|
||||
@@ -28,6 +30,7 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showInfo('User created successfully');
|
||||
setShowAddForm(false);
|
||||
setNewUsername('');
|
||||
setNewPassword('');
|
||||
@@ -35,7 +38,7 @@ export default function AdminUsersPage() {
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert('Failed to create user: ' + error.message);
|
||||
showError('Failed to create user: ' + error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -51,10 +54,11 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showInfo('User deleted successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert('Failed to delete user: ' + error.message);
|
||||
showError('Failed to delete user: ' + error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -73,10 +77,11 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showInfo('Password updated successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert('Failed to update password: ' + error.message);
|
||||
showError('Failed to update password: ' + error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -93,10 +98,12 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
const role = isAdmin ? 'admin' : 'user';
|
||||
showInfo(`User permissions updated to ${role}`);
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert('Failed to update admin status: ' + error.message);
|
||||
showError('Failed to update admin status: ' + error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
166
frontend/src/pages/ComponentDemoPage.tsx
Normal file
166
frontend/src/pages/ComponentDemoPage.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonCard,
|
||||
SkeletonTable,
|
||||
SkeletonButton,
|
||||
PageLoader,
|
||||
InlineLoader
|
||||
} from '../components/Skeleton';
|
||||
|
||||
export default function ComponentDemoPage() {
|
||||
const { showInfo, showWarning, showError, showToast } = useToasts();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDemoClick = () => {
|
||||
setIsLoading(true);
|
||||
showInfo('Starting demo operation...');
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
showInfo('Demo operation completed successfully!');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleErrorClick = () => {
|
||||
showError('This is a sample error message');
|
||||
};
|
||||
|
||||
const handleWarningClick = () => {
|
||||
showWarning('This is a sample warning message', 10000);
|
||||
};
|
||||
|
||||
const handleCustomToast = () => {
|
||||
showToast('Custom toast message', 'info', 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4">
|
||||
<h1 className="text-2xl font-bold dark:text-white">UI Components Demo</h1>
|
||||
|
||||
{/* Toast Demos */}
|
||||
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-white">Toast Notifications</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<button
|
||||
onClick={handleDemoClick}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWarningClick}
|
||||
className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||
>
|
||||
Show Warning Toast (10s)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleErrorClick}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Show Error Toast
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCustomToast}
|
||||
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
>
|
||||
Show Custom Toast
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skeleton Demos */}
|
||||
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-white">Skeleton Loading Components</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Basic Skeletons */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Basic Skeletons</h3>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="w-full h-8" />
|
||||
<Skeleton variant="text" className="w-3/4" />
|
||||
<Skeleton variant="text" className="w-1/2" />
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<Skeleton variant="rectangular" width={100} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Text */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Text</h3>
|
||||
<SkeletonText lines={3} />
|
||||
<SkeletonText lines={5} className="max-w-md" />
|
||||
</div>
|
||||
|
||||
{/* Skeleton Avatar */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Avatar</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<SkeletonAvatar size="sm" />
|
||||
<SkeletonAvatar size="md" />
|
||||
<SkeletonAvatar size="lg" />
|
||||
<SkeletonAvatar size={72} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Button */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Button</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<SkeletonButton width={120} />
|
||||
<SkeletonButton className="w-full max-w-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skeleton Card Demo */}
|
||||
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-white">Skeleton Cards</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard showAvatar />
|
||||
<SkeletonCard showAvatar showTitle showText textLines={4} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skeleton Table Demo */}
|
||||
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-white">Skeleton Table</h2>
|
||||
<SkeletonTable rows={5} columns={4} />
|
||||
</section>
|
||||
|
||||
{/* Page Loader Demo */}
|
||||
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-white">Page Loader</h2>
|
||||
<PageLoader message="Loading demo content..." />
|
||||
</section>
|
||||
|
||||
{/* Inline Loader Demo */}
|
||||
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-white">Inline Loader</h2>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-center">
|
||||
<InlineLoader size="sm" />
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Small</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<InlineLoader size="md" />
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Medium</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<InlineLoader size="lg" />
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Large</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,57 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description?: string;
|
||||
isbn10?: string;
|
||||
isbn13?: string;
|
||||
words?: number;
|
||||
filepath?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted: boolean;
|
||||
percentage?: number;
|
||||
total_time_seconds?: number;
|
||||
wpm?: number;
|
||||
seconds_per_percent?: number;
|
||||
last_read?: string;
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
document_id?: string;
|
||||
percentage?: number;
|
||||
created_at?: string;
|
||||
user_id?: string;
|
||||
device_name?: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
// Helper function to format seconds nicely (mirroring legacy niceSeconds)
|
||||
function niceSeconds(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A';
|
||||
|
||||
const days = Math.floor(seconds / 60 / 60 / 24);
|
||||
const remainingSeconds = seconds % (60 * 60 * 24);
|
||||
const hours = Math.floor(remainingSeconds / 60 / 60);
|
||||
const remainingAfterHours = remainingSeconds % (60 * 60);
|
||||
const minutes = Math.floor(remainingAfterHours / 60);
|
||||
const remainingSeconds2 = remainingAfterHours % 60;
|
||||
|
||||
let result = '';
|
||||
if (days > 0) result += `${days}d `;
|
||||
if (hours > 0) result += `${hours}h `;
|
||||
if (minutes > 0) result += `${minutes}m `;
|
||||
if (remainingSeconds2 > 0) result += `${remainingSeconds2}s`;
|
||||
|
||||
return result || 'N/A';
|
||||
}
|
||||
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||
|
||||
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
|
||||
@@ -12,81 +60,131 @@ export default function DocumentPage() {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
const document = docData?.data?.document;
|
||||
const progress = progressData?.data;
|
||||
const document = docData?.data?.document as Document;
|
||||
const progressDataArray = progressData?.data?.progress;
|
||||
const progress = Array.isArray(progressDataArray) ? progressDataArray[0] as Progress : undefined;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||
}
|
||||
|
||||
// Calculate total time left (mirroring legacy template logic)
|
||||
const percentage = progress?.percentage || document.percentage || 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"
|
||||
>
|
||||
{/* Document Info */}
|
||||
{/* Document Info - Left Column */}
|
||||
<div
|
||||
className="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"
|
||||
>
|
||||
<div className="rounded object-fill w-full bg-gray-200 dark:bg-gray-600 h-60">
|
||||
{/* Cover image placeholder */}
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Cover
|
||||
{/* Cover Image */}
|
||||
{document.filepath && (
|
||||
<div className="rounded object-fill w-full bg-gray-200 dark:bg-gray-600 h-60">
|
||||
<img
|
||||
className="rounded object-cover h-full"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/reader#id=${document.id}&type=REMOTE`}
|
||||
className="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
Read
|
||||
</a>
|
||||
{/* Read Button - Only if file exists */}
|
||||
{document.filepath && (
|
||||
<a
|
||||
href={`/reader#id=${document.id}&type=REMOTE`}
|
||||
className="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none w-full mt-2"
|
||||
>
|
||||
Read
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap-reverse justify-between gap-2">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap-reverse justify-between gap-2 z-20 relative my-2">
|
||||
<div className="min-w-[50%] md:mr-2">
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-gray-500">Words:</p>
|
||||
<p className="font-medium">{document.words || 'N/A'}</p>
|
||||
<p className="text-gray-500">ISBN-10:</p>
|
||||
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<p className="text-gray-500">ISBN-13:</p>
|
||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Button - Only if file exists */}
|
||||
{document.filepath && (
|
||||
<a
|
||||
href={`/api/v1/documents/${document.id}/file`}
|
||||
className="z-10 text-gray-500 dark:text-gray-400"
|
||||
title="Download"
|
||||
>
|
||||
<svg className="w-6 h-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" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Details Grid */}
|
||||
<div className="grid sm:grid-cols-2 justify-between gap-4 pb-4">
|
||||
<div>
|
||||
<p className="text-gray-500">Title</p>
|
||||
<p className="font-medium text-lg">{document.title}</p>
|
||||
{/* Title - Editable */}
|
||||
<div className="relative">
|
||||
<div className="text-gray-500 inline-flex gap-2 relative">
|
||||
<p>Title</p>
|
||||
</div>
|
||||
<div className="relative font-medium text-justify hyphens-auto">
|
||||
<p>{document.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Author</p>
|
||||
<p className="font-medium text-lg">{document.author}</p>
|
||||
|
||||
{/* Author - Editable */}
|
||||
<div className="relative">
|
||||
<div className="text-gray-500 inline-flex gap-2 relative">
|
||||
<p>Author</p>
|
||||
</div>
|
||||
<div className="relative font-medium text-justify hyphens-auto">
|
||||
<p>{document.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Time Read</p>
|
||||
<p className="font-medium text-lg">
|
||||
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
|
||||
</p>
|
||||
|
||||
{/* Time Read */}
|
||||
<div className="relative">
|
||||
<div className="text-gray-500 inline-flex gap-2 relative">
|
||||
<p>Time Read</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p className="font-medium text-lg">
|
||||
{document.total_time_seconds ? niceSeconds(document.total_time_seconds) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<p className="text-gray-500">Progress</p>
|
||||
<p className="font-medium text-lg">
|
||||
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
|
||||
{percentage ? `${Math.round(percentage)}%` : '0%'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{/* Description - Editable */}
|
||||
<div className="relative">
|
||||
<div className="text-gray-500 inline-flex gap-2 relative">
|
||||
<p>Description</p>
|
||||
</div>
|
||||
<div className="relative font-medium text-justify hyphens-auto">
|
||||
<p>N/A</p>
|
||||
<p>{document.description || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{/* Reading Statistics */}
|
||||
<div className="mt-4 grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-500">Words</p>
|
||||
@@ -105,6 +203,22 @@ export default function DocumentPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Reading Stats - Matching Legacy Template */}
|
||||
{progress && (
|
||||
<div className="mt-4 grid sm:grid-cols-2 gap-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<p className="text-gray-500">Words / Minute:</p>
|
||||
<p className="font-medium">{document.wpm || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<p className="text-gray-500">Est. Time Left:</p>
|
||||
<p className="font-medium whitespace-nowrap">
|
||||
{niceSeconds(totalTimeLeftSeconds)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import { Activity, Download, Search, Upload } from 'lucide-react';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: {
|
||||
@@ -101,6 +102,7 @@ export default function DocumentsPage() {
|
||||
const [limit] = useState(9);
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showInfo, showWarning, showError } = useToasts();
|
||||
|
||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search });
|
||||
const createMutation = useCreateDocument();
|
||||
@@ -118,7 +120,7 @@ export default function DocumentsPage() {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith('.epub')) {
|
||||
alert('Please upload an EPUB file');
|
||||
showWarning('Please upload an EPUB file');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,12 +130,11 @@ export default function DocumentsPage() {
|
||||
document_file: file,
|
||||
},
|
||||
});
|
||||
alert('Document uploaded successfully!');
|
||||
showInfo('Document uploaded successfully!');
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Failed to upload document');
|
||||
} catch (error: any) {
|
||||
showError('Failed to upload document: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@ import { useState, FormEvent, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
|
||||
// Redirect to home if already logged in
|
||||
useEffect(() => {
|
||||
@@ -22,12 +23,11 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (err) {
|
||||
setError('Invalid credentials');
|
||||
showError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -66,7 +66,6 @@ export default function LoginPage() {
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute -bottom-5 text-red-400 text-xs">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,28 +1,94 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetSettings } from '../generated/anthoLumeAPIV1';
|
||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||
import { User, Lock, Clock } from 'lucide-react';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = data?.data;
|
||||
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [timezone, setTimezone] = useState(settingsData?.timezone || '');
|
||||
|
||||
const handlePasswordSubmit = (e: FormEvent) => {
|
||||
const handlePasswordSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Call API to change password
|
||||
|
||||
if (!password || !newPassword) {
|
||||
showError('Please enter both current and new password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({
|
||||
data: {
|
||||
password: password,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
showInfo('Password updated successfully');
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
} catch (error: any) {
|
||||
showError('Failed to update password: ' + (error.response?.data?.message || error.message || 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimezoneSubmit = (e: FormEvent) => {
|
||||
const handleTimezoneSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Call API to change timezone
|
||||
|
||||
try {
|
||||
await updateSettings.mutateAsync({
|
||||
data: {
|
||||
timezone: timezone,
|
||||
},
|
||||
});
|
||||
showInfo('Timezone updated successfully');
|
||||
} catch (error: any) {
|
||||
showError('Failed to update timezone: ' + (error.response?.data?.message || error.message || 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
return (
|
||||
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700">
|
||||
<div className="w-16 h-16 bg-gray-200 dark:bg-gray-600 rounded-full mb-4" />
|
||||
<div className="w-32 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 grow">
|
||||
<div className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700">
|
||||
<div className="w-48 h-6 bg-gray-200 dark:bg-gray-600 rounded mb-4" />
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
<div className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
<div className="w-40 h-10 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700">
|
||||
<div className="w-48 h-6 bg-gray-200 dark:bg-gray-600 rounded mb-4" />
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
<div className="w-40 h-10 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 rounded shadow-lg bg-white dark:bg-gray-700">
|
||||
<div className="w-24 h-6 bg-gray-200 dark:bg-gray-600 rounded mb-4" />
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex-1 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
<div className="flex-1 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
<div className="flex-1 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
<div className="flex-1 h-32 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
6
frontend/src/utils/cn.ts
Normal file
6
frontend/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user