211 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|