This commit is contained in:
2026-03-16 09:59:56 -04:00
parent ecf77fd105
commit 5cb17bace7
34 changed files with 4104 additions and 153 deletions

View File

@@ -2,11 +2,13 @@ import { useState } from 'react';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { FolderOpen } from 'lucide-react';
import { useToasts } from '../components/ToastContext';
export default function AdminImportPage() {
const [currentPath, setCurrentPath] = useState<string>('');
const [selectedDirectory, setSelectedDirectory] = useState<string>('');
const [importType, setImportType] = useState<'DIRECT' | 'COPY'>('DIRECT');
const { showInfo, showError } = useToasts();
const { data: directoryData, isLoading } = useGetImportDirectory(
currentPath ? { directory: currentPath } : {}
@@ -41,13 +43,14 @@ export default function AdminImportPage() {
},
{
onSuccess: (response) => {
console.log('Import completed:', response.data);
// Redirect to import results page
window.location.href = '/admin/import-results';
showInfo('Import completed successfully');
// Redirect to import results page after a short delay
setTimeout(() => {
window.location.href = '/admin/import-results';
}, 1500);
},
onError: (error) => {
console.error('Import failed:', error);
alert('Import failed: ' + (error as any).message);
showError('Import failed: ' + (error as any).message);
},
}
);

View File

@@ -1,6 +1,7 @@
import { useState, FormEvent } from 'react';
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
interface BackupTypes {
covers: boolean;
@@ -10,14 +11,13 @@ interface BackupTypes {
export default function AdminPage() {
const { isLoading } = useGetAdmin();
const postAdminAction = usePostAdminAction();
const { showInfo, showError } = useToasts();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({
covers: false,
documents: false,
});
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleBackupSubmit = (e: FormEvent) => {
e.preventDefault();
@@ -42,12 +42,10 @@ export default function AdminPage() {
document.body.appendChild(link);
link.click();
link.remove();
setMessage('Backup completed successfully');
setErrorMessage(null);
showInfo('Backup completed successfully');
},
onError: (error) => {
setErrorMessage('Backup failed: ' + (error as any).message);
setMessage(null);
showError('Backup failed: ' + (error as any).message);
},
}
);
@@ -67,12 +65,10 @@ export default function AdminPage() {
},
{
onSuccess: () => {
setMessage('Restore completed successfully');
setErrorMessage(null);
showInfo('Restore completed successfully');
},
onError: (error) => {
setErrorMessage('Restore failed: ' + (error as any).message);
setMessage(null);
showError('Restore failed: ' + (error as any).message);
},
}
);
@@ -87,12 +83,10 @@ export default function AdminPage() {
},
{
onSuccess: () => {
setMessage('Metadata matching started');
setErrorMessage(null);
showInfo('Metadata matching started');
},
onError: (error) => {
setErrorMessage('Metadata matching failed: ' + (error as any).message);
setMessage(null);
showError('Metadata matching failed: ' + (error as any).message);
},
}
);
@@ -107,12 +101,10 @@ export default function AdminPage() {
},
{
onSuccess: () => {
setMessage('Cache tables started');
setErrorMessage(null);
showInfo('Cache tables started');
},
onError: (error) => {
setErrorMessage('Cache tables failed: ' + (error as any).message);
setMessage(null);
showError('Cache tables failed: ' + (error as any).message);
},
}
);
@@ -175,12 +167,6 @@ export default function AdminPage() {
</div>
</form>
</div>
{errorMessage && (
<span className="text-red-400 text-xs">{errorMessage}</span>
)}
{message && (
<span className="text-green-400 text-xs">{message}</span>
)}
</div>
{/* Tasks Card */}

View File

@@ -1,10 +1,12 @@
import { useState, FormEvent } from 'react';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import { Plus, Trash2 } from 'lucide-react';
import { useToasts } from '../components/ToastContext';
export default function AdminUsersPage() {
const { data: usersData, isLoading, refetch } = useGetUsers({});
const updateUser = useUpdateUser();
const { showInfo, showError } = useToasts();
const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
@@ -16,7 +18,7 @@ export default function AdminUsersPage() {
const handleCreateUser = (e: FormEvent) => {
e.preventDefault();
if (!newUsername || !newPassword) return;
updateUser.mutate(
{
data: {
@@ -28,6 +30,7 @@ export default function AdminUsersPage() {
},
{
onSuccess: () => {
showInfo('User created successfully');
setShowAddForm(false);
setNewUsername('');
setNewPassword('');
@@ -35,7 +38,7 @@ export default function AdminUsersPage() {
refetch();
},
onError: (error: any) => {
alert('Failed to create user: ' + error.message);
showError('Failed to create user: ' + error.message);
},
}
);
@@ -51,10 +54,11 @@ export default function AdminUsersPage() {
},
{
onSuccess: () => {
showInfo('User deleted successfully');
refetch();
},
onError: (error: any) => {
alert('Failed to delete user: ' + error.message);
showError('Failed to delete user: ' + error.message);
},
}
);
@@ -73,10 +77,11 @@ export default function AdminUsersPage() {
},
{
onSuccess: () => {
showInfo('Password updated successfully');
refetch();
},
onError: (error: any) => {
alert('Failed to update password: ' + error.message);
showError('Failed to update password: ' + error.message);
},
}
);
@@ -93,10 +98,12 @@ export default function AdminUsersPage() {
},
{
onSuccess: () => {
const role = isAdmin ? 'admin' : 'user';
showInfo(`User permissions updated to ${role}`);
refetch();
},
onError: (error: any) => {
alert('Failed to update admin status: ' + error.message);
showError('Failed to update admin status: ' + error.message);
},
}
);

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { useToasts } from '../components/ToastContext';
import {
Skeleton,
SkeletonText,
SkeletonAvatar,
SkeletonCard,
SkeletonTable,
SkeletonButton,
PageLoader,
InlineLoader
} from '../components/Skeleton';
export default function ComponentDemoPage() {
const { showInfo, showWarning, showError, showToast } = useToasts();
const [isLoading, setIsLoading] = useState(false);
const handleDemoClick = () => {
setIsLoading(true);
showInfo('Starting demo operation...');
setTimeout(() => {
setIsLoading(false);
showInfo('Demo operation completed successfully!');
}, 2000);
};
const handleErrorClick = () => {
showError('This is a sample error message');
};
const handleWarningClick = () => {
showWarning('This is a sample warning message', 10000);
};
const handleCustomToast = () => {
showToast('Custom toast message', 'info', 3000);
};
return (
<div className="space-y-8 p-4">
<h1 className="text-2xl font-bold dark:text-white">UI Components Demo</h1>
{/* Toast Demos */}
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4 dark:text-white">Toast Notifications</h2>
<div className="flex flex-wrap gap-4">
<button
onClick={handleDemoClick}
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? <InlineLoader size="sm" /> : 'Show Info Toast'}
</button>
<button
onClick={handleWarningClick}
className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
>
Show Warning Toast (10s)
</button>
<button
onClick={handleErrorClick}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Show Error Toast
</button>
<button
onClick={handleCustomToast}
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600"
>
Show Custom Toast
</button>
</div>
</section>
{/* Skeleton Demos */}
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4 dark:text-white">Skeleton Loading Components</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Basic Skeletons */}
<div className="space-y-4">
<h3 className="text-lg font-medium dark:text-gray-300">Basic Skeletons</h3>
<div className="space-y-2">
<Skeleton className="w-full h-8" />
<Skeleton variant="text" className="w-3/4" />
<Skeleton variant="text" className="w-1/2" />
<div className="flex items-center gap-4">
<Skeleton variant="circular" width={40} height={40} />
<Skeleton variant="rectangular" width={100} height={40} />
</div>
</div>
</div>
{/* Skeleton Text */}
<div className="space-y-4">
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Text</h3>
<SkeletonText lines={3} />
<SkeletonText lines={5} className="max-w-md" />
</div>
{/* Skeleton Avatar */}
<div className="space-y-4">
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Avatar</h3>
<div className="flex items-center gap-4">
<SkeletonAvatar size="sm" />
<SkeletonAvatar size="md" />
<SkeletonAvatar size="lg" />
<SkeletonAvatar size={72} />
</div>
</div>
{/* Skeleton Button */}
<div className="space-y-4">
<h3 className="text-lg font-medium dark:text-gray-300">Skeleton Button</h3>
<div className="flex gap-2 flex-wrap">
<SkeletonButton width={120} />
<SkeletonButton className="w-full max-w-xs" />
</div>
</div>
</div>
</section>
{/* Skeleton Card Demo */}
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4 dark:text-white">Skeleton Cards</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<SkeletonCard />
<SkeletonCard showAvatar />
<SkeletonCard showAvatar showTitle showText textLines={4} />
</div>
</section>
{/* Skeleton Table Demo */}
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4 dark:text-white">Skeleton Table</h2>
<SkeletonTable rows={5} columns={4} />
</section>
{/* Page Loader Demo */}
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4 dark:text-white">Page Loader</h2>
<PageLoader message="Loading demo content..." />
</section>
{/* Inline Loader Demo */}
<section className="bg-white dark:bg-gray-700 rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4 dark:text-white">Inline Loader</h2>
<div className="flex items-center gap-8">
<div className="text-center">
<InlineLoader size="sm" />
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Small</p>
</div>
<div className="text-center">
<InlineLoader size="md" />
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Medium</p>
</div>
<div className="text-center">
<InlineLoader size="lg" />
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Large</p>
</div>
</div>
</section>
</div>
);
}

View File

@@ -1,9 +1,57 @@
import { useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
export default function DocumentPage() {
const { id } = useParams<{ id: string }>();
interface Document {
id: string;
title: string;
author: string;
description?: string;
isbn10?: string;
isbn13?: string;
words?: number;
filepath?: string;
created_at: string;
updated_at: string;
deleted: boolean;
percentage?: number;
total_time_seconds?: number;
wpm?: number;
seconds_per_percent?: number;
last_read?: string;
}
interface Progress {
document_id?: string;
percentage?: number;
created_at?: string;
user_id?: string;
device_name?: string;
title?: string;
author?: string;
}
// Helper function to format seconds nicely (mirroring legacy niceSeconds)
function niceSeconds(seconds: number): string {
if (seconds === 0) return 'N/A';
const days = Math.floor(seconds / 60 / 60 / 24);
const remainingSeconds = seconds % (60 * 60 * 24);
const hours = Math.floor(remainingSeconds / 60 / 60);
const remainingAfterHours = remainingSeconds % (60 * 60);
const minutes = Math.floor(remainingAfterHours / 60);
const remainingSeconds2 = remainingAfterHours % 60;
let result = '';
if (days > 0) result += `${days}d `;
if (hours > 0) result += `${hours}h `;
if (minutes > 0) result += `${minutes}m `;
if (remainingSeconds2 > 0) result += `${remainingSeconds2}s`;
return result || 'N/A';
}
export default function DocumentPage() {
const { id } = useParams<{ id: string }>();
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
@@ -12,81 +60,131 @@ export default function DocumentPage() {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
}
const document = docData?.data?.document;
const progress = progressData?.data;
const document = docData?.data?.document as Document;
const progressDataArray = progressData?.data?.progress;
const progress = Array.isArray(progressDataArray) ? progressDataArray[0] as Progress : undefined;
if (!document) {
return <div className="text-gray-500 dark:text-white">Document not found</div>;
}
// Calculate total time left (mirroring legacy template logic)
const percentage = progress?.percentage || document.percentage || 0;
const secondsPerPercent = document.seconds_per_percent || 0;
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
return (
<div className="h-full w-full relative">
<div
className="h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"
>
{/* Document Info */}
{/* Document Info - Left Column */}
<div
className="flex flex-col gap-2 float-left w-44 md:w-60 lg:w-80 mr-4 mb-2 relative"
>
<div className="rounded object-fill w-full bg-gray-200 dark:bg-gray-600 h-60">
{/* Cover image placeholder */}
<div className="w-full h-full flex items-center justify-center text-gray-400">
No Cover
{/* Cover Image */}
{document.filepath && (
<div className="rounded object-fill w-full bg-gray-200 dark:bg-gray-600 h-60">
<img
className="rounded object-cover h-full"
src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`}
/>
</div>
</div>
)}
<a
href={`/reader#id=${document.id}&type=REMOTE`}
className="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700"
>
Read
</a>
{/* Read Button - Only if file exists */}
{document.filepath && (
<a
href={`/reader#id=${document.id}&type=REMOTE`}
className="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none w-full mt-2"
>
Read
</a>
)}
<div className="flex flex-wrap-reverse justify-between gap-2">
{/* Action Buttons */}
<div className="flex flex-wrap-reverse justify-between gap-2 z-20 relative my-2">
<div className="min-w-[50%] md:mr-2">
<div className="flex gap-1 text-sm">
<p className="text-gray-500">Words:</p>
<p className="font-medium">{document.words || 'N/A'}</p>
<p className="text-gray-500">ISBN-10:</p>
<p className="font-medium">{document.isbn10 || 'N/A'}</p>
</div>
<div className="flex gap-1 text-sm">
<p className="text-gray-500">ISBN-13:</p>
<p className="font-medium">{document.isbn13 || 'N/A'}</p>
</div>
</div>
{/* Download Button - Only if file exists */}
{document.filepath && (
<a
href={`/api/v1/documents/${document.id}/file`}
className="z-10 text-gray-500 dark:text-gray-400"
title="Download"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003-3h4a3 3 0 003 3v1m0-3l-3 3m0 0L4 20" />
</svg>
</a>
)}
</div>
</div>
{/* Document Details Grid */}
<div className="grid sm:grid-cols-2 justify-between gap-4 pb-4">
<div>
<p className="text-gray-500">Title</p>
<p className="font-medium text-lg">{document.title}</p>
{/* Title - Editable */}
<div className="relative">
<div className="text-gray-500 inline-flex gap-2 relative">
<p>Title</p>
</div>
<div className="relative font-medium text-justify hyphens-auto">
<p>{document.title}</p>
</div>
</div>
<div>
<p className="text-gray-500">Author</p>
<p className="font-medium text-lg">{document.author}</p>
{/* Author - Editable */}
<div className="relative">
<div className="text-gray-500 inline-flex gap-2 relative">
<p>Author</p>
</div>
<div className="relative font-medium text-justify hyphens-auto">
<p>{document.author}</p>
</div>
</div>
<div>
<p className="text-gray-500">Time Read</p>
<p className="font-medium text-lg">
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
</p>
{/* Time Read */}
<div className="relative">
<div className="text-gray-500 inline-flex gap-2 relative">
<p>Time Read</p>
</div>
<div className="relative">
<p className="font-medium text-lg">
{document.total_time_seconds ? niceSeconds(document.total_time_seconds) : 'N/A'}
</p>
</div>
</div>
{/* Progress */}
<div>
<p className="text-gray-500">Progress</p>
<p className="font-medium text-lg">
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
{percentage ? `${Math.round(percentage)}%` : '0%'}
</p>
</div>
</div>
{/* Description */}
{/* Description - Editable */}
<div className="relative">
<div className="text-gray-500 inline-flex gap-2 relative">
<p>Description</p>
</div>
<div className="relative font-medium text-justify hyphens-auto">
<p>N/A</p>
<p>{document.description || 'N/A'}</p>
</div>
</div>
{/* Stats */}
{/* Reading Statistics */}
<div className="mt-4 grid sm:grid-cols-3 gap-4">
<div>
<p className="text-gray-500">Words</p>
@@ -105,6 +203,22 @@ export default function DocumentPage() {
</p>
</div>
</div>
{/* Additional Reading Stats - Matching Legacy Template */}
{progress && (
<div className="mt-4 grid sm:grid-cols-2 gap-4">
<div className="flex gap-2 items-center">
<p className="text-gray-500">Words / Minute:</p>
<p className="font-medium">{document.wpm || 'N/A'}</p>
</div>
<div className="flex gap-2 items-center">
<p className="text-gray-500">Est. Time Left:</p>
<p className="font-medium whitespace-nowrap">
{niceSeconds(totalTimeLeftSeconds)}
</p>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import { Activity, Download, Search, Upload } from 'lucide-react';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
interface DocumentCardProps {
doc: {
@@ -101,6 +102,7 @@ export default function DocumentsPage() {
const [limit] = useState(9);
const [uploadMode, setUploadMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts();
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search });
const createMutation = useCreateDocument();
@@ -118,7 +120,7 @@ export default function DocumentsPage() {
if (!file) return;
if (!file.name.endsWith('.epub')) {
alert('Please upload an EPUB file');
showWarning('Please upload an EPUB file');
return;
}
@@ -128,12 +130,11 @@ export default function DocumentsPage() {
document_file: file,
},
});
alert('Document uploaded successfully!');
showInfo('Document uploaded successfully!');
setUploadMode(false);
refetch();
} catch (error) {
console.error('Upload failed:', error);
alert('Failed to upload document');
} catch (error: any) {
showError('Failed to upload document: ' + error.message);
}
};

View File

@@ -2,15 +2,16 @@ import { useState, FormEvent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated, isCheckingAuth } = useAuth();
const navigate = useNavigate();
const { showError } = useToasts();
// Redirect to home if already logged in
useEffect(() => {
@@ -22,12 +23,11 @@ export default function LoginPage() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await login(username, password);
} catch (err) {
setError('Invalid credentials');
showError('Invalid credentials');
} finally {
setIsLoading(false);
}
@@ -66,7 +66,6 @@ export default function LoginPage() {
required
disabled={isLoading}
/>
<span className="absolute -bottom-5 text-red-400 text-xs">{error}</span>
</div>
</div>
<Button

View File

@@ -1,28 +1,94 @@
import { useState, FormEvent } from 'react';
import { useGetSettings } from '../generated/anthoLumeAPIV1';
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
import { User, Lock, Clock } from 'lucide-react';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings();
const settingsData = data?.data;
const { showInfo, showError } = useToasts();
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [timezone, setTimezone] = useState(settingsData?.timezone || '');
const handlePasswordSubmit = (e: FormEvent) => {
const handlePasswordSubmit = async (e: FormEvent) => {
e.preventDefault();
// TODO: Call API to change password
if (!password || !newPassword) {
showError('Please enter both current and new password');
return;
}
try {
await updateSettings.mutateAsync({
data: {
password: password,
new_password: newPassword,
},
});
showInfo('Password updated successfully');
setPassword('');
setNewPassword('');
} catch (error: any) {
showError('Failed to update password: ' + (error.response?.data?.message || error.message || 'Unknown error'));
}
};
const handleTimezoneSubmit = (e: FormEvent) => {
const handleTimezoneSubmit = async (e: FormEvent) => {
e.preventDefault();
// TODO: Call API to change timezone
try {
await updateSettings.mutateAsync({
data: {
timezone: timezone,
},
});
showInfo('Timezone updated successfully');
} catch (error: any) {
showError('Failed to update timezone: ' + (error.response?.data?.message || error.message || 'Unknown error'));
}
};
if (isLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
return (
<div className="w-full flex flex-col md:flex-row gap-4">
<div>
<div className="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700">
<div className="w-16 h-16 bg-gray-200 dark:bg-gray-600 rounded-full mb-4" />
<div className="w-32 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
</div>
</div>
<div className="flex flex-col gap-4 grow">
<div className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700">
<div className="w-48 h-6 bg-gray-200 dark:bg-gray-600 rounded mb-4" />
<div className="flex gap-4">
<div className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded" />
<div className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded" />
<div className="w-40 h-10 bg-gray-200 dark:bg-gray-600 rounded" />
</div>
</div>
<div className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700">
<div className="w-48 h-6 bg-gray-200 dark:bg-gray-600 rounded mb-4" />
<div className="flex gap-4">
<div className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded" />
<div className="w-40 h-10 bg-gray-200 dark:bg-gray-600 rounded" />
</div>
</div>
<div className="flex flex-col p-4 rounded shadow-lg bg-white dark:bg-gray-700">
<div className="w-24 h-6 bg-gray-200 dark:bg-gray-600 rounded mb-4" />
<div className="flex gap-4 mb-4">
<div className="flex-1 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
<div className="flex-1 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
<div className="flex-1 h-6 bg-gray-200 dark:bg-gray-600 rounded" />
</div>
<div className="flex-1 h-32 bg-gray-200 dark:bg-gray-600 rounded" />
</div>
</div>
</div>
);
}
return (