Files
2026-07-17 02:23:59 +02:00

211 lines
6.7 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
import type { JobResponse } from "@/lib/types";
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
const STATUS_LABELS: Record<string, string> = {
PENDING: "Queued",
ENCODING: "Encoding",
UPLOADING: "Uploading",
COMPLETED: "Completed",
FAILED: "Failed",
};
function sameDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function formatDayLabel(date: Date) {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (sameDay(date, today)) return "Today";
if (sameDay(date, yesterday)) return "Yesterday";
return date.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
function groupJobsByDay(jobs: JobResponse[]) {
const groups = new Map<string, { label: string; jobs: JobResponse[] }>();
for (const job of jobs) {
const date = new Date(job.createdAt);
const key = date.toLocaleDateString("en-CA");
const existing = groups.get(key);
if (existing) {
existing.jobs.push(job);
} else {
groups.set(key, { label: formatDayLabel(date), jobs: [job] });
}
}
return Array.from(groups.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([, group]) => group);
}
function statusClass(status: string) {
if (status === "COMPLETED") return "bg-green-500/20 text-green-400";
if (status === "FAILED") return "bg-red-500/20 text-red-400";
return "bg-yellow-500/20 text-yellow-400";
}
export function JobHistory() {
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function load() {
try {
const res = await fetch("/api/jobs?limit=100");
if (!res.ok) throw new Error("Failed to load history");
const data = await res.json();
if (active) setJobs(data.jobs);
} catch (err) {
if (active) setError(err instanceof Error ? err.message : "Error loading history");
} finally {
if (active) setLoading(false);
}
}
load();
return () => {
active = false;
};
}, []);
const dayGroups = useMemo(() => groupJobsByDay(jobs), [jobs]);
const youtubeLimitHit = useMemo(
() =>
jobs.some((job) =>
job.items.some(
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
),
),
[jobs],
);
if (loading) {
return <div className="text-gray-400">Loading history</div>;
}
if (error) {
return (
<div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>
);
}
if (dayGroups.length === 0) {
return (
<div className="rounded-lg border border-gray-700 bg-surface-light p-8 text-center">
<p className="text-gray-400">No videos created yet.</p>
<Link
href="/dashboard"
className="mt-4 inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
>
Create your first video
</Link>
</div>
);
}
return (
<div className="space-y-8">
{youtubeLimitHit && <YouTubeLimitBanner />}
{dayGroups.map((group) => (
<section key={group.label}>
<h2 className="mb-4 text-lg font-semibold text-white">{group.label}</h2>
<div className="space-y-4">
{group.jobs.map((job) => (
<div
key={job.id}
className="rounded-lg border border-gray-700 bg-surface-light p-4"
>
<div className="mb-3 flex items-center justify-between gap-4">
<div>
<p className="text-sm text-gray-400">
{new Date(job.createdAt).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
{" · "}
{job.items.length} video{job.items.length === 1 ? "" : "s"}
</p>
<p className="text-xs text-gray-500">
Status: <span className="text-gray-300">{job.status}</span>
</p>
</div>
<Link
href={`/jobs/${job.id}`}
className="text-sm text-accent transition-colors hover:text-accent-hover"
>
View job
</Link>
</div>
<div className="space-y-2">
{job.items.map((item) => {
const itemError =
item.status === "FAILED" ? displayJobItemError(item.error) : null;
return (
<div
key={item.id}
className="rounded border border-gray-800 bg-surface px-3 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="truncate font-medium text-white">{item.title}</p>
<p className="truncate text-xs text-gray-500">{item.audioFilename}</p>
{item.youtubeVideoId && (
<a
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-xs text-accent hover:underline"
>
View on YouTube
</a>
)}
</div>
<span
className={`shrink-0 rounded px-2 py-1 text-xs font-medium ${statusClass(item.status)}`}
>
{STATUS_LABELS[item.status] || item.status}
</span>
</div>
{itemError && (
<p className="mt-2 text-xs leading-relaxed text-red-400">{itemError}</p>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</section>
))}
</div>
);
}