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,6 +1,7 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "../../../lib/api";
import { Card, CardHeader, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui";
interface JobDetailPageProps {
params: Promise<{ id: string }>;
@@ -83,170 +84,143 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
return (
<>
<div className="page-header">
<Link href="/jobs" className="back-link"> Back to jobs</Link>
<h1>Job Details</h1>
<div className="mb-6">
<Link href="/jobs" className="inline-flex items-center text-sm text-muted hover:text-primary transition-colors">
Back to jobs
</Link>
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
</div>
<div className="job-detail-grid">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Overview Card */}
<div className="card job-overview">
<h2>Overview</h2>
<div className="job-meta">
<div className="meta-item">
<span className="meta-label">ID</span>
<code className="meta-value">{job.id}</code>
<Card>
<CardHeader title="Overview" />
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">ID</span>
<code className="px-2 py-1 bg-muted/10 rounded font-mono text-sm text-foreground">{job.id}</code>
</div>
<div className="meta-item">
<span className="meta-label">Type</span>
<span className={`meta-value job-type ${job.type}`}>{job.type}</span>
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Type</span>
<JobTypeBadge type={job.type} />
</div>
<div className="meta-item">
<span className="meta-label">Status</span>
<span className={`meta-value status-badge status-${job.status}`}>{job.status}</span>
<div className="flex items-center justify-between py-2 border-b border-line">
<span className="text-sm text-muted">Status</span>
<StatusBadge status={job.status} />
</div>
<div className="meta-item">
<span className="meta-label">Library</span>
<span className="meta-value">{job.library_id || "All libraries"}</span>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted">Library</span>
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
</div>
</div>
</div>
</Card>
{/* Timeline Card */}
<div className="card job-timeline">
<h2>Timeline</h2>
<div className="timeline">
<div className={`timeline-item ${job.created_at ? 'completed' : ''}`}>
<div className="timeline-dot" />
<div className="timeline-content">
<span className="timeline-label">Created</span>
<span className="timeline-time">{new Date(job.created_at).toLocaleString()}</span>
<Card>
<CardHeader title="Timeline" />
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.created_at ? 'bg-success' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Created</span>
<p className="text-sm text-muted">{new Date(job.created_at).toLocaleString()}</p>
</div>
</div>
<div className={`timeline-item ${job.started_at ? 'completed' : ''} ${!job.started_at ? 'pending' : ''}`}>
<div className="timeline-dot" />
<div className="timeline-content">
<span className="timeline-label">Started</span>
<span className="timeline-time">
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.started_at ? 'bg-success' : job.created_at ? 'bg-warning' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Started</span>
<p className="text-sm text-muted">
{job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."}
</span>
</p>
</div>
</div>
<div className={`timeline-item ${job.finished_at ? 'completed' : ''} ${job.started_at && !job.finished_at ? 'active' : ''} ${!job.started_at ? 'pending' : ''}`}>
<div className="timeline-dot" />
<div className="timeline-content">
<span className="timeline-label">Finished</span>
<span className="timeline-time">
<div className="flex items-start gap-4">
<div className={`w-2 h-2 rounded-full mt-2 ${job.finished_at ? 'bg-success' : job.started_at ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
<div className="flex-1">
<span className="text-sm font-medium text-foreground">Finished</span>
<p className="text-sm text-muted">
{job.finished_at
? new Date(job.finished_at).toLocaleString()
: job.started_at
? "Running..."
: "Waiting..."
}
</span>
</p>
</div>
</div>
</div>
{job.started_at && (
<div className="duration-badge">
<div className="mt-4 inline-flex items-center px-3 py-1.5 bg-primary-soft text-primary rounded-lg text-sm font-medium">
Duration: {formatDuration(job.started_at, job.finished_at)}
</div>
)}
</div>
</Card>
{/* Progress Card */}
{(job.status === "running" || job.status === "success" || job.status === "failed") && (
<div className="card job-progress-detail">
<h2>Progress</h2>
<Card>
<CardHeader title="Progress" />
{job.total_files && job.total_files > 0 && (
<>
<div className="progress-bar-large">
<div
className="progress-fill"
style={{ width: `${job.progress_percent || 0}%` }}
/>
<span className="progress-text">{job.progress_percent || 0}%</span>
</div>
<div className="progress-stats-grid">
<div className="stat-box">
<span className="stat-value">{job.processed_files || 0}</span>
<span className="stat-label">Processed</span>
</div>
<div className="stat-box">
<span className="stat-value">{job.total_files}</span>
<span className="stat-label">Total</span>
</div>
<div className="stat-box">
<span className="stat-value">{job.total_files - (job.processed_files || 0)}</span>
<span className="stat-label">Remaining</span>
</div>
<ProgressBar value={job.progress_percent || 0} showLabel size="lg" className="mb-4" />
<div className="grid grid-cols-3 gap-4">
<StatBox value={job.processed_files || 0} label="Processed" variant="primary" />
<StatBox value={job.total_files} label="Total" />
<StatBox value={job.total_files - (job.processed_files || 0)} label="Remaining" variant="warning" />
</div>
</>
)}
{job.current_file && (
<div className="current-file-box">
<span className="label">Current file:</span>
<code className="file-path">{job.current_file}</code>
<div className="mt-4 p-3 bg-muted/5 rounded-lg">
<span className="text-sm text-muted">Current file:</span>
<code className="block mt-1 text-xs font-mono text-foreground truncate">{job.current_file}</code>
</div>
)}
</div>
</Card>
)}
{/* Statistics Card */}
{job.stats_json && (
<div className="card job-statistics">
<h2>Statistics</h2>
<div className="stats-grid">
<div className="stat-item">
<span className="stat-number success">{job.stats_json.scanned_files}</span>
<span className="stat-label">Scanned</span>
</div>
<div className="stat-item">
<span className="stat-number primary">{job.stats_json.indexed_files}</span>
<span className="stat-label">Indexed</span>
</div>
<div className="stat-item">
<span className="stat-number warning">{job.stats_json.removed_files}</span>
<span className="stat-label">Removed</span>
</div>
<div className="stat-item">
<span className={`stat-number ${job.stats_json.errors > 0 ? 'error' : ''}`}>
{job.stats_json.errors}
</span>
<span className="stat-label">Errors</span>
</div>
<Card>
<CardHeader title="Statistics" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
</div>
{job.started_at && (
<div className="speed-stat">
<span className="speed-label">Speed:</span>
<span className="speed-value">{formatSpeed(job.stats_json, duration)}</span>
<div className="flex items-center justify-between py-2 border-t border-line">
<span className="text-sm text-muted">Speed:</span>
<span className="text-sm font-medium text-foreground">{formatSpeed(job.stats_json, duration)}</span>
</div>
)}
</div>
</Card>
)}
{/* Errors Card */}
{errors.length > 0 && (
<div className="card job-errors">
<h2>Errors ({errors.length})</h2>
<div className="errors-list">
<Card className="lg:col-span-2">
<CardHeader title={`Errors (${errors.length})`} />
<div className="space-y-2 max-h-80 overflow-y-auto">
{errors.map((error) => (
<div key={error.id} className="error-item">
<code className="error-file">{error.file_path}</code>
<span className="error-message">{error.error_message}</span>
<span className="error-time">{new Date(error.created_at).toLocaleString()}</span>
<div key={error.id} className="p-3 bg-error-soft rounded-lg">
<code className="block text-sm font-mono text-error mb-1">{error.file_path}</code>
<p className="text-sm text-error/80">{error.error_message}</p>
<span className="text-xs text-muted">{new Date(error.created_at).toLocaleString()}</span>
</div>
))}
</div>
</div>
</Card>
)}
{/* Error Message */}
{job.error_opt && (
<div className="card job-error-message">
<h2>Error</h2>
<pre className="error-details">{job.error_opt}</pre>
</div>
<Card className="lg:col-span-2">
<CardHeader title="Error" />
<pre className="p-4 bg-error-soft rounded-lg text-sm text-error overflow-x-auto">{job.error_opt}</pre>
</Card>
)}
</div>
</>

View File

@@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, IndexJobDto, LibraryDto } from "../../lib/api";
import { JobsList } from "../components/JobsList";
import { Card, CardHeader, Button, FormField, FormSelect, FormRow } from "../components/ui";
export const dynamic = "force-dynamic";
@@ -30,36 +31,47 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
redirect(`/jobs?highlight=${result.id}`);
}
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN || "";
return (
<>
<h1>Index Jobs</h1>
<div className="card">
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Index Jobs
</h1>
<Card className="mb-6">
<form action={triggerRebuild}>
<select name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
<button type="submit">Queue Rebuild</button>
<FormRow>
<FormField>
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit">🔄 Queue Rebuild</Button>
</FormRow>
</form>
<form action={triggerFullRebuild} style={{ marginTop: '12px' }}>
<select name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</select>
<button type="submit" className="full-rebuild-btn">Full Rebuild (Reindex All)</button>
<form action={triggerFullRebuild} className="mt-3">
<FormRow>
<FormField>
<FormSelect name="library_id" defaultValue="">
<option value="">All libraries</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit" variant="warning">🔁 Full Rebuild</Button>
</FormRow>
</form>
</div>
</Card>
<JobsList
initialJobs={jobs}
libraries={libraryMap}