wip 3
This commit is contained in:
43
frontend/src/pages/ActivityPage.tsx
Normal file
43
frontend/src/pages/ActivityPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||
const activities = data?.data?.activities;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white dark:bg-gray-700">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-600">
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Activity Type</th>
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activities?.map((activity: any) => (
|
||||
<tr key={activity.id} className="border-b dark:border-gray-600">
|
||||
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||
{activity.activity_type}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<a href={`/documents/${activity.document_id}`} className="text-blue-600 dark:text-blue-400">
|
||||
{activity.document_id}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||
{new Date(activity.timestamp).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/pages/DocumentPage.tsx
Normal file
111
frontend/src/pages/DocumentPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function DocumentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: docData, isLoading: docLoading } = useGetDocument(id || '');
|
||||
|
||||
const { data: progressData, isLoading: progressLoading } = useGetProgress(id || '');
|
||||
|
||||
if (docLoading || progressLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
const document = docData?.data?.document;
|
||||
const progress = progressData?.data;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-gray-500 dark:text-white">Document not found</div>;
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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
|
||||
</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>
|
||||
|
||||
<div className="flex flex-wrap-reverse justify-between gap-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>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Author</p>
|
||||
<p className="font-medium text-lg">{document.author}</p>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Progress</p>
|
||||
<p className="font-medium text-lg">
|
||||
{progress?.progress?.percentage ? `${Math.round(progress.progress.percentage)}%` : '0%'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-4 grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-gray-500">Words</p>
|
||||
<p className="font-medium">{document.words || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Created</p>
|
||||
<p className="font-medium">
|
||||
{new Date(document.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Updated</p>
|
||||
<p className="font-medium">
|
||||
{new Date(document.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
frontend/src/pages/DocumentsPage.tsx
Normal file
312
frontend/src/pages/DocumentsPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, FormEvent, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
created_at: string;
|
||||
deleted: boolean;
|
||||
words?: number;
|
||||
filepath?: string;
|
||||
percentage?: number;
|
||||
total_time_seconds?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Activity icon SVG
|
||||
function ActivityIcon() {
|
||||
return (
|
||||
<svg className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Download icon SVG
|
||||
function DownloadIcon({ disabled }: { disabled?: boolean }) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<svg className="w-20 h-20 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="21 15 16 10 8 10" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
<line x1="21" y1="15" x2="21" y2="15" opacity="0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="21 15 16 10 8 10" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentCard({ doc }: DocumentCardProps) {
|
||||
const percentage = doc.percentage || 0;
|
||||
const totalTimeSeconds = doc.total_time_seconds || 0;
|
||||
|
||||
// Convert seconds to nice format (e.g., "2h 30m")
|
||||
const niceSeconds = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
<div
|
||||
className="flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"
|
||||
>
|
||||
<div className="min-w-fit my-auto h-48 relative">
|
||||
<Link to={`/documents/${doc.id}`}>
|
||||
<img
|
||||
className="rounded object-cover h-full"
|
||||
src={`/api/v1/documents/${doc.id}/cover`}
|
||||
alt={doc.title}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Title</p>
|
||||
<p className="font-medium">{doc.title || "Unknown"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Author</p>
|
||||
<p className="font-medium">{doc.author || "Unknown"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Progress</p>
|
||||
<p className="font-medium">{percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p className="text-gray-400">Time Read</p>
|
||||
<p className="font-medium">{niceSeconds(totalTimeSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Link to={`/activity?document=${doc.id}`}>
|
||||
<ActivityIcon />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<Link to={`/documents/${doc.id}/file`}>
|
||||
<DownloadIcon />
|
||||
</Link>
|
||||
) : (
|
||||
<DownloadIcon disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Search icon SVG
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload icon SVG
|
||||
function UploadIcon() {
|
||||
return (
|
||||
<svg className="w-34 h-34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(9);
|
||||
const [uploadMode, setUploadMode] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useGetDocuments({ page, limit, search });
|
||||
const createMutation = useCreateDocument();
|
||||
const docs = data?.data?.documents;
|
||||
const previousPage = data?.data?.previous_page;
|
||||
const nextPage = data?.data?.next_page;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.endsWith('.epub')) {
|
||||
alert('Please upload an EPUB file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
data: {
|
||||
document_file: file,
|
||||
},
|
||||
});
|
||||
alert('Document uploaded successfully!');
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
alert('Failed to upload document');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
setUploadMode(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search Form */}
|
||||
<div
|
||||
className="flex flex-col gap-2 grow p-4 mb-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||
>
|
||||
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col w-full grow">
|
||||
<div className="flex relative">
|
||||
<span
|
||||
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||
>
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
placeholder="Search Author / Title"
|
||||
name="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<button
|
||||
type="submit"
|
||||
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Document Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{docs?.map((doc: any) => (
|
||||
<DocumentCard key={doc.id} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="w-full flex gap-4 justify-center mt-4 text-black dark:text-white">
|
||||
{previousPage && previousPage > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
◄
|
||||
</button>
|
||||
)}
|
||||
{nextPage && nextPage > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="bg-white shadow-lg dark:bg-gray-600 hover:bg-gray-400 font-medium rounded text-sm text-center p-2 w-24 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
►
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
className="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="upload-file-button"
|
||||
className="hidden"
|
||||
checked={uploadMode}
|
||||
onChange={() => setUploadMode(!uploadMode)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-0 z-10 bottom-0 rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2 ${uploadMode ? 'display-block' : 'display-none'}`}
|
||||
>
|
||||
<form
|
||||
method="POST"
|
||||
encType="multipart/form-data"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".epub"
|
||||
id="document_file"
|
||||
name="document_file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleFileChange({ target: { files: fileInputRef.current?.files } } as any);
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</form>
|
||||
<label htmlFor="upload-file-button">
|
||||
<div
|
||||
className="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={handleCancelUpload}
|
||||
>
|
||||
Cancel Upload
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
className="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
||||
htmlFor="upload-file-button"
|
||||
>
|
||||
<UploadIcon />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
frontend/src/pages/HomePage.tsx
Normal file
262
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetHome, useGetDocuments } from '../generated/anthoLumeAPIV1';
|
||||
import type { GraphDataPoint, LeaderboardData } from '../generated/model';
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string;
|
||||
size: string | number;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
function InfoCard({ title, size, link }: InfoCardProps) {
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className="w-full">
|
||||
<div className="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||
<div className="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||
<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 {
|
||||
window: 'DAY' | 'WEEK';
|
||||
currentStreak: number;
|
||||
currentStreakStartDate: string;
|
||||
currentStreakEndDate: string;
|
||||
maxStreak: number;
|
||||
maxStreakStartDate: string;
|
||||
maxStreakEndDate: string;
|
||||
}
|
||||
|
||||
function StreakCard({ window, currentStreak, currentStreakStartDate, currentStreakEndDate, maxStreak, maxStreakStartDate, maxStreakEndDate }: StreakCardProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||
<p className="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||
{window === 'WEEK' ? 'Weekly Read Streak' : 'Daily Read Streak'}
|
||||
</p>
|
||||
<div className="flex items-end my-6 space-x-2">
|
||||
<p className="text-5xl font-bold text-black dark:text-white">{currentStreak}</p>
|
||||
</div>
|
||||
<div className="dark:text-white">
|
||||
<div className="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Current Weekly Streak' : 'Current Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-gray-400">
|
||||
{currentStreakStartDate} ➞ {currentStreakEndDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{currentStreak}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pb-2 mb-2 text-sm">
|
||||
<div>
|
||||
<p>{window === 'WEEK' ? 'Best Weekly Streak' : 'Best Daily Streak'}</p>
|
||||
<div className="flex items-end text-sm text-gray-400">
|
||||
{maxStreakStartDate} ➞ {maxStreakEndDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{maxStreak}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LeaderboardCardProps {
|
||||
name: string;
|
||||
data: LeaderboardData;
|
||||
}
|
||||
|
||||
function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<p className="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||
{name} Leaderboard
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-gray-400 items-center">
|
||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">all</span>
|
||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">year</span>
|
||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">month</span>
|
||||
<span className="cursor-pointer hover:text-black dark:hover:text-white">week</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All time data */}
|
||||
<div className="flex items-end my-6 space-x-2">
|
||||
{data.all.length === 0 ? (
|
||||
<p className="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||
) : (
|
||||
<p className="text-5xl font-bold text-black dark:text-white">{data.all[0]?.user_id || 'N/A'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dark:text-white">
|
||||
{data.all.slice(0, 3).map((item: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between pt-2 pb-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
|
||||
>
|
||||
<div>
|
||||
<p>{item.user_id}</p>
|
||||
</div>
|
||||
<div className="flex items-end font-bold">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphVisualization({ data }: { data: GraphDataPoint[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="relative h-24 flex items-center justify-center bg-gray-100 dark:bg-gray-600">
|
||||
<p className="text-gray-400 dark:text-gray-300">No data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple bar visualization (could be enhanced with SVG bezier curve like SSR)
|
||||
const maxMinutes = Math.max(...data.map(d => d.minutes_read), 1);
|
||||
|
||||
return (
|
||||
<div className="relative h-24 flex items-end justify-between p-2 bg-gray-100 dark:bg-gray-600">
|
||||
{data.map((point, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 mx-0.5 bg-blue-500 hover:bg-blue-600 transition-colors relative group"
|
||||
style={{ height: `${(point.minutes_read / maxMinutes) * 100}%` }}
|
||||
>
|
||||
<div className="absolute bottom-full mb-1 left-0 w-full text-xs text-center text-gray-600 dark:text-gray-300 opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||
{point.minutes_read} min
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||
const { data: docsData, isLoading: docsLoading } = useGetDocuments({ page: 1, limit: 9 });
|
||||
|
||||
const docs = docsData?.data?.documents;
|
||||
const dbInfo = homeData?.data?.database_info;
|
||||
const streaks = homeData?.data?.streaks?.streaks;
|
||||
const graphData = homeData?.data?.graph_data?.graph_data;
|
||||
const userStats = homeData?.data?.user_statistics;
|
||||
|
||||
if (homeLoading || docsLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Daily Read Totals Graph */}
|
||||
<div className="w-full">
|
||||
<div className="relative w-full bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||
<p className="absolute top-3 left-5 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500">
|
||||
Daily Read Totals
|
||||
</p>
|
||||
<GraphVisualization data={graphData || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<InfoCard
|
||||
title="Documents"
|
||||
size={dbInfo?.documents_size || 0}
|
||||
link="./documents"
|
||||
/>
|
||||
<InfoCard
|
||||
title="Activity Records"
|
||||
size={dbInfo?.activity_size || 0}
|
||||
link="./activity"
|
||||
/>
|
||||
<InfoCard
|
||||
title="Progress Records"
|
||||
size={dbInfo?.progress_size || 0}
|
||||
link="./progress"
|
||||
/>
|
||||
<InfoCard
|
||||
title="Devices"
|
||||
size={dbInfo?.devices_size || 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streak Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{streaks?.map((streak: any, index) => (
|
||||
<StreakCard
|
||||
key={index}
|
||||
window={streak.window as 'DAY' | 'WEEK'}
|
||||
currentStreak={streak.current_streak}
|
||||
currentStreakStartDate={streak.current_streak_start_date}
|
||||
currentStreakEndDate={streak.current_streak_end_date}
|
||||
maxStreak={streak.max_streak}
|
||||
maxStreakStartDate={streak.max_streak_start_date}
|
||||
maxStreakEndDate={streak.max_streak_end_date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<LeaderboardCard
|
||||
name="WPM"
|
||||
data={userStats?.wpm || { all: [], year: [], month: [], week: []}}
|
||||
/>
|
||||
<LeaderboardCard
|
||||
name="Duration"
|
||||
data={userStats?.duration || { all: [], year: [], month: [], week: []}}
|
||||
/>
|
||||
<LeaderboardCard
|
||||
name="Words"
|
||||
data={userStats?.words || { all: [], year: [], month: [], week: []}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Documents */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{docs?.slice(0, 6).map((doc: any) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||
>
|
||||
<h3 className="font-medium text-lg">{doc.title}</h3>
|
||||
<p className="text-sm">{doc.author}</p>
|
||||
<Link
|
||||
to={`/documents/${doc.id}`}
|
||||
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"
|
||||
>
|
||||
View Document
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/pages/LoginPage.tsx
Normal file
87
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (err) {
|
||||
setError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 dark:bg-gray-800 dark:text-white min-h-screen">
|
||||
<div className="flex flex-wrap w-full">
|
||||
<div className="flex flex-col w-full md:w-1/2">
|
||||
<div
|
||||
className="flex flex-col justify-center px-8 pt-8 my-auto md:justify-start md:pt-0 md:px-24 lg:px-32"
|
||||
>
|
||||
<p className="text-3xl text-center">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pt-4 mb-12">
|
||||
<div className="flex relative">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute -bottom-5 text-red-400 text-xs">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="pt-12 pb-12 text-center">
|
||||
<p className="mt-4">
|
||||
<a href="/local" className="font-semibold underline">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden image-fader w-1/2 shadow-2xl h-screen relative md:block">
|
||||
<div className="w-full h-screen object-cover ease-in-out top-0 left-0 bg-gray-300 flex items-center justify-center">
|
||||
<span className="text-gray-500">AnthoLume</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/pages/ProgressPage.tsx
Normal file
51
frontend/src/pages/ProgressPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function ProgressPage() {
|
||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||
const progress = data?.data?.progress;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white dark:bg-gray-700">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-600">
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Document</th>
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Device Name</th>
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Percentage</th>
|
||||
<th className="text-left p-3 text-gray-500 dark:text-white">Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{progress?.map((row: any) => (
|
||||
<tr key={row.document_id} className="border-b dark:border-gray-600">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{row.author || 'Unknown'} - {row.title || 'Unknown'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||
{row.device_name || 'Unknown'}
|
||||
</td>
|
||||
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||
{row.percentage ? Math.round(row.percentage) : 0}%
|
||||
</td>
|
||||
<td className="p-3 text-gray-700 dark:text-gray-300">
|
||||
{row.created_at ? new Date(row.created_at).toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/pages/SearchPage.tsx
Normal file
183
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
|
||||
// Search icon SVG
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Documents icon SVG
|
||||
function DocumentsIcon() {
|
||||
return (
|
||||
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Download icon SVG
|
||||
function DownloadIcon() {
|
||||
return (
|
||||
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="21 15 16 10 8 10" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
|
||||
const { data, isLoading } = useGetSearch({ query, source });
|
||||
const results = data?.data?.results;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Trigger refetch by updating query
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||
<div className="flex flex-col gap-4 grow">
|
||||
{/* Search Form */}
|
||||
<div
|
||||
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||
>
|
||||
<form className="flex gap-4 flex-col lg:flex-row" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col w-full grow">
|
||||
<div className="flex relative">
|
||||
<span
|
||||
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||
>
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
placeholder="Query"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex relative min-w-[12em]">
|
||||
<span
|
||||
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||
>
|
||||
<DocumentsIcon />
|
||||
</span>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as GetSearchSource)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
>
|
||||
<option value="LibGen">Library Genesis</option>
|
||||
<option value="Annas Archive">Annas Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<button
|
||||
type="submit"
|
||||
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Search Results Table */}
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table
|
||||
className="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm"
|
||||
>
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<tr>
|
||||
<th
|
||||
className="w-12 p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
></th>
|
||||
<th
|
||||
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Document
|
||||
</th>
|
||||
<th
|
||||
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Series
|
||||
</th>
|
||||
<th
|
||||
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Size
|
||||
</th>
|
||||
<th
|
||||
className="p-3 hidden md:block font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="text-center p-3" colSpan={6}>Loading...</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && !results && (
|
||||
<tr>
|
||||
<td className="text-center p-3" colSpan={6}>No Results</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && results && results.map((item: any) => (
|
||||
<tr key={item.id}>
|
||||
<td
|
||||
className="p-3 border-b border-gray-200 text-gray-500 dark:text-gray-500"
|
||||
>
|
||||
<button
|
||||
className="hover:text-purple-600"
|
||||
title="Download"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 border-b border-gray-200">
|
||||
{item.author || 'N/A'} - {item.title || 'N/A'}
|
||||
</td>
|
||||
<td className="p-3 border-b border-gray-200">
|
||||
<p>{item.series || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="p-3 border-b border-gray-200">
|
||||
<p>{item.file_type || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="p-3 border-b border-gray-200">
|
||||
<p>{item.file_size || 'N/A'}</p>
|
||||
</td>
|
||||
<td className="hidden md:table-cell p-3 border-b border-gray-200">
|
||||
<p>{item.upload_date || 'N/A'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
frontend/src/pages/SettingsPage.tsx
Normal file
215
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useGetSettings } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
// User icon SVG
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg className="w-60 h-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M12 12c-4 0-8 3-8 8h16c0-5-4-8-8-8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Password icon SVG
|
||||
function PasswordIcon() {
|
||||
return (
|
||||
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Clock icon SVG
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<svg className="w-15 h-15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const settingsData = data?.data;
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [timezone, setTimezone] = useState(settingsData?.timezone || '');
|
||||
|
||||
const handlePasswordSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Call API to change password
|
||||
};
|
||||
|
||||
const handleTimezoneSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// TODO: Call API to change timezone
|
||||
};
|
||||
|
||||
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">
|
||||
{/* User Profile Card */}
|
||||
<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 text-gray-500 dark:text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
<p className="text-lg">{settingsData?.user?.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 grow">
|
||||
{/* Change Password Form */}
|
||||
<div
|
||||
className="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||
>
|
||||
<p className="text-lg font-semibold mb-2">Change Password</p>
|
||||
<form
|
||||
className="flex gap-4 flex-col lg:flex-row"
|
||||
onSubmit={handlePasswordSubmit}
|
||||
>
|
||||
<div className="flex flex-col grow">
|
||||
<div className="flex relative">
|
||||
<span
|
||||
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||
>
|
||||
<PasswordIcon />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grow">
|
||||
<div className="flex relative">
|
||||
<span
|
||||
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||
>
|
||||
<PasswordIcon />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
placeholder="New Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<button
|
||||
type="submit"
|
||||
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Change Timezone Form */}
|
||||
<div
|
||||
className="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||
>
|
||||
<p className="text-lg font-semibold mb-2">Change Timezone</p>
|
||||
<form
|
||||
className="flex gap-4 flex-col lg:flex-row"
|
||||
onSubmit={handleTimezoneSubmit}
|
||||
>
|
||||
<div className="flex relative grow">
|
||||
<span
|
||||
className="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||
>
|
||||
<ClockIcon />
|
||||
</span>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="America/Chicago">America/Chicago</option>
|
||||
<option value="America/Denver">America/Denver</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="Asia/Shanghai">Asia/Shanghai</option>
|
||||
<option value="Australia/Sydney">Australia/Sydney</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
<button
|
||||
type="submit"
|
||||
className="font-medium px-4 py-2 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Devices Table */}
|
||||
<div
|
||||
className="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||
>
|
||||
<p className="text-lg font-semibold">Devices</p>
|
||||
<table className="min-w-full bg-white dark:bg-gray-700 text-sm">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
<tr>
|
||||
<th
|
||||
className="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Last Sync
|
||||
</th>
|
||||
<th
|
||||
className="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
{!settingsData?.devices || settingsData.devices.length === 0 ? (
|
||||
<tr>
|
||||
<td className="text-center p-3" colSpan={3}>No Results</td>
|
||||
</tr>
|
||||
) : (
|
||||
settingsData.devices.map((device: any) => (
|
||||
<tr key={device.id}>
|
||||
<td className="p-3 pl-0">
|
||||
<p>{device.device_name || 'Unknown'}</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p>{device.last_synced ? new Date(device.last_synced).toLocaleString() : 'N/A'}</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p>{device.created_at ? new Date(device.created_at).toLocaleString() : 'N/A'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user