theme draft 2 (done?)
This commit is contained in:
@@ -9,13 +9,11 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|||||||
const { isAuthenticated, isCheckingAuth } = useAuth();
|
const { isAuthenticated, isCheckingAuth } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Show loading while checking authentication status
|
|
||||||
if (isCheckingAuth) {
|
if (isCheckingAuth) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Redirect to login with the current location saved
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface FieldProps {
|
|||||||
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
export function Field({ label, children, isEditing: _isEditing = false }: FieldProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded">
|
<div className="relative rounded">
|
||||||
<div className="relative inline-flex gap-2 text-gray-500">{label}</div>
|
<div className="relative inline-flex gap-2 text-content-muted">{label}</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function Layout() {
|
|||||||
|
|
||||||
{isUserDropdownOpen && (
|
{isUserDropdownOpen && (
|
||||||
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
|
||||||
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-black/5 dark:shadow-gray-800">
|
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-border/30">
|
||||||
<div
|
<div
|
||||||
className="border-b border-border px-4 py-3"
|
className="border-b border-border px-4 py-3"
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -124,10 +124,7 @@ export function getSVGGraphData(
|
|||||||
const itemY = svgHeight - itemSize;
|
const itemY = svgHeight - itemSize;
|
||||||
const lineX = (idx + 1) * blockOffset;
|
const lineX = (idx + 1) * blockOffset;
|
||||||
|
|
||||||
linePoints.push({
|
linePoints.push({ x: lineX, y: itemY });
|
||||||
x: lineX,
|
|
||||||
y: itemY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lineX > maxBX) {
|
if (lineX > maxBX) {
|
||||||
maxBX = lineX;
|
maxBX = lineX;
|
||||||
@@ -164,8 +161,8 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
|||||||
|
|
||||||
if (!data || data.length < 2) {
|
if (!data || data.length < 2) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-24 items-center justify-center bg-gray-100 dark:bg-gray-600">
|
<div className="relative flex h-24 items-center justify-center bg-surface-muted">
|
||||||
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
<p className="text-content-subtle">No data available</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,8 +172,8 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
<svg viewBox={`26 0 755 ${svgHeight}`} preserveAspectRatio="none" width="100%" height="6em">
|
||||||
<path fill="#316BBE" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
<path fill="rgb(var(--secondary-600))" fillOpacity="0.5" stroke="none" d={`${BezierPath} ${BezierFill}`} />
|
||||||
<path fill="none" stroke="#316BBE" d={BezierPath} />
|
<path fill="none" stroke="rgb(var(--secondary-600))" d={BezierPath} />
|
||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 flex size-full"
|
className="absolute top-0 flex size-full"
|
||||||
@@ -196,11 +193,10 @@ export default function ReadingHistoryGraph({ data }: ReadingHistoryGraphProps)
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute top-3 flex flex-col items-center rounded p-2 text-xs dark:text-white"
|
className="pointer-events-none absolute top-3 flex flex-col items-center rounded bg-surface/80 p-2 text-xs text-content"
|
||||||
style={{
|
style={{
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
backgroundColor: 'rgba(128, 128, 128, 0.2)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{formatDate(point.date)}</span>
|
<span>{formatDate(point.date)}</span>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function Skeleton({
|
|||||||
height,
|
height,
|
||||||
animation = 'pulse',
|
animation = 'pulse',
|
||||||
}: SkeletonProps) {
|
}: SkeletonProps) {
|
||||||
const baseClasses = 'bg-gray-200 dark:bg-gray-600';
|
const baseClasses = 'bg-surface-strong';
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: 'rounded',
|
default: 'rounded',
|
||||||
@@ -184,7 +184,7 @@ export function PageLoader({ message = 'Loading...', className = '' }: PageLoade
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
|
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="size-12 animate-spin rounded-full border-4 border-gray-200 border-t-secondary-500 dark:border-gray-600" />
|
<div className="size-12 animate-spin rounded-full border-4 border-surface-strong border-t-secondary-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-content-muted">{message}</p>
|
<p className="text-sm font-medium text-content-muted">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +206,7 @@ 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
|
<div
|
||||||
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-secondary-500 dark:border-gray-600`}
|
className={`${sizeMap[size]} animate-spin rounded-full border-surface-strong border-t-secondary-500`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ export default function ActivityPage() {
|
|||||||
key: 'document_id' as const,
|
key: 'document_id' as const,
|
||||||
header: 'Document',
|
header: 'Document',
|
||||||
render: (_value, row) => (
|
render: (_value, row) => (
|
||||||
<Link
|
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||||
to={`/documents/${row.document_id}`}
|
|
||||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
@@ -29,9 +26,7 @@ export default function ActivityPage() {
|
|||||||
{
|
{
|
||||||
key: 'duration' as const,
|
key: 'duration' as const,
|
||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
render: value => {
|
render: value => formatDuration(typeof value === 'number' ? value : 0),
|
||||||
return formatDuration(typeof value === 'number' ? value : 0);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'end_percentage' as const,
|
key: 'end_percentage' as const,
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ 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
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/admin/import-results';
|
window.location.href = '/admin/import-results';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -65,22 +64,22 @@ export default function AdminImportPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading && !currentPath) {
|
if (isLoading && !currentPath) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDirectory) {
|
if (selectedDirectory) {
|
||||||
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 className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<p className="text-lg font-semibold text-gray-500">Selected Import Directory</p>
|
<p className="text-lg font-semibold text-content">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 text-content">
|
||||||
<FolderOpenIcon size={20} />
|
<FolderOpenIcon size={20} />
|
||||||
<p className="break-all text-lg font-medium">{selectedDirectory}</p>
|
<p className="break-all text-lg font-medium">{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 text-content">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -124,20 +123,20 @@ 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 className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||||
<thead className="text-gray-800 dark:text-gray-400">
|
<thead className="text-content-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800"></th>
|
<th className="w-12 border-b border-border p-3 text-left font-normal"></th>
|
||||||
<th className="break-all border-b border-gray-200 p-3 text-left font-normal dark:border-gray-800">
|
<th className="break-all border-b border-border p-3 text-left font-normal">
|
||||||
{currentPath}
|
{currentPath}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-black dark:text-white">
|
<tbody>
|
||||||
{currentPath !== '/' && (
|
{currentPath !== '/' && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400"></td>
|
<td className="border-b border-border p-3 text-content-muted"></td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<button onClick={handleNavigateUp}>
|
<button onClick={handleNavigateUp}>
|
||||||
<p>../</p>
|
<p>../</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -153,12 +152,12 @@ export default function AdminImportPage() {
|
|||||||
) : (
|
) : (
|
||||||
directories.map((item: DirectoryItem) => (
|
directories.map((item: DirectoryItem) => (
|
||||||
<tr key={item.name}>
|
<tr key={item.name}>
|
||||||
<td className="border-b border-gray-200 p-3 text-gray-800 dark:text-gray-400">
|
<td className="border-b border-border p-3 text-content-muted">
|
||||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||||
<FolderOpenIcon size={20} />
|
<FolderOpenIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
<button onClick={() => item.name && handleSelectDirectory(item.name)}>
|
||||||
<p>{item.name ?? ''}</p>
|
<p>{item.name ?? ''}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,27 +8,27 @@ export default function AdminImportResultsPage() {
|
|||||||
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
|
resultsData?.status === 200 ? (resultsData.data as ImportResultsResponse).results || [] : [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||||
<thead className="text-gray-800 dark:text-gray-400">
|
<thead className="text-content-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Document
|
Document
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Error
|
Error
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-black dark:text-white">
|
<tbody>
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="p-3 text-center" colSpan={3}>
|
<td className="p-3 text-center" colSpan={3}>
|
||||||
@@ -39,22 +39,24 @@ export default function AdminImportResultsPage() {
|
|||||||
results.map((result: ImportResult, index: number) => (
|
results.map((result: ImportResult, index: number) => (
|
||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
<td
|
<td
|
||||||
className="grid border-b border-gray-200 p-3"
|
className="grid border-b border-border p-3"
|
||||||
style={{ gridTemplateColumns: '4rem auto' }}
|
style={{ gridTemplateColumns: '4rem auto' }}
|
||||||
>
|
>
|
||||||
<span className="text-gray-800 dark:text-gray-400">Name:</span>
|
<span className="text-content-muted">Name:</span>
|
||||||
{result.id ? (
|
{result.id ? (
|
||||||
<Link to={`/documents/${result.id}`}>{result.name}</Link>
|
<Link to={`/documents/${result.id}`} className="text-secondary-600 hover:underline">
|
||||||
|
{result.name}
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span>N/A</span>
|
<span>N/A</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-800 dark:text-gray-400">File:</span>
|
<span className="text-content-muted">File:</span>
|
||||||
<span>{result.path}</span>
|
<span>{result.path}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<p>{result.status}</p>
|
<p>{result.status}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<p>{result.error || ''}</p>
|
<p>{result.error || ''}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -26,18 +26,18 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<div className="mb-4 flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<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 className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||||
<Search2Icon size={15} hoverable={false} />
|
<Search2Icon size={15} hoverable={false} />
|
||||||
</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-border bg-surface p-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||||
placeholder="JQ Filter"
|
placeholder="JQ Filter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@ export default function AdminLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex w-full flex-col-reverse overflow-scroll text-black dark:text-white"
|
className="flex w-full flex-col-reverse overflow-scroll text-content"
|
||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -42,19 +42,14 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
|
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
|
||||||
|
|
||||||
// Stream the response directly to disk using File System Access API
|
|
||||||
// This avoids loading multi-GB files into browser memory
|
|
||||||
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
||||||
try {
|
try {
|
||||||
// Modern browsers: Use File System Access API for direct disk writes
|
|
||||||
const handle = await window.showSaveFilePicker({
|
const handle = await window.showSaveFilePicker({
|
||||||
suggestedName: filename,
|
suggestedName: filename,
|
||||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const writable = await handle.createWritable();
|
const writable = await handle.createWritable();
|
||||||
|
|
||||||
// Stream response body directly to file without buffering
|
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) throw new Error('Unable to read response');
|
if (!reader) throw new Error('Unable to read response');
|
||||||
|
|
||||||
@@ -67,13 +62,11 @@ export default function AdminPage() {
|
|||||||
await writable.close();
|
await writable.close();
|
||||||
showInfo('Backup completed successfully');
|
showInfo('Backup completed successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled or error
|
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if ((err as Error).name !== 'AbortError') {
|
||||||
showError('Backup failed: ' + (err as Error).message);
|
showError('Backup failed: ' + (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback for older browsers
|
|
||||||
showError(
|
showError(
|
||||||
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
|
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
|
||||||
);
|
);
|
||||||
@@ -113,52 +106,34 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleMetadataMatch = () => {
|
const handleMetadataMatch = () => {
|
||||||
postAdminAction.mutate(
|
postAdminAction.mutate(
|
||||||
|
{ data: { action: 'METADATA_MATCH' } },
|
||||||
{
|
{
|
||||||
data: {
|
onSuccess: () => showInfo('Metadata matching started'),
|
||||||
action: 'METADATA_MATCH',
|
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)),
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
showInfo('Metadata matching started');
|
|
||||||
},
|
|
||||||
onError: error => {
|
|
||||||
showError('Metadata matching failed: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCacheTables = () => {
|
const handleCacheTables = () => {
|
||||||
postAdminAction.mutate(
|
postAdminAction.mutate(
|
||||||
|
{ data: { action: 'CACHE_TABLES' } },
|
||||||
{
|
{
|
||||||
data: {
|
onSuccess: () => showInfo('Cache tables started'),
|
||||||
action: 'CACHE_TABLES',
|
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)),
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
showInfo('Cache tables started');
|
|
||||||
},
|
|
||||||
onError: error => {
|
|
||||||
showError('Cache tables failed: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 */}
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
<p className="mb-2 text-lg font-semibold text-content">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 */}
|
<form className="flex justify-between text-content" onSubmit={handleBackupSubmit}>
|
||||||
<form className="flex justify-between" onSubmit={handleBackupSubmit}>
|
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -186,8 +161,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Restore Form */}
|
<form onSubmit={handleRestoreSubmit} className="flex grow justify-between text-content">
|
||||||
<form 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"
|
||||||
@@ -205,11 +179,10 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Card */}
|
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<div 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 text-content">Tasks</p>
|
||||||
<p className="text-lg font-semibold">Tasks</p>
|
<table className="min-w-full bg-surface text-sm text-content">
|
||||||
<table className="min-w-full bg-white text-sm dark:bg-gray-700">
|
<tbody>
|
||||||
<tbody className="text-black dark:text-white">
|
|
||||||
<tr>
|
<tr>
|
||||||
<td className="pl-0">
|
<td className="pl-0">
|
||||||
<p>Metadata Matching</p>
|
<p>Metadata Matching</p>
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ export default function AdminUsersPage() {
|
|||||||
setNewIsAdmin(false);
|
setNewIsAdmin(false);
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => showError('Failed to create user: ' + getErrorMessage(error)),
|
||||||
showError('Failed to create user: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -49,19 +47,14 @@ export default function AdminUsersPage() {
|
|||||||
const handleDeleteUser = (userId: string) => {
|
const handleDeleteUser = (userId: string) => {
|
||||||
updateUser.mutate(
|
updateUser.mutate(
|
||||||
{
|
{
|
||||||
data: {
|
data: { operation: 'DELETE', user: userId },
|
||||||
operation: 'DELETE',
|
|
||||||
user: userId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showInfo('User deleted successfully');
|
showInfo('User deleted successfully');
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => showError('Failed to delete user: ' + getErrorMessage(error)),
|
||||||
showError('Failed to delete user: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -71,20 +64,14 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
updateUser.mutate(
|
updateUser.mutate(
|
||||||
{
|
{
|
||||||
data: {
|
data: { operation: 'UPDATE', user: userId, password },
|
||||||
operation: 'UPDATE',
|
|
||||||
user: userId,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showInfo('Password updated successfully');
|
showInfo('Password updated successfully');
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => showError('Failed to update password: ' + getErrorMessage(error)),
|
||||||
showError('Failed to update password: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -92,50 +79,40 @@ export default function AdminUsersPage() {
|
|||||||
const handleToggleAdmin = (userId: string, isAdmin: boolean) => {
|
const handleToggleAdmin = (userId: string, isAdmin: boolean) => {
|
||||||
updateUser.mutate(
|
updateUser.mutate(
|
||||||
{
|
{
|
||||||
data: {
|
data: { operation: 'UPDATE', user: userId, is_admin: isAdmin },
|
||||||
operation: 'UPDATE',
|
|
||||||
user: userId,
|
|
||||||
is_admin: isAdmin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const role = isAdmin ? 'admin' : 'user';
|
showInfo(`User permissions updated to ${isAdmin ? 'admin' : 'user'}`);
|
||||||
showInfo(`User permissions updated to ${role}`);
|
|
||||||
refetch();
|
refetch();
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => showError('Failed to update admin status: ' + getErrorMessage(error)),
|
||||||
showError('Failed to update admin status: ' + getErrorMessage(error));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full overflow-x-auto">
|
<div className="relative h-full overflow-x-auto">
|
||||||
{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-surface-strong p-3 shadow-lg transition-all duration-200">
|
||||||
<form
|
<form onSubmit={handleCreateUser} className="flex flex-col gap-2 text-sm text-content">
|
||||||
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-surface p-2 text-content"
|
||||||
/>
|
/>
|
||||||
<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-surface p-2 text-content"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -147,7 +124,7 @@ export default function AdminUsersPage() {
|
|||||||
<label htmlFor="new_is_admin">Admin</label>
|
<label htmlFor="new_is_admin">Admin</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="bg-gray-500 px-2 py-1 font-medium text-white hover:bg-gray-800 dark:text-gray-800 dark:hover:bg-gray-100"
|
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
@@ -157,29 +134,25 @@ export default function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-full overflow-scroll rounded shadow">
|
<div className="min-w-full overflow-scroll rounded shadow">
|
||||||
<table className="min-w-full bg-white text-sm leading-normal dark:bg-gray-700">
|
<table className="min-w-full bg-surface text-sm leading-normal text-content">
|
||||||
<thead className="text-gray-800 dark:text-gray-400">
|
<thead className="text-content-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<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-border p-3 text-left font-normal uppercase">
|
||||||
<button onClick={() => setShowAddForm(!showAddForm)}>
|
<button onClick={() => setShowAddForm(!showAddForm)}>
|
||||||
<AddIcon size={20} />
|
<AddIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">User</th>
|
||||||
User
|
<th className="border-b border-border p-3 text-left font-normal uppercase">Password</th>
|
||||||
</th>
|
<th className="border-b border-border p-3 text-center font-normal uppercase">
|
||||||
<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">
|
<th className="w-48 border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Created
|
Created
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-black dark:text-white">
|
<tbody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="p-3 text-center" colSpan={5}>
|
<td className="p-3 text-center" colSpan={5}>
|
||||||
@@ -189,33 +162,33 @@ export default function AdminUsersPage() {
|
|||||||
) : (
|
) : (
|
||||||
users.map((user: User) => (
|
users.map((user: User) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<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-border p-3 text-content-muted">
|
||||||
<button onClick={() => handleDeleteUser(user.id)}>
|
<button onClick={() => handleDeleteUser(user.id)}>
|
||||||
<DeleteIcon size={20} />
|
<DeleteIcon size={20} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<p>{user.id}</p>
|
<p>{user.id}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 px-3">
|
<td className="border-b border-border px-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const password = prompt(`Enter new password for ${user.id}`);
|
const password = prompt(`Enter new password for ${user.id}`);
|
||||||
if (password) handleUpdatePassword(user.id, password);
|
if (password) handleUpdatePassword(user.id, password);
|
||||||
}}
|
}}
|
||||||
className="bg-gray-500 px-2 py-1 font-medium text-white hover:bg-gray-800 dark:text-gray-800 dark:hover:bg-gray-100"
|
className="bg-primary-500 px-2 py-1 font-medium text-primary-foreground hover:bg-primary-700"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="flex min-w-40 justify-center gap-2 border-b border-gray-200 p-3 text-center">
|
<td className="flex min-w-40 justify-center gap-2 border-b border-border p-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleAdmin(user.id, true)}
|
onClick={() => handleToggleAdmin(user.id, true)}
|
||||||
disabled={user.admin}
|
disabled={user.admin}
|
||||||
className={`rounded-md px-2 py-1 text-white dark:text-black ${
|
className={`rounded-md px-2 py-1 ${
|
||||||
user.admin
|
user.admin
|
||||||
? 'cursor-default bg-gray-800 dark:bg-gray-100'
|
? 'cursor-default bg-content text-content-inverse'
|
||||||
: 'cursor-pointer bg-gray-400 dark:bg-gray-600'
|
: 'cursor-pointer bg-surface-strong text-content'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
admin
|
admin
|
||||||
@@ -223,16 +196,16 @@ export default function AdminUsersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleAdmin(user.id, false)}
|
onClick={() => handleToggleAdmin(user.id, false)}
|
||||||
disabled={!user.admin}
|
disabled={!user.admin}
|
||||||
className={`rounded-md px-2 py-1 text-white dark:text-black ${
|
className={`rounded-md px-2 py-1 ${
|
||||||
!user.admin
|
!user.admin
|
||||||
? 'cursor-default bg-gray-800 dark:bg-gray-100'
|
? 'cursor-default bg-content text-content-inverse'
|
||||||
: 'cursor-pointer bg-gray-400 dark:bg-gray-600'
|
: 'cursor-pointer bg-surface-strong text-content'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
user
|
user
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<p>{user.created_at}</p>
|
<p>{user.created_at}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -38,17 +38,16 @@ export default function ComponentDemoPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 p-4">
|
<div className="space-y-8 p-4 text-content">
|
||||||
<h1 className="text-2xl font-bold dark:text-white">UI Components Demo</h1>
|
<h1 className="text-2xl font-bold">UI Components Demo</h1>
|
||||||
|
|
||||||
{/* Toast Demos */}
|
<section className="rounded-lg bg-surface p-6 shadow">
|
||||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
<h2 className="mb-4 text-xl font-semibold">Toast Notifications</h2>
|
||||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Toast Notifications</h2>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleDemoClick}
|
onClick={handleDemoClick}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
|
className="rounded bg-secondary-500 px-4 py-2 text-secondary-foreground hover:bg-secondary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
|
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
|
||||||
</button>
|
</button>
|
||||||
@@ -66,21 +65,19 @@ export default function ComponentDemoPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCustomToast}
|
onClick={handleCustomToast}
|
||||||
className="rounded bg-purple-500 px-4 py-2 text-white hover:bg-purple-600"
|
className="rounded bg-primary-500 px-4 py-2 text-primary-foreground hover:bg-primary-600"
|
||||||
>
|
>
|
||||||
Show Custom Toast
|
Show Custom Toast
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Skeleton Demos */}
|
<section className="rounded-lg bg-surface p-6 shadow">
|
||||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
<h2 className="mb-4 text-xl font-semibold">Skeleton Loading Components</h2>
|
||||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Loading Components</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
{/* Basic Skeletons */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium dark:text-gray-300">Basic Skeletons</h3>
|
<h3 className="text-lg font-medium text-content-muted">Basic Skeletons</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-full" />
|
<Skeleton className="h-8 w-full" />
|
||||||
<Skeleton variant="text" className="w-3/4" />
|
<Skeleton variant="text" className="w-3/4" />
|
||||||
@@ -92,16 +89,14 @@ export default function ComponentDemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skeleton Text */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Text</h3>
|
<h3 className="text-lg font-medium text-content-muted">Skeleton Text</h3>
|
||||||
<SkeletonText lines={3} />
|
<SkeletonText lines={3} />
|
||||||
<SkeletonText lines={5} className="max-w-md" />
|
<SkeletonText lines={5} className="max-w-md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skeleton Avatar */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Avatar</h3>
|
<h3 className="text-lg font-medium text-content-muted">Skeleton Avatar</h3>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<SkeletonAvatar size="sm" />
|
<SkeletonAvatar size="sm" />
|
||||||
<SkeletonAvatar size="md" />
|
<SkeletonAvatar size="md" />
|
||||||
@@ -110,9 +105,8 @@ export default function ComponentDemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skeleton Button */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Button</h3>
|
<h3 className="text-lg font-medium text-content-muted">Skeleton Button</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<SkeletonButton width={120} />
|
<SkeletonButton width={120} />
|
||||||
<SkeletonButton className="w-full max-w-xs" />
|
<SkeletonButton className="w-full max-w-xs" />
|
||||||
@@ -121,9 +115,8 @@ export default function ComponentDemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Skeleton Card Demo */}
|
<section className="rounded-lg bg-surface p-6 shadow">
|
||||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
<h2 className="mb-4 text-xl font-semibold">Skeleton Cards</h2>
|
||||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Cards</h2>
|
|
||||||
<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">
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
<SkeletonCard showAvatar />
|
<SkeletonCard showAvatar />
|
||||||
@@ -131,33 +124,30 @@ export default function ComponentDemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Skeleton Table Demo */}
|
<section className="rounded-lg bg-surface p-6 shadow">
|
||||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
<h2 className="mb-4 text-xl font-semibold">Skeleton Table</h2>
|
||||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Skeleton Table</h2>
|
|
||||||
<SkeletonTable rows={5} columns={4} />
|
<SkeletonTable rows={5} columns={4} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Page Loader Demo */}
|
<section className="rounded-lg bg-surface p-6 shadow">
|
||||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
<h2 className="mb-4 text-xl font-semibold">Page Loader</h2>
|
||||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Page Loader</h2>
|
|
||||||
<PageLoader message="Loading demo content..." />
|
<PageLoader message="Loading demo content..." />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Inline Loader Demo */}
|
<section className="rounded-lg bg-surface p-6 shadow">
|
||||||
<section className="rounded-lg bg-white p-6 shadow dark:bg-gray-700">
|
<h2 className="mb-4 text-xl font-semibold">Inline Loader</h2>
|
||||||
<h2 className="mb-4 text-xl font-semibold dark:text-white">Inline Loader</h2>
|
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<InlineLoader size="sm" />
|
<InlineLoader size="sm" />
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Small</p>
|
<p className="mt-2 text-sm text-content-muted">Small</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<InlineLoader size="md" />
|
<InlineLoader size="md" />
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Medium</p>
|
<p className="mt-2 text-sm text-content-muted">Medium</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<InlineLoader size="lg" />
|
<InlineLoader size="lg" />
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Large</p>
|
<p className="mt-2 text-sm text-content-muted">Large</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
useGetDocument,
|
useGetDocument,
|
||||||
useEditDocument,
|
useEditDocument,
|
||||||
@@ -6,7 +8,6 @@ import {
|
|||||||
} from '../generated/anthoLumeAPIV1';
|
} from '../generated/anthoLumeAPIV1';
|
||||||
import { Document } from '../generated/model/document';
|
import { Document } from '../generated/model/document';
|
||||||
import { Progress } from '../generated/model/progress';
|
import { Progress } from '../generated/model/progress';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import {
|
import {
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
@@ -18,9 +19,14 @@ import {
|
|||||||
CloseIcon,
|
CloseIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import { useState } from 'react';
|
|
||||||
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
||||||
|
|
||||||
|
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
||||||
|
const popupClassName = 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||||
|
const popupInputClassName = 'rounded bg-surface p-2 text-content';
|
||||||
|
const editInputClassName =
|
||||||
|
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
||||||
|
|
||||||
export default function DocumentPage() {
|
export default function DocumentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -35,18 +41,16 @@ export default function DocumentPage() {
|
|||||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||||
|
|
||||||
// Edit values - initialized after document is loaded
|
|
||||||
const [editTitle, setEditTitle] = useState('');
|
const [editTitle, setEditTitle] = useState('');
|
||||||
const [editAuthor, setEditAuthor] = useState('');
|
const [editAuthor, setEditAuthor] = useState('');
|
||||||
const [editDescription, setEditDescription] = useState('');
|
const [editDescription, setEditDescription] = useState('');
|
||||||
|
|
||||||
if (docLoading) {
|
if (docLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for successful response (status 200)
|
|
||||||
if (!docData || docData.status !== 200) {
|
if (!docData || docData.status !== 200) {
|
||||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
return <div className="text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = docData.data.document as Document;
|
const document = docData.data.document as Document;
|
||||||
@@ -54,7 +58,7 @@ export default function DocumentPage() {
|
|||||||
docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
|
docData?.status === 200 ? (docData.data.progress as Progress | undefined) : undefined;
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
return <div className="text-content-muted">Document not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const percentage =
|
const percentage =
|
||||||
@@ -62,24 +66,18 @@ export default function DocumentPage() {
|
|||||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||||
|
|
||||||
// Helper to start editing
|
|
||||||
const startEditing = (field: 'title' | 'author' | 'description') => {
|
const startEditing = (field: 'title' | 'author' | 'description') => {
|
||||||
if (field === 'title') setEditTitle(document.title);
|
if (field === 'title') setEditTitle(document.title);
|
||||||
if (field === 'author') setEditAuthor(document.author);
|
if (field === 'author') setEditAuthor(document.author);
|
||||||
if (field === 'description') setEditDescription(document.description || '');
|
if (field === 'description') setEditDescription(document.description || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save edit handlers
|
|
||||||
const saveTitle = () => {
|
const saveTitle = () => {
|
||||||
editMutation.mutate(
|
editMutation.mutate(
|
||||||
{
|
{ id: document.id, data: { title: editTitle } },
|
||||||
id: document.id,
|
|
||||||
data: { title: editTitle },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onSuccess: response => {
|
onSuccess: response => {
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
// Update cache with the response data (no refetch needed)
|
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||||
},
|
},
|
||||||
onError: () => setIsEditingTitle(false),
|
onError: () => setIsEditingTitle(false),
|
||||||
@@ -89,14 +87,10 @@ export default function DocumentPage() {
|
|||||||
|
|
||||||
const saveAuthor = () => {
|
const saveAuthor = () => {
|
||||||
editMutation.mutate(
|
editMutation.mutate(
|
||||||
{
|
{ id: document.id, data: { author: editAuthor } },
|
||||||
id: document.id,
|
|
||||||
data: { author: editAuthor },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onSuccess: response => {
|
onSuccess: response => {
|
||||||
setIsEditingAuthor(false);
|
setIsEditingAuthor(false);
|
||||||
// Update cache with the response data (no refetch needed)
|
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||||
},
|
},
|
||||||
onError: () => setIsEditingAuthor(false),
|
onError: () => setIsEditingAuthor(false),
|
||||||
@@ -106,14 +100,10 @@ export default function DocumentPage() {
|
|||||||
|
|
||||||
const saveDescription = () => {
|
const saveDescription = () => {
|
||||||
editMutation.mutate(
|
editMutation.mutate(
|
||||||
{
|
{ id: document.id, data: { description: editDescription } },
|
||||||
id: document.id,
|
|
||||||
data: { description: editDescription },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onSuccess: response => {
|
onSuccess: response => {
|
||||||
setIsEditingDescription(false);
|
setIsEditingDescription(false);
|
||||||
// Update cache with the response data (no refetch needed)
|
|
||||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||||
},
|
},
|
||||||
onError: () => setIsEditingDescription(false),
|
onError: () => setIsEditingDescription(false),
|
||||||
@@ -123,10 +113,8 @@ export default function DocumentPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative size-full">
|
<div className="relative size-full">
|
||||||
<div className="size-full overflow-scroll rounded bg-white p-4 shadow-lg dark:bg-gray-700 dark:text-white">
|
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
|
||||||
{/* Document Info - Left Column */}
|
|
||||||
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
||||||
{/* Cover Image with Edit Label */}
|
|
||||||
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
||||||
<img
|
<img
|
||||||
className="w-full rounded object-fill"
|
className="w-full rounded object-fill"
|
||||||
@@ -135,31 +123,27 @@ export default function DocumentPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Read Button - Only if file exists */}
|
|
||||||
{document.filepath && (
|
{document.filepath && (
|
||||||
<a
|
<a
|
||||||
href={`/reader#id=${document.id}&type=REMOTE`}
|
href={`/reader#id=${document.id}&type=REMOTE`}
|
||||||
className="z-10 mt-2 w-full rounded bg-blue-700 py-1 text-center text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700"
|
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-white hover:bg-secondary-800 focus:outline-none focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700"
|
||||||
>
|
>
|
||||||
Read
|
Read
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons Container */}
|
|
||||||
<div className="relative z-20 flex flex-wrap-reverse justify-between gap-2">
|
<div className="relative z-20 flex flex-wrap-reverse justify-between gap-2">
|
||||||
{/* ISBN Info */}
|
|
||||||
<div className="min-w-[50%] md:mr-2">
|
<div className="min-w-[50%] md:mr-2">
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-gray-500">ISBN-10:</p>
|
<p className="text-content-muted">ISBN-10:</p>
|
||||||
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 text-sm">
|
<div className="flex gap-1 text-sm">
|
||||||
<p className="text-gray-500">ISBN-13:</p>
|
<p className="text-content-muted">ISBN-13:</p>
|
||||||
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Cover Dropdown */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -169,35 +153,24 @@ export default function DocumentPage() {
|
|||||||
onChange={e => setShowEditCover(e.target.checked)}
|
onChange={e => setShowEditCover(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 ${popupClassName} ${
|
||||||
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
|
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white">
|
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||||
<input
|
<input type="file" id="cover_file" name="cover_file" className={popupInputClassName} />
|
||||||
type="file"
|
|
||||||
id="cover_file"
|
|
||||||
name="cover_file"
|
|
||||||
className="bg-gray-300 p-2"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||||
>
|
>
|
||||||
Upload Cover
|
Upload Cover
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form className="flex w-72 flex-col gap-2 text-sm text-black dark:text-white">
|
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||||
<input
|
<input type="checkbox" checked id="remove_cover" name="remove_cover" className="hidden" />
|
||||||
type="checkbox"
|
|
||||||
checked
|
|
||||||
id="remove_cover"
|
|
||||||
name="remove_cover"
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||||
>
|
>
|
||||||
Remove Cover
|
Remove Cover
|
||||||
</button>
|
</button>
|
||||||
@@ -205,24 +178,22 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icons Container */}
|
<div className="relative my-auto flex grow justify-between text-content-muted">
|
||||||
<div className="relative my-auto flex grow justify-between text-gray-500 dark:text-gray-500">
|
|
||||||
{/* Delete Button */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowDelete(!showDelete)}
|
onClick={() => setShowDelete(!showDelete)}
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
>
|
>
|
||||||
<DeleteIcon size={28} />
|
<DeleteIcon size={28} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-7 left-5 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||||
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
|
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<form className="w-24 text-sm text-black dark:text-white">
|
<form className="w-24 text-sm">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
|
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
|
||||||
@@ -233,38 +204,36 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity Button */}
|
|
||||||
<a
|
<a
|
||||||
href={`/activity?document=${document.id}`}
|
href={`/activity?document=${document.id}`}
|
||||||
aria-label="Activity"
|
aria-label="Activity"
|
||||||
className="hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
<ActivityIcon size={28} />
|
<ActivityIcon size={28} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Identify/Search Button */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowIdentify(!showIdentify)}
|
onClick={() => setShowIdentify(!showIdentify)}
|
||||||
aria-label="Identify"
|
aria-label="Identify"
|
||||||
className="hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
<SearchIcon size={28} />
|
<SearchIcon size={28} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-7 left-5 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||||
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
|
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<form className="flex flex-col gap-2 text-sm text-black dark:text-white">
|
<form className="flex flex-col gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
defaultValue={document.title}
|
defaultValue={document.title}
|
||||||
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
className={popupInputClassName}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -272,7 +241,7 @@ export default function DocumentPage() {
|
|||||||
name="author"
|
name="author"
|
||||||
placeholder="Author"
|
placeholder="Author"
|
||||||
defaultValue={document.author}
|
defaultValue={document.author}
|
||||||
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
className={popupInputClassName}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -280,11 +249,11 @@ export default function DocumentPage() {
|
|||||||
name="isbn"
|
name="isbn"
|
||||||
placeholder="ISBN 10 / ISBN 13"
|
placeholder="ISBN 10 / ISBN 13"
|
||||||
defaultValue={document.isbn13 || document.isbn10}
|
defaultValue={document.isbn13 || document.isbn10}
|
||||||
className="rounded bg-gray-300 p-2 text-black dark:bg-gray-700 dark:text-white"
|
className={popupInputClassName}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded bg-blue-700 px-2 py-1 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600"
|
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||||
>
|
>
|
||||||
Identify
|
Identify
|
||||||
</button>
|
</button>
|
||||||
@@ -292,17 +261,16 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download Button */}
|
|
||||||
{document.filepath ? (
|
{document.filepath ? (
|
||||||
<a
|
<a
|
||||||
href={`/api/v1/documents/${document.id}/file`}
|
href={`/api/v1/documents/${document.id}/file`}
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
className="hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
>
|
>
|
||||||
<DownloadIcon size={28} />
|
<DownloadIcon size={28} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-200 dark:text-gray-600">
|
<span className="text-content-subtle">
|
||||||
<DownloadIcon size={28} disabled />
|
<DownloadIcon size={28} disabled />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -310,9 +278,7 @@ export default function DocumentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document Details Grid */}
|
|
||||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||||
{/* Title - Editable */}
|
|
||||||
<Field
|
<Field
|
||||||
isEditing={isEditingTitle}
|
isEditing={isEditingTitle}
|
||||||
label={
|
label={
|
||||||
@@ -321,20 +287,10 @@ export default function DocumentPage() {
|
|||||||
<FieldActions>
|
<FieldActions>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button type="button" onClick={() => setIsEditingTitle(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||||
type="button"
|
|
||||||
onClick={() => setIsEditingTitle(false)}
|
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label="Cancel edit"
|
|
||||||
>
|
|
||||||
<CloseIcon size={18} />
|
<CloseIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={saveTitle} className={iconButtonClassName} aria-label="Confirm edit">
|
||||||
type="button"
|
|
||||||
onClick={saveTitle}
|
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label="Confirm edit"
|
|
||||||
>
|
|
||||||
<CheckIcon size={18} />
|
<CheckIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,7 +301,7 @@ export default function DocumentPage() {
|
|||||||
startEditing('title');
|
startEditing('title');
|
||||||
setIsEditingTitle(true);
|
setIsEditingTitle(true);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
aria-label="Edit title"
|
aria-label="Edit title"
|
||||||
>
|
>
|
||||||
<EditIcon size={18} />
|
<EditIcon size={18} />
|
||||||
@@ -357,19 +313,13 @@ export default function DocumentPage() {
|
|||||||
>
|
>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
<div className="relative mt-1 flex gap-2">
|
<div className="relative mt-1 flex gap-2">
|
||||||
<input
|
<input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)} className={editInputClassName} />
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
|
||||||
className="w-full rounded border border-blue-200 bg-blue-50 p-2 text-lg font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FieldValue>{document.title}</FieldValue>
|
<FieldValue>{document.title}</FieldValue>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Author - Editable */}
|
|
||||||
<Field
|
<Field
|
||||||
isEditing={isEditingAuthor}
|
isEditing={isEditingAuthor}
|
||||||
label={
|
label={
|
||||||
@@ -378,20 +328,10 @@ export default function DocumentPage() {
|
|||||||
<FieldActions>
|
<FieldActions>
|
||||||
{isEditingAuthor ? (
|
{isEditingAuthor ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button type="button" onClick={() => setIsEditingAuthor(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||||
type="button"
|
|
||||||
onClick={() => setIsEditingAuthor(false)}
|
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label="Cancel edit"
|
|
||||||
>
|
|
||||||
<CloseIcon size={18} />
|
<CloseIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={saveAuthor} className={iconButtonClassName} aria-label="Confirm edit">
|
||||||
type="button"
|
|
||||||
onClick={saveAuthor}
|
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label="Confirm edit"
|
|
||||||
>
|
|
||||||
<CheckIcon size={18} />
|
<CheckIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -402,7 +342,7 @@ export default function DocumentPage() {
|
|||||||
startEditing('author');
|
startEditing('author');
|
||||||
setIsEditingAuthor(true);
|
setIsEditingAuthor(true);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
aria-label="Edit author"
|
aria-label="Edit author"
|
||||||
>
|
>
|
||||||
<EditIcon size={18} />
|
<EditIcon size={18} />
|
||||||
@@ -414,19 +354,13 @@ export default function DocumentPage() {
|
|||||||
>
|
>
|
||||||
{isEditingAuthor ? (
|
{isEditingAuthor ? (
|
||||||
<div className="relative mt-1 flex gap-2">
|
<div className="relative mt-1 flex gap-2">
|
||||||
<input
|
<input type="text" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} className={editInputClassName} />
|
||||||
type="text"
|
|
||||||
value={editAuthor}
|
|
||||||
onChange={e => setEditAuthor(e.target.value)}
|
|
||||||
className="w-full rounded border border-blue-200 bg-blue-50 p-2 text-lg font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FieldValue>{document.author}</FieldValue>
|
<FieldValue>{document.author}</FieldValue>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Time Read with Info Dropdown */}
|
|
||||||
<Field
|
<Field
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
@@ -434,31 +368,27 @@ export default function DocumentPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
||||||
className="my-auto cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
className={`${iconButtonClassName} my-auto`}
|
||||||
aria-label="Show time read info"
|
aria-label="Show time read info"
|
||||||
>
|
>
|
||||||
<InfoIcon size={18} />
|
<InfoIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`absolute right-0 top-7 z-30 rounded bg-gray-200 p-3 shadow-lg transition-all duration-200 dark:bg-gray-600 ${
|
className={`absolute right-0 top-7 z-30 ${popupClassName} ${
|
||||||
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-gray-400">Seconds / Percent</p>
|
<p className="w-32 text-content-subtle">Seconds / Percent</p>
|
||||||
<p className="font-medium dark:text-white">
|
<p className="font-medium">{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}</p>
|
||||||
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-gray-400">Words / Minute</p>
|
<p className="w-32 text-content-subtle">Words / Minute</p>
|
||||||
<p className="font-medium dark:text-white">
|
<p className="font-medium">{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}</p>
|
||||||
{document.wpm && document.wpm > 0 ? document.wpm : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">
|
||||||
<p className="w-32 text-gray-400">Est. Time Left</p>
|
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
||||||
<p className="whitespace-nowrap font-medium dark:text-white">
|
<p className="whitespace-nowrap font-medium">
|
||||||
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
|
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,13 +403,11 @@ export default function DocumentPage() {
|
|||||||
</FieldValue>
|
</FieldValue>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
<Field label={<FieldLabel>Progress</FieldLabel>}>
|
<Field label={<FieldLabel>Progress</FieldLabel>}>
|
||||||
<FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
|
<FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description - Editable */}
|
|
||||||
<Field
|
<Field
|
||||||
isEditing={isEditingDescription}
|
isEditing={isEditingDescription}
|
||||||
label={
|
label={
|
||||||
@@ -488,20 +416,10 @@ export default function DocumentPage() {
|
|||||||
<FieldActions>
|
<FieldActions>
|
||||||
{isEditingDescription ? (
|
{isEditingDescription ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button type="button" onClick={() => setIsEditingDescription(false)} className={iconButtonClassName} aria-label="Cancel edit">
|
||||||
type="button"
|
|
||||||
onClick={() => setIsEditingDescription(false)}
|
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label="Cancel edit"
|
|
||||||
>
|
|
||||||
<CloseIcon size={18} />
|
<CloseIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={saveDescription} className={iconButtonClassName} aria-label="Confirm edit">
|
||||||
type="button"
|
|
||||||
onClick={saveDescription}
|
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
|
||||||
aria-label="Confirm edit"
|
|
||||||
>
|
|
||||||
<CheckIcon size={18} />
|
<CheckIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -512,7 +430,7 @@ export default function DocumentPage() {
|
|||||||
startEditing('description');
|
startEditing('description');
|
||||||
setIsEditingDescription(true);
|
setIsEditingDescription(true);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
className={iconButtonClassName}
|
||||||
aria-label="Edit description"
|
aria-label="Edit description"
|
||||||
>
|
>
|
||||||
<EditIcon size={18} />
|
<EditIcon size={18} />
|
||||||
@@ -527,14 +445,12 @@ export default function DocumentPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={e => setEditDescription(e.target.value)}
|
onChange={e => setEditDescription(e.target.value)}
|
||||||
className="h-32 w-full grow rounded border border-blue-200 bg-blue-50 p-2 font-medium text-black focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-700 dark:bg-blue-900/20 dark:text-white dark:focus:ring-blue-500"
|
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-none focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
||||||
rows={5}
|
rows={5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FieldValue className="hyphens-auto text-justify">
|
<FieldValue className="hyphens-auto text-justify">{document.description || 'N/A'}</FieldValue>
|
||||||
{document.description || 'N/A'}
|
|
||||||
</FieldValue>
|
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,29 +17,24 @@ interface InfoCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({ title, size, link }: InfoCardProps) {
|
function InfoCard({ title, size, link }: InfoCardProps) {
|
||||||
|
const content = (
|
||||||
|
<div className="flex w-full gap-4 rounded bg-surface p-4 shadow-lg">
|
||||||
|
<div className="flex w-full flex-col justify-around text-sm text-content">
|
||||||
|
<p className="text-2xl font-bold">{size}</p>
|
||||||
|
<p className="text-sm text-content-subtle">{title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (link) {
|
if (link) {
|
||||||
return (
|
return (
|
||||||
<Link to={link} className="w-full">
|
<Link to={link} className="w-full">
|
||||||
<div className="flex w-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
{content}
|
||||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
|
||||||
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
|
|
||||||
<p className="text-sm text-gray-400">{title}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <div className="w-full">{content}</div>;
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex w-full gap-4 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
|
|
||||||
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
|
|
||||||
<p className="text-2xl font-bold text-black dark:text-white">{size}</p>
|
|
||||||
<p className="text-sm text-gray-400">{title}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StreakCardProps {
|
interface StreakCardProps {
|
||||||
@@ -63,18 +58,18 @@ function StreakCard({
|
|||||||
}: StreakCardProps) {
|
}: 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-surface px-4 py-6 text-content shadow-lg">
|
||||||
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||||
</p>
|
</p>
|
||||||
<div className="my-6 flex items-end space-x-2">
|
<div className="my-6 flex items-end space-x-2">
|
||||||
<p className="text-5xl font-bold text-black dark:text-white">{currentStreak}</p>
|
<p className="text-5xl font-bold">{currentStreak}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="dark:text-white">
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2 text-sm">
|
<div className="mb-2 flex items-center justify-between border-b border-border pb-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||||
<div className="flex items-end text-sm text-gray-400">
|
<div className="flex items-end text-sm text-content-subtle">
|
||||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +78,7 @@ function StreakCard({
|
|||||||
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
<div className="mb-2 flex items-center justify-between pb-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||||
<div className="flex items-end text-sm text-gray-400">
|
<div className="flex items-end text-sm text-content-subtle">
|
||||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,67 +115,47 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
|||||||
|
|
||||||
const currentData = data[selectedPeriod];
|
const currentData = data[selectedPeriod];
|
||||||
|
|
||||||
const handlePeriodChange = (period: TimePeriod) => {
|
const getPeriodClassName = (period: TimePeriod) =>
|
||||||
setSelectedPeriod(period);
|
`cursor-pointer ${selectedPeriod === period ? 'text-content' : 'text-content-subtle hover:text-content'}`;
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex size-full flex-col justify-between rounded bg-white px-4 py-6 shadow-lg dark:bg-gray-700">
|
<div className="flex size-full flex-col justify-between rounded bg-surface px-4 py-6 text-content shadow-lg">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
<p className="w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||||
{name} Leaderboard
|
{name} Leaderboard
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<button
|
<button type="button" onClick={() => setSelectedPeriod('all')} className={getPeriodClassName('all')}>
|
||||||
type="button"
|
|
||||||
onClick={() => handlePeriodChange('all')}
|
|
||||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'all' ? '!text-black dark:!text-white' : ''}`}
|
|
||||||
>
|
|
||||||
all
|
all
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={() => setSelectedPeriod('year')} className={getPeriodClassName('year')}>
|
||||||
type="button"
|
|
||||||
onClick={() => handlePeriodChange('year')}
|
|
||||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'year' ? '!text-black dark:!text-white' : ''}`}
|
|
||||||
>
|
|
||||||
year
|
year
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={() => setSelectedPeriod('month')} className={getPeriodClassName('month')}>
|
||||||
type="button"
|
|
||||||
onClick={() => handlePeriodChange('month')}
|
|
||||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'month' ? '!text-black dark:!text-white' : ''}`}
|
|
||||||
>
|
|
||||||
month
|
month
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={() => setSelectedPeriod('week')} className={getPeriodClassName('week')}>
|
||||||
type="button"
|
|
||||||
onClick={() => handlePeriodChange('week')}
|
|
||||||
className={`cursor-pointer hover:text-black dark:hover:text-white ${selectedPeriod === 'week' ? '!text-black dark:!text-white' : ''}`}
|
|
||||||
>
|
|
||||||
week
|
week
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current period data */}
|
|
||||||
<div className="my-6 flex items-end space-x-2">
|
<div className="my-6 flex items-end space-x-2">
|
||||||
{currentData?.length === 0 ? (
|
{currentData?.length === 0 ? (
|
||||||
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
|
<p className="text-5xl font-bold">N/A</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-5xl font-bold text-black dark:text-white">
|
<p className="text-5xl font-bold">{currentData[0]?.user_id || 'N/A'}</p>
|
||||||
{currentData[0]?.user_id || 'N/A'}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dark:text-white">
|
<div>
|
||||||
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-border' : ''}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p>{item.user_id}</p>
|
<p>{item.user_id}</p>
|
||||||
@@ -204,22 +179,20 @@ export default function HomePage() {
|
|||||||
const userStats = homeResponse?.user_statistics;
|
const userStats = homeResponse?.user_statistics;
|
||||||
|
|
||||||
if (homeLoading) {
|
if (homeLoading) {
|
||||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
return <div className="text-content-muted">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Daily Read Totals Graph */}
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative w-full rounded bg-white shadow-lg dark:bg-gray-700">
|
<div className="relative w-full rounded bg-surface shadow-lg">
|
||||||
<p className="absolute left-5 top-3 w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
|
<p className="absolute left-5 top-3 w-max border-b border-border text-sm font-semibold text-content-muted">
|
||||||
Daily Read Totals
|
Daily Read Totals
|
||||||
</p>
|
</p>
|
||||||
<ReadingHistoryGraph data={graphData || []} />
|
<ReadingHistoryGraph data={graphData || []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 title="Documents" size={dbInfo?.documents_size || 0} link="./documents" />
|
<InfoCard title="Documents" size={dbInfo?.documents_size || 0} link="./documents" />
|
||||||
<InfoCard title="Activity Records" size={dbInfo?.activity_size || 0} link="./activity" />
|
<InfoCard title="Activity Records" size={dbInfo?.activity_size || 0} link="./activity" />
|
||||||
@@ -227,7 +200,6 @@ export default function HomePage() {
|
|||||||
<InfoCard title="Devices" size={dbInfo?.devices_size || 0} />
|
<InfoCard title="Devices" size={dbInfo?.devices_size || 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Streak Cards */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{streaks?.map((streak: UserStreak, index: number) => (
|
{streaks?.map((streak: UserStreak, index: number) => (
|
||||||
<StreakCard
|
<StreakCard
|
||||||
@@ -243,7 +215,6 @@ export default function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard Cards */}
|
|
||||||
<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"
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ export default function ProgressPage() {
|
|||||||
key: 'document_id' as const,
|
key: 'document_id' as const,
|
||||||
header: 'Document',
|
header: 'Document',
|
||||||
render: (_value, row) => (
|
render: (_value, row) => (
|
||||||
<Link
|
<Link to={`/documents/${row.document_id}`} className="text-secondary-600 hover:underline">
|
||||||
to={`/documents/${row.document_id}`}
|
|
||||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function RegisterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
|
<div className="min-h-screen bg-canvas text-content">
|
||||||
<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 className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
<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">
|
||||||
@@ -61,7 +61,7 @@ export default function RegisterPage() {
|
|||||||
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-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
required
|
required
|
||||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||||
@@ -74,7 +74,7 @@ export default function RegisterPage() {
|
|||||||
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-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
required
|
required
|
||||||
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
disabled={isLoading || isLoadingInfo || !registrationEnabled}
|
||||||
@@ -106,8 +106,8 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
|
||||||
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
|
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
|
||||||
<span className="text-gray-500">AnthoLume</span>
|
<span className="text-content-muted">AnthoLume</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,30 +49,30 @@ export function SearchPageView({
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||||
<div className="flex grow flex-col gap-4">
|
<div className="flex grow flex-col gap-4">
|
||||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
|
||||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||||
<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 className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||||
<Search2Icon size={15} hoverable={false} />
|
<Search2Icon size={15} hoverable={false} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => onQueryChange(e.target.value)}
|
onChange={e => onQueryChange(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-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||||
placeholder="Query"
|
placeholder="Query"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex min-w-[12em]">
|
<div className="relative flex min-w-[12em]">
|
||||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
|
||||||
<BookIcon size={15} />
|
<BookIcon size={15} />
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
onChange={e => onSourceChange(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-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
|
||||||
>
|
>
|
||||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||||
@@ -87,28 +87,28 @@ export function SearchPageView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
<table className="min-w-full bg-white text-sm leading-normal md:text-sm dark:bg-gray-700">
|
<table className="min-w-full bg-surface text-sm leading-normal text-content md:text-sm">
|
||||||
<thead className="text-gray-800 dark:text-gray-400">
|
<thead className="text-content-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-12 border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800"></th>
|
<th className="w-12 border-b border-border p-3 text-left font-normal uppercase"></th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Document
|
Document
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Series
|
Series
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
|
<th className="border-b border-border p-3 text-left font-normal uppercase">
|
||||||
Size
|
Size
|
||||||
</th>
|
</th>
|
||||||
<th className="hidden border-b border-gray-200 p-3 text-left font-normal uppercase md:block dark:border-gray-800">
|
<th className="hidden border-b border-border p-3 text-left font-normal uppercase md:table-cell">
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-black dark:text-white">
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="p-3 text-center" colSpan={6}>
|
<td className="p-3 text-center" colSpan={6}>
|
||||||
@@ -126,24 +126,24 @@ export function SearchPageView({
|
|||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
results.map(item => (
|
results.map(item => (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
<td className="border-b border-border p-3 text-content-muted">
|
||||||
<button className="hover:text-purple-600" title="Download">
|
<button className="hover:text-primary-600" title="Download">
|
||||||
<DownloadIcon size={15} />
|
<DownloadIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
{item.author || 'N/A'} - {item.title || 'N/A'}
|
{item.author || 'N/A'} - {item.title || 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<p>{item.series || 'N/A'}</p>
|
<p>{item.series || 'N/A'}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="border-b border-gray-200 p-3">
|
<td className="border-b border-border p-3">
|
||||||
<p>{item.file_type || '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-border p-3">
|
||||||
<p>{item.file_size || 'N/A'}</p>
|
<p>{item.file_size || 'N/A'}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden border-b border-gray-200 p-3 md:table-cell">
|
<td className="hidden border-b border-border p-3 md:table-cell">
|
||||||
<p>{item.upload_date || 'N/A'}</p>
|
<p>{item.upload_date || 'N/A'}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -172,7 +172,7 @@ export default function SearchPage() {
|
|||||||
query: {
|
query: {
|
||||||
enabled: activeQuery.trim().length > 0,
|
enabled: activeQuery.trim().length > 0,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
const results = getSearchResults(data);
|
const results = getSearchResults(data);
|
||||||
|
|
||||||
|
|||||||
@@ -74,42 +74,42 @@ 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">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
|
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
|
||||||
<div className="mb-4 size-16 rounded-full bg-gray-200 dark:bg-gray-600" />
|
<div className="mb-4 size-16 rounded-full bg-surface-strong" />
|
||||||
<div className="h-6 w-32 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-6 w-32 rounded bg-surface-strong" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex grow flex-col gap-4">
|
<div className="flex grow flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||||
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||||
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||||
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-10 w-40 rounded bg-surface-strong" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||||
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-12 flex-1 rounded bg-surface-strong" />
|
||||||
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-10 w-40 rounded bg-surface-strong" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
|
||||||
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="mb-4 h-6 w-48 rounded bg-surface-strong" />
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
{themeModes.map(mode => (
|
{themeModes.map(mode => (
|
||||||
<div key={mode.value} className="h-24 rounded bg-gray-200 dark:bg-gray-600" />
|
<div key={mode.value} className="h-24 rounded bg-surface-strong" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
|
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
|
||||||
<div className="mb-4 h-6 w-24 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="mb-4 h-6 w-24 rounded bg-surface-strong" />
|
||||||
<div className="mb-4 flex gap-4">
|
<div className="mb-4 flex gap-4">
|
||||||
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||||
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||||
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-6 flex-1 rounded bg-surface-strong" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-32 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
|
<div className="h-32 flex-1 rounded bg-surface-strong" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user