feat: implement thumbnail generation and management

- Remove unused image dependencies from Cargo.lock.
- Update API to handle thumbnail generation and checkup processes.
- Introduce new routes for rebuilding and regenerating thumbnails.
- Enhance job tracking with progress indicators for thumbnail jobs.
- Update front-end components to display thumbnail job status and progress.
- Add backend logic for managing thumbnail jobs and integrating with the API.
- Refactor existing code to accommodate new thumbnail functionalities.
This commit is contained in:
2026-03-08 20:55:12 +01:00
parent c93a7d5d29
commit e64848a216
21 changed files with 625 additions and 254 deletions

View File

@@ -87,6 +87,8 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
const percent = progress.progress_percent ?? 0;
const processed = progress.processed_files ?? 0;
const total = progress.total_files ?? 0;
const isThumbnailsPhase = progress.status === "generating_thumbnails";
const unitLabel = isThumbnailsPhase ? "thumbnails" : "files";
return (
<div className="p-4 bg-card rounded-lg border border-border">
@@ -100,7 +102,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
<ProgressBar value={percent} showLabel size="lg" className="mb-3" />
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
<span>{processed} / {total} files</span>
<span>{processed} / {total} {unitLabel}</span>
{progress.current_file && (
<span className="truncate max-w-md" title={progress.current_file}>
Current: {progress.current_file.length > 40
@@ -110,7 +112,7 @@ export function JobProgress({ jobId, onComplete }: JobProgressProps) {
)}
</div>
{progress.stats_json && (
{progress.stats_json && !isThumbnailsPhase && (
<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>

View File

@@ -33,9 +33,8 @@ interface JobRowProps {
}
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const [showProgress, setShowProgress] = useState(
highlighted || job.status === "running" || job.status === "pending"
);
const isActive = job.status === "running" || job.status === "pending" || job.status === "generating_thumbnails";
const [showProgress, setShowProgress] = useState(highlighted || isActive);
const handleComplete = () => {
setShowProgress(false);
@@ -53,12 +52,32 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
const removed = job.stats_json?.removed_files ?? 0;
const errors = job.stats_json?.errors ?? 0;
// Format files display
const filesDisplay = job.status === "running" && job.total_files
? `${job.processed_files || 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
: "-";
const isThumbnailPhase = job.status === "generating_thumbnails";
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
const hasThumbnailPhase = isThumbnailPhase || isThumbnailJob;
// Files column: index-phase stats only
const filesDisplay =
job.status === "running" && !isThumbnailPhase
? job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
: "-"
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
? null // rendered below as ✓ / / ⚠
: scanned > 0
? `${scanned} scanned`
: "—";
// Thumbnails column
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isThumbnailPhase);
const thumbDisplay =
thumbInProgress && job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: job.status === "success" && job.total_files != null && hasThumbnailPhase
? `${job.total_files}`
: "—";
return (
<>
@@ -86,7 +105,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
!
</span>
)}
{(job.status === "running" || job.status === "pending") && (
{isActive && (
<button
className="text-xs text-primary hover:text-primary/80 hover:underline"
onClick={() => setShowProgress(!showProgress)}
@@ -98,21 +117,26 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{filesDisplay}</span>
{job.status === "running" && job.total_files && (
<MiniProgressBar
value={job.processed_files || 0}
max={job.total_files}
className="w-24"
/>
)}
{job.status === "success" && (
{filesDisplay !== null ? (
<span className="text-sm text-foreground">{filesDisplay}</span>
) : (
<div className="flex items-center gap-2 text-xs">
<span className="text-success"> {indexed}</span>
{removed > 0 && <span className="text-warning"> {removed}</span>}
{errors > 0 && <span className="text-error"> {errors}</span>}
</div>
)}
{job.status === "running" && !isThumbnailPhase && job.total_files != null && (
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{thumbDisplay}</span>
{thumbInProgress && job.total_files != null && (
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
@@ -129,7 +153,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
>
View
</Link>
{(job.status === "pending" || job.status === "running") && (
{(job.status === "pending" || job.status === "running" || job.status === "generating_thumbnails") && (
<Button
variant="danger"
size="sm"
@@ -141,9 +165,9 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</div>
</td>
</tr>
{showProgress && (job.status === "running" || job.status === "pending") && (
{showProgress && isActive && (
<tr>
<td colSpan={8} className="px-4 py-3 bg-muted/50">
<td colSpan={9} className="px-4 py-3 bg-muted/50">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -78,7 +78,7 @@ export function JobsIndicator() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const runningJobs = activeJobs.filter(j => j.status === "running");
const runningJobs = activeJobs.filter(j => j.status === "running" || j.status === "generating_thumbnails");
const pendingJobs = activeJobs.filter(j => j.status === "pending");
const totalCount = activeJobs.length;
@@ -210,19 +210,19 @@ export function JobsIndicator() {
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{job.status === "running" && <span className="animate-spin inline-block"></span>}
{(job.status === "running" || job.status === "generating_thumbnails") && <span className="animate-spin inline-block"></span>}
{job.status === "pending" && <span></span>}
</div>
<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-muted rounded font-mono">{job.id.slice(0, 8)}</code>
<Badge variant={job.type === 'rebuild' ? 'primary' : 'secondary'} className="text-[10px]">
{job.type}
<Badge variant={job.type === 'rebuild' ? 'primary' : job.type === 'thumbnail_regenerate' ? 'warning' : 'secondary'} className="text-[10px]">
{job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type}
</Badge>
</div>
{job.status === "running" && job.progress_percent !== null && (
{(job.status === "running" || job.status === "generating_thumbnails") && job.progress_percent != null && (
<div className="flex items-center gap-2 mt-2">
<MiniProgressBar value={job.progress_percent} />
<span className="text-xs font-medium text-muted-foreground">{job.progress_percent}%</span>

View File

@@ -111,6 +111,7 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Thumbnails</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>

View File

@@ -60,6 +60,7 @@ export function Badge({ children, variant = "default", className = "" }: BadgePr
// Status badge for jobs/tasks
const statusVariants: Record<string, BadgeVariant> = {
running: "in-progress",
generating_thumbnails: "in-progress",
success: "completed",
completed: "completed",
failed: "error",
@@ -68,20 +69,33 @@ const statusVariants: Record<string, BadgeVariant> = {
unread: "unread",
};
const statusLabels: Record<string, string> = {
generating_thumbnails: "Thumbnails",
};
interface StatusBadgeProps {
status: string;
className?: string;
}
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
const variant = statusVariants[status.toLowerCase()] || "default";
return <Badge variant={variant} className={className}>{status}</Badge>;
const key = status.toLowerCase();
const variant = statusVariants[key] || "default";
const label = statusLabels[key] ?? status;
return <Badge variant={variant} className={className}>{label}</Badge>;
}
// Job type badge
const jobTypeVariants: Record<string, BadgeVariant> = {
rebuild: "primary",
full_rebuild: "warning",
thumbnail_rebuild: "secondary",
thumbnail_regenerate: "warning",
};
const jobTypeLabels: Record<string, string> = {
thumbnail_rebuild: "Thumbnails",
thumbnail_regenerate: "Regenerate",
};
interface JobTypeBadgeProps {
@@ -90,8 +104,10 @@ interface JobTypeBadgeProps {
}
export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
const variant = jobTypeVariants[type.toLowerCase()] || "default";
return <Badge variant={variant} className={className}>{type}</Badge>;
const key = type.toLowerCase();
const variant = jobTypeVariants[key] || "default";
const label = jobTypeLabels[key] ?? type;
return <Badge variant={variant} className={className}>{label}</Badge>;
}
// Progress badge (shows percentage)