feat(ui): Components refactoring with Tailwind - UI kit, icons, lazy loading images

- Created reusable UI components (Card, Button, Badge, Form, Icon)
- Added PageIcon and NavIcon components with consistent styling
- Refactored all pages to use new UI components
- Added non-blocking image loading with skeleton for book covers
- Created LibraryActions dropdown for library settings
- Added emojis to buttons for better UX
- Fixed Client Component issues with getBookCoverUrl
This commit is contained in:
2026-03-06 14:11:23 +01:00
parent 05a18c3c77
commit d001e29bbc
24 changed files with 1235 additions and 459 deletions

View File

@@ -1,42 +1,92 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { BookDto } from "../../lib/api";
interface BookCardProps {
book: BookDto;
getBookCoverUrl: (bookId: string) => string;
book: BookDto & { coverUrl?: string };
}
export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
function BookImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<Link href={`/books/${book.id}`} className="book-card">
<div className="book-cover">
<Image
src={getBookCoverUrl(book.id)}
alt={`Cover of ${book.title}`}
width={150}
height={220}
className="cover-image"
unoptimized
loading="lazy"
/>
<div className="relative aspect-[2/3] overflow-hidden bg-gradient-to-br from-line/50 to-line">
{/* Skeleton */}
<div
className={`absolute inset-0 bg-muted/10 animate-pulse transition-opacity duration-300 ${
isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
}`}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-muted/20 to-transparent shimmer" />
</div>
<div className="book-info">
<h3 className="book-title" title={book.title}>
{/* Image */}
<Image
src={src}
alt={alt}
fill
className={`object-cover group-hover:scale-105 transition-all duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
onLoad={() => setIsLoaded(true)}
unoptimized
/>
</div>
);
}
export function BookCard({ book }: BookCardProps) {
const coverUrl = book.coverUrl || `/api/books/${book.id}/pages/1?format=webp&width=200`;
return (
<Link
href={`/books/${book.id}`}
className="group block bg-card rounded-xl border border-line shadow-soft hover:shadow-card hover:-translate-y-1 transition-all duration-200 overflow-hidden"
>
<BookImage
src={coverUrl}
alt={`Cover of ${book.title}`}
/>
{/* Book Info */}
<div className="p-4">
<h3
className="font-semibold text-foreground mb-1 line-clamp-2 min-h-[2.5rem]"
title={book.title}
>
{book.title}
</h3>
{book.author && (
<p className="book-author">{book.author}</p>
<p className="text-sm text-muted mb-1 truncate">{book.author}</p>
)}
{book.series && (
<p className="book-series">
<p className="text-xs text-muted/80 truncate mb-2">
{book.series}
{book.volume && ` #${book.volume}`}
{book.volume && <span className="text-primary font-medium"> #{book.volume}</span>}
</p>
)}
<div className="book-meta">
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
{book.language && <span className="book-lang">{book.language.toUpperCase()}</span>}
{/* Meta Tags */}
<div className="flex items-center gap-2 mt-2">
<span className={`
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
${book.kind === 'cbz' ? 'bg-success-soft text-success' : ''}
${book.kind === 'cbr' ? 'bg-warning-soft text-warning' : ''}
${book.kind === 'pdf' ? 'bg-error-soft text-error' : ''}
`}>
{book.kind}
</span>
{book.language && (
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary-soft text-primary">
{book.language}
</span>
)}
</div>
</div>
</Link>
@@ -44,15 +94,14 @@ export function BookCard({ book, getBookCoverUrl }: BookCardProps) {
}
interface BooksGridProps {
books: BookDto[];
getBookCoverUrl: (bookId: string) => string;
books: (BookDto & { coverUrl?: string })[];
}
export function BooksGrid({ books, getBookCoverUrl }: BooksGridProps) {
export function BooksGrid({ books }: BooksGridProps) {
return (
<div className="books-grid">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{books.map((book) => (
<BookCard key={book.id} book={book} getBookCoverUrl={getBookCoverUrl} />
<BookCard key={book.id} book={book} />
))}
</div>
);
@@ -64,8 +113,13 @@ interface EmptyStateProps {
export function EmptyState({ message }: EmptyStateProps) {
return (
<div className="empty-state">
<p>{message}</p>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 mb-4 text-muted/30">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<p className="text-muted text-lg">{message}</p>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { StatusBadge, Badge, ProgressBar } from "./ui";
interface ProgressEvent {
job_id: string;
@@ -28,7 +29,6 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const [isComplete, setIsComplete] = useState(false);
useEffect(() => {
// Use SSE via local proxy
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
eventSource.onmessage = (event) => {
@@ -69,11 +69,19 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
}, [jobId, onComplete]);
if (error) {
return <div className="progress-error">Error: {error}</div>;
return (
<div className="p-4 bg-error-soft text-error rounded-lg text-sm">
Error: {error}
</div>
);
}
if (!progress) {
return <div className="progress-loading">Loading progress...</div>;
return (
<div className="p-4 text-muted text-sm">
Loading progress...
</div>
);
}
const percent = progress.progress_percent ?? 0;
@@ -81,26 +89,20 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const total = progress.total_files ?? 0;
return (
<div className="job-progress">
<div className="progress-header">
<span className={`status-badge status-${progress.status}`}>
{progress.status}
</span>
{isComplete && <span className="complete-badge">Complete</span>}
<div className="p-4 bg-card rounded-lg border border-line">
<div className="flex items-center justify-between mb-3">
<StatusBadge status={progress.status} />
{isComplete && (
<Badge variant="success">Complete</Badge>
)}
</div>
<div className="progress-bar-container">
<div
className="progress-bar-fill"
style={{ width: `${percent}%` }}
/>
<span className="progress-percent">{percent}%</span>
</div>
<ProgressBar value={percent} showLabel size="md" className="mb-3" />
<div className="progress-stats">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted mb-3">
<span>{processed} / {total} files</span>
{progress.current_file && (
<span className="current-file" title={progress.current_file}>
<span className="truncate max-w-md" title={progress.current_file}>
Current: {progress.current_file.length > 40
? progress.current_file.substring(0, 40) + "..."
: progress.current_file}
@@ -109,12 +111,12 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
</div>
{progress.stats_json && (
<div className="progress-detailed-stats">
<span>Scanned: {progress.stats_json.scanned_files}</span>
<span>Indexed: {progress.stats_json.indexed_files}</span>
<span>Removed: {progress.stats_json.removed_files}</span>
<div className="flex flex-wrap gap-3 text-xs">
<Badge variant="primary">Scanned: {progress.stats_json.scanned_files}</Badge>
<Badge variant="success">Indexed: {progress.stats_json.indexed_files}</Badge>
<Badge variant="warning">Removed: {progress.stats_json.removed_files}</Badge>
{progress.stats_json.errors > 0 && (
<span className="error-count">Errors: {progress.stats_json.errors}</span>
<Badge variant="error">Errors: {progress.stats_json.errors}</Badge>
)}
</div>
)}

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { JobProgress } from "./JobProgress";
import { StatusBadge, Button } from "./ui";
interface JobRowProps {
job: {
@@ -25,52 +26,71 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
const handleComplete = () => {
setShowProgress(false);
// Trigger a page refresh to update the job status
window.location.reload();
};
return (
<>
<tr className={highlighted ? "job-highlighted" : undefined}>
<td>
<Link href={`/jobs/${job.id}`} className="job-id-link">
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
<td className="px-4 py-3">
<Link
href={`/jobs/${job.id}`}
className="text-primary hover:text-primary/80 hover:underline font-mono text-sm"
>
<code>{job.id.slice(0, 8)}</code>
</Link>
</td>
<td>{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}</td>
<td>{job.type}</td>
<td>
<span className={`status-${job.status}`}>{job.status}</span>
{job.error_opt && <span className="error-hint" title={job.error_opt}>!</span>}
{(job.status === "running" || job.status === "pending") && (
<button
className="toggle-progress-btn"
onClick={() => setShowProgress(!showProgress)}
>
{showProgress ? "Hide" : "Show"} progress
</button>
)}
<td className="px-4 py-3 text-sm text-foreground">
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"}
</td>
<td>{new Date(job.created_at).toLocaleString()}</td>
<td>
<div style={{ display: "flex", gap: "8px" }}>
<Link href={`/jobs/${job.id}`} className="view-btn">
<td className="px-4 py-3 text-sm text-foreground">{job.type}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={job.status} />
{job.error_opt && (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-error text-white text-xs font-bold cursor-help"
title={job.error_opt}
>
!
</span>
)}
{(job.status === "running" || job.status === "pending") && (
<button
className="text-xs text-primary hover:text-primary/80 hover:underline"
onClick={() => setShowProgress(!showProgress)}
>
{showProgress ? "Hide" : "Show"} progress
</button>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted">
{new Date(job.created_at).toLocaleString()}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
href={`/jobs/${job.id}`}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
>
View
</Link>
{(job.status === "pending" || job.status === "running") && (
<button
className="cancel-btn"
<Button
variant="danger"
size="sm"
onClick={() => onCancel(job.id)}
>
Cancel
</button>
</Button>
)}
</div>
</td>
</tr>
{showProgress && (job.status === "running" || job.status === "pending") && (
<tr className="progress-row">
<td colSpan={6}>
<tr>
<td colSpan={6} className="px-4 py-3 bg-muted/5">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -22,7 +22,6 @@ interface Job {
export function JobsIndicator() {
const [activeJobs, setActiveJobs] = useState<Job[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -66,7 +65,11 @@ export function JobsIndicator() {
if (totalCount === 0) {
return (
<Link href="/jobs" className="jobs-indicator-empty" title="View all jobs">
<Link
href="/jobs"
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted transition-all duration-200 hover:text-foreground hover:bg-primary-soft"
title="View all jobs"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M6 8h12M6 12h12M6 16h8" />
@@ -76,15 +79,19 @@ export function JobsIndicator() {
}
return (
<div className="jobs-indicator-wrapper" ref={dropdownRef}>
<div className="relative" ref={dropdownRef}>
<button
className={`jobs-indicator-button ${runningJobs.length > 0 ? 'has-running' : ''} ${isOpen ? 'open' : ''}`}
className={`flex items-center gap-2 px-3 py-2 rounded-lg font-medium text-sm transition-all duration-200 ${
runningJobs.length > 0
? 'bg-success-soft text-success'
: 'bg-warning-soft text-warning'
} ${isOpen ? 'ring-2 ring-primary' : ''}`}
onClick={() => setIsOpen(!isOpen)}
title={`${totalCount} active job${totalCount !== 1 ? 's' : ''}`}
>
{/* Animated spinner for running jobs */}
{runningJobs.length > 0 && (
<div className="jobs-spinner">
<div className="w-4 h-4 animate-spin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
@@ -93,21 +100,19 @@ export function JobsIndicator() {
)}
{/* Icon */}
<svg className="jobs-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="18" rx="2" />
<path d="M6 8h12M6 12h12M6 16h8" />
</svg>
{/* Badge with count */}
<span className="jobs-count-badge">
{totalCount > 99 ? "99+" : totalCount}
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-bold text-white bg-current rounded-full">
<span className="text-background">{totalCount > 99 ? "99+" : totalCount}</span>
</span>
{/* Chevron */}
<svg
className={`jobs-chevron ${isOpen ? 'open' : ''}`}
width="16"
height="16"
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -119,13 +124,13 @@ export function JobsIndicator() {
{/* Popin/Dropdown */}
{isOpen && (
<div className="jobs-popin">
<div className="jobs-popin-header">
<div className="jobs-popin-title">
<span className="jobs-icon-large">📊</span>
<div className="absolute right-0 top-full mt-2 w-96 bg-card rounded-xl shadow-card border border-line overflow-hidden z-50">
<div className="flex items-center justify-between px-4 py-3 border-b border-line bg-muted/5">
<div className="flex items-center gap-3">
<span className="text-2xl">📊</span>
<div>
<h3>Active Jobs</h3>
<p className="jobs-subtitle">
<h3 className="font-semibold text-foreground">Active Jobs</h3>
<p className="text-xs text-muted">
{runningJobs.length > 0
? `${runningJobs.length} running, ${pendingJobs.length} pending`
: `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending`
@@ -135,7 +140,7 @@ export function JobsIndicator() {
</div>
<Link
href="/jobs"
className="jobs-view-all"
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors"
onClick={() => setIsOpen(false)}
>
View All
@@ -144,72 +149,74 @@ export function JobsIndicator() {
{/* Overall progress bar if running */}
{runningJobs.length > 0 && (
<div className="jobs-overall-progress">
<div className="progress-header">
<span>Overall Progress</span>
<span className="progress-percent">{Math.round(totalProgress)}%</span>
<div className="px-4 py-3 border-b border-line">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted">Overall Progress</span>
<span className="font-semibold text-foreground">{Math.round(totalProgress)}%</span>
</div>
<div className="progress-bar">
<div className="h-2 bg-line rounded-full overflow-hidden">
<div
className="progress-fill"
className="h-full bg-success rounded-full transition-all duration-500"
style={{ width: `${totalProgress}%` }}
/>
</div>
</div>
)}
<div className="jobs-list-container">
<div className="max-h-80 overflow-y-auto">
{activeJobs.length === 0 ? (
<div className="jobs-empty-state">
<span className="empty-icon"></span>
<div className="flex flex-col items-center justify-center py-8 text-muted">
<span className="text-4xl mb-2"></span>
<p>No active jobs</p>
</div>
) : (
<ul className="jobs-detailed-list">
<ul className="divide-y divide-line">
{activeJobs.map(job => (
<li key={job.id} className={`job-detailed-item job-status-${job.status}`}>
<li key={job.id}>
<Link
href={`/jobs/${job.id}`}
className="job-link"
className="block px-4 py-3 hover:bg-muted/5 transition-colors"
onClick={() => setIsOpen(false)}
>
<div className="job-info-row">
<div className="job-status-icon">
{job.status === "running" && <span className="spinning"></span>}
<div className="flex items-start gap-3">
<div className="mt-0.5">
{job.status === "running" && <span className="animate-spin inline-block"></span>}
{job.status === "pending" && <span></span>}
</div>
<div className="job-details">
<div className="job-main-info">
<code className="job-id-short">{job.id.slice(0, 8)}</code>
<span className={`job-type-badge ${job.type}`}>{job.type}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<code className="text-xs px-1.5 py-0.5 bg-line/50 rounded font-mono">{job.id.slice(0, 8)}</code>
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
job.type === 'rebuild' ? 'bg-primary-soft text-primary' : 'bg-muted/20 text-muted'
}`}>
{job.type}
</span>
</div>
{job.status === "running" && job.progress_percent !== null && (
<div className="job-progress-row">
<div className="job-mini-progress-bar">
<div className="flex items-center gap-2 mt-2">
<div className="flex-1 h-1.5 bg-line rounded-full overflow-hidden">
<div
className="job-mini-progress-fill"
className="h-full bg-success rounded-full transition-all duration-300"
style={{ width: `${job.progress_percent}%` }}
/>
</div>
<span className="job-progress-text">{job.progress_percent}%</span>
<span className="text-xs font-medium text-muted">{job.progress_percent}%</span>
</div>
)}
{job.current_file && (
<p className="job-current-file" title={job.current_file}>
📄 {job.current_file.length > 35
? job.current_file.substring(0, 35) + "..."
: job.current_file}
<p className="text-xs text-muted mt-1.5 truncate" title={job.current_file}>
📄 {job.current_file}
</p>
)}
{job.stats_json && (
<div className="job-mini-stats">
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
<span> {job.stats_json.indexed_files}</span>
{job.stats_json.errors > 0 && (
<span className="error-stat"> {job.stats_json.errors}</span>
<span className="text-error"> {job.stats_json.errors}</span>
)}
</div>
)}
@@ -223,8 +230,8 @@ export function JobsIndicator() {
</div>
{/* Footer */}
<div className="jobs-popin-footer">
<p className="jobs-auto-refresh">Auto-refreshing every 2s</p>
<div className="px-4 py-2 border-t border-line bg-muted/5">
<p className="text-xs text-muted text-center">Auto-refreshing every 2s</p>
</div>
</div>
)}

View File

@@ -64,28 +64,32 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
};
return (
<table>
<thead>
<tr>
<th>ID</th>
<th>Library</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<JobRow
key={job.id}
job={job}
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
highlighted={job.id === highlightJobId}
onCancel={handleCancel}
/>
))}
</tbody>
</table>
<div className="bg-card rounded-xl shadow-soft border border-line overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-line bg-muted/5">
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{jobs.map((job) => (
<JobRow
key={job.id}
job={job}
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
highlighted={job.id === highlightJobId}
onCancel={handleCancel}
/>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import Link from "next/link";
import { Button, Badge } from "../components/ui";
interface LibraryActionsProps {
libraryId: string;
monitorEnabled: boolean;
scanMode: string;
watcherEnabled: boolean;
onUpdate?: () => void;
}
export function LibraryActions({
libraryId,
monitorEnabled,
scanMode,
watcherEnabled,
onUpdate
}: LibraryActionsProps) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSubmit = (formData: FormData) => {
startTransition(async () => {
const data = {
monitor_enabled: formData.get("monitor_enabled") === "true",
scan_mode: formData.get("scan_mode") as string,
watcher_enabled: formData.get("watcher_enabled") === "true",
};
await fetch(`/api/libraries/${libraryId}/monitoring`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setIsOpen(false);
onUpdate?.();
window.location.reload();
});
};
return (
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className={isOpen ? "bg-muted/10" : ""}
>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-72 bg-card rounded-xl shadow-card border border-line p-4 z-50">
<form action={handleSubmit}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">🔄 Auto Scan</label>
<input
type="checkbox"
name="monitor_enabled"
defaultChecked={monitorEnabled}
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground"> File Watcher</label>
<input
type="checkbox"
name="watcher_enabled"
defaultChecked={watcherEnabled}
className="w-4 h-4 rounded border-line text-primary focus:ring-primary"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">📅 Schedule</label>
<select
name="scan_mode"
defaultValue={scanMode}
className="text-sm border border-line rounded-lg px-2 py-1 bg-background"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<Button
type="submit"
size="sm"
className="w-full"
disabled={isPending}
>
{isPending ? "Saving..." : "Save Settings"}
</Button>
</div>
</form>
</div>
)}
</div>
);
}

View File

@@ -34,28 +34,38 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
};
return (
<form action={handleSubmit} className="monitoring-form-compact">
<form action={handleSubmit} className="flex items-center gap-2">
<input type="hidden" name="id" value={libraryId} />
<div className="monitor-row">
<label className={`monitor-checkbox ${isPending ? 'pending' : ''}`}>
<div className="flex items-center gap-2">
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
isPending
? 'opacity-50 cursor-not-allowed'
: 'hover:border-primary'
} ${monitorEnabled ? 'bg-primary-soft border-primary text-primary' : 'bg-card border-line text-muted'}`}>
<input
type="checkbox"
name="monitor_enabled"
value="true"
defaultChecked={monitorEnabled}
disabled={isPending}
className="w-3.5 h-3.5 rounded border-line text-primary focus:ring-primary"
/>
<span>Auto</span>
</label>
<label className={`monitor-checkbox watcher ${isPending ? 'pending' : ''} ${watcherEnabled ? 'active' : ''}`}>
<label className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-sm font-medium transition-all cursor-pointer select-none ${
isPending
? 'opacity-50 cursor-not-allowed'
: 'hover:border-primary'
} ${watcherEnabled ? 'bg-warning-soft border-warning text-warning' : 'bg-card border-line text-muted'}`}>
<input
type="checkbox"
name="watcher_enabled"
value="true"
defaultChecked={watcherEnabled}
disabled={isPending}
className="w-3.5 h-3.5 rounded border-line text-warning focus:ring-warning"
/>
<span title="Real-time file watcher"></span>
</label>
@@ -64,7 +74,7 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
name="scan_mode"
defaultValue={scanMode}
disabled={isPending}
className="scan-mode-select"
className="px-3 py-1.5 text-sm rounded-lg border border-line bg-card text-foreground focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"
>
<option value="manual">Manual</option>
<option value="hourly">Hourly</option>
@@ -72,7 +82,11 @@ export function MonitoringForm({ libraryId, monitorEnabled, scanMode, watcherEna
<option value="weekly">Weekly</option>
</select>
<button type="submit" className="save-btn" disabled={isPending}>
<button
type="submit"
disabled={isPending}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary text-white font-semibold text-sm transition-all hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? '...' : '✓'}
</button>
</div>

View File

@@ -0,0 +1,61 @@
type BadgeVariant = "default" | "primary" | "success" | "warning" | "error" | "muted";
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
default: "bg-muted/20 text-muted",
primary: "bg-primary-soft text-primary",
success: "bg-success-soft text-success",
warning: "bg-warning-soft text-warning",
error: "bg-error-soft text-error",
muted: "bg-muted/10 text-muted",
};
export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
return (
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${variantStyles[variant]} ${className}`}>
{children}
</span>
);
}
type StatusVariant = "running" | "success" | "failed" | "cancelled" | "pending";
interface StatusBadgeProps {
status: string;
className?: string;
}
const statusVariants: Record<StatusVariant, BadgeVariant> = {
running: "primary",
success: "success",
failed: "error",
cancelled: "muted",
pending: "warning",
};
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
const variant = statusVariants[status as StatusVariant] || "default";
return <Badge variant={variant} className={className}>{status}</Badge>;
}
type JobTypeVariant = "rebuild" | "full_rebuild";
interface JobTypeBadgeProps {
type: string;
className?: string;
}
const jobTypeVariants: Record<JobTypeVariant, BadgeVariant> = {
rebuild: "primary",
full_rebuild: "warning",
};
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
const variant = jobTypeVariants[type as JobTypeVariant] || "default";
return <Badge variant={variant} className={className}>{type}</Badge>;
}

View File

@@ -0,0 +1,48 @@
import { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonVariant = "primary" | "secondary" | "danger" | "warning" | "ghost";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: ButtonVariant;
size?: "sm" | "md" | "lg";
}
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary/90",
secondary: "border border-line text-muted hover:bg-muted/5",
danger: "bg-error text-white hover:bg-error/90",
warning: "bg-warning text-white hover:bg-warning/90",
ghost: "text-muted hover:text-foreground hover:bg-muted/5",
};
const sizeStyles: Record<string, string> = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
export function Button({
children,
variant = "primary",
size = "md",
className = "",
disabled,
...props
}: ButtonProps) {
return (
<button
className={`
inline-flex items-center justify-center font-medium rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
disabled={disabled}
{...props}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,27 @@
import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
}
export function Card({ children, className = "" }: CardProps) {
return (
<div className={`bg-card rounded-xl shadow-soft border border-line p-6 ${className}`}>
{children}
</div>
);
}
interface CardHeaderProps {
title: string;
className?: string;
}
export function CardHeader({ title, className = "" }: CardHeaderProps) {
return (
<h2 className={`text-lg font-semibold text-foreground mb-4 ${className}`}>
{title}
</h2>
);
}

View File

@@ -0,0 +1,57 @@
import { ReactNode, LabelHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes } from "react";
interface FormFieldProps {
children: ReactNode;
className?: string;
}
export function FormField({ children, className = "" }: FormFieldProps) {
return <div className={`flex-1 min-w-48 ${className}`}>{children}</div>;
}
interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
}
export function FormLabel({ children, className = "", ...props }: FormLabelProps) {
return (
<label className={`block text-sm font-medium text-foreground mb-1.5 ${className}`} {...props}>
{children}
</label>
);
}
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {}
export function FormInput({ className = "", ...props }: FormInputProps) {
return (
<input
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
{...props}
/>
);
}
interface FormSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
children: ReactNode;
}
export function FormSelect({ children, className = "", ...props }: FormSelectProps) {
return (
<select
className={`w-full h-10 px-3 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm ${className}`}
{...props}
>
{children}
</select>
);
}
interface FormRowProps {
children: ReactNode;
className?: string;
}
export function FormRow({ children, className = "" }: FormRowProps) {
return <div className={`flex items-end gap-3 flex-wrap ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,94 @@
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series";
interface PageIconProps {
name: IconName;
className?: string;
}
const icons: Record<IconName, React.ReactNode> = {
dashboard: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
books: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
libraries: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
jobs: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
tokens: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
series: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
};
const colors: Record<IconName, string> = {
dashboard: "text-primary",
books: "text-success",
libraries: "text-primary",
jobs: "text-warning",
tokens: "text-error",
series: "text-primary",
};
export function PageIcon({ name, className = "" }: PageIconProps) {
return (
<span className={`${colors[name]} ${className}`}>
{icons[name]}
</span>
);
}
// Nav icons (smaller)
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
const navIcons: Record<IconName, React.ReactNode> = {
dashboard: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
books: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
),
libraries: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
jobs: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
tokens: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
series: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
};
return <span className={className}>{navIcons[name]}</span>;
}

View File

@@ -0,0 +1,30 @@
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className = "", ...props }: InputProps) {
return (
<input
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground placeholder-muted focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
{...props}
/>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
children: ReactNode;
}
export function Select({ label, children, className = "", ...props }: SelectProps) {
return (
<select
className={`px-4 py-2.5 rounded-lg border border-line bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary ${className}`}
{...props}
>
{children}
</select>
);
}

View File

@@ -0,0 +1,56 @@
interface ProgressBarProps {
value: number;
max?: number;
showLabel?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
}
const sizeStyles = {
sm: "h-1.5",
md: "h-2",
lg: "h-8",
};
export function ProgressBar({
value,
max = 100,
showLabel = false,
size = "md",
className = ""
}: ProgressBarProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={`relative ${sizeStyles[size]} bg-line rounded-full overflow-hidden ${className}`}>
<div
className="absolute inset-y-0 left-0 bg-success rounded-full transition-all duration-300"
style={{ width: `${percent}%` }}
/>
{showLabel && (
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-foreground">
{Math.round(percent)}%
</span>
)}
</div>
);
}
interface MiniProgressBarProps {
value: number;
max?: number;
className?: string;
}
export function MiniProgressBar({ value, max = 100, className = "" }: MiniProgressBarProps) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={`flex-1 h-1.5 bg-line rounded-full overflow-hidden ${className}`}>
<div
className="h-full bg-success rounded-full transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from "react";
interface StatBoxProps {
value: ReactNode;
label: string;
variant?: "default" | "primary" | "success" | "warning" | "error";
className?: string;
}
const variantStyles: Record<string, string> = {
default: "bg-muted/5",
primary: "bg-primary-soft",
success: "bg-success-soft",
warning: "bg-warning-soft",
error: "bg-error-soft",
};
const valueVariantStyles: Record<string, string> = {
default: "text-foreground",
primary: "text-primary",
success: "text-success",
warning: "text-warning",
error: "text-error",
};
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
return (
<div className={`text-center p-4 rounded-lg ${variantStyles[variant]} ${className}`}>
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
<span className={`text-xs ${valueVariantStyles[variant]}/80`}>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export { Card, CardHeader } from "./Card";
export { Badge, StatusBadge, JobTypeBadge } from "./Badge";
export { StatBox } from "./StatBox";
export { ProgressBar, MiniProgressBar } from "./ProgressBar";
export { Button } from "./Button";
export { Input, Select } from "./Input";
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
export { PageIcon, NavIcon } from "./Icon";