149 lines
4.6 KiB
TypeScript
149 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
|
import type { JobResponse } from "@/lib/types";
|
|
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
|
|
|
type Props = {
|
|
jobId: string;
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
PENDING: "Queued",
|
|
ENCODING: "Encoding video…",
|
|
UPLOADING: "Uploading to YouTube…",
|
|
COMPLETED: "Completed",
|
|
FAILED: "Failed",
|
|
};
|
|
|
|
export function JobProgress({ jobId }: Props) {
|
|
const [job, setJob] = useState<JobResponse | null>(null);
|
|
const [quota, setQuota] = useState<{
|
|
remaining: number;
|
|
limit: number;
|
|
plan: string;
|
|
resetsIn: string;
|
|
} | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
|
|
async function poll() {
|
|
try {
|
|
const [jobRes, quotaRes] = await Promise.all([
|
|
fetch(`/api/jobs/${jobId}`),
|
|
fetch("/api/quota"),
|
|
]);
|
|
|
|
if (!jobRes.ok) throw new Error("Failed to load job");
|
|
const jobData = await jobRes.json();
|
|
if (active) setJob(jobData);
|
|
|
|
if (quotaRes.ok) {
|
|
const quotaData = await quotaRes.json();
|
|
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit, plan: quotaData.plan, resetsIn: quotaData.resetsIn });
|
|
}
|
|
} catch (err) {
|
|
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
|
}
|
|
}
|
|
|
|
poll();
|
|
const interval = setInterval(poll, 3000);
|
|
return () => {
|
|
active = false;
|
|
clearInterval(interval);
|
|
};
|
|
}, [jobId]);
|
|
|
|
if (error) {
|
|
return <div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>;
|
|
}
|
|
|
|
if (!job) {
|
|
return <div className="text-gray-400">Loading job status…</div>;
|
|
}
|
|
|
|
const allDone = job.items.every((i) => i.status === "COMPLETED" || i.status === "FAILED");
|
|
const youtubeLimitHit = job.items.some(
|
|
(i) => i.status === "FAILED" && i.error && isYouTubeUploadLimitError(i.error),
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-white">Job Status</h2>
|
|
<p className="text-sm text-gray-400">
|
|
Overall: <span className="text-white">{job.status}</span>
|
|
</p>
|
|
</div>
|
|
{quota && (
|
|
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
|
{quota.remaining} / {quota.limit} videos remaining ·{" "}
|
|
{quota.plan === "FREE"
|
|
? `resets in ${quota.resetsIn}`
|
|
: `monthly quota resets on ${quota.resetsIn}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{youtubeLimitHit && <YouTubeLimitBanner />}
|
|
|
|
<div className="space-y-3">
|
|
{job.items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="rounded-lg border border-gray-700 bg-surface-light p-4"
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="font-medium text-white">{item.title}</p>
|
|
<p className="text-xs text-gray-500">{item.audioFilename}</p>
|
|
</div>
|
|
<span
|
|
className={`rounded px-2 py-1 text-xs font-medium ${
|
|
item.status === "COMPLETED"
|
|
? "bg-green-500/20 text-green-400"
|
|
: item.status === "FAILED"
|
|
? "bg-red-500/20 text-red-400"
|
|
: "bg-yellow-500/20 text-yellow-400"
|
|
}`}
|
|
>
|
|
{STATUS_LABELS[item.status] || item.status}
|
|
</span>
|
|
</div>
|
|
|
|
{item.youtubeVideoId && (
|
|
<a
|
|
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 inline-block text-sm text-accent hover:underline"
|
|
>
|
|
View on YouTube
|
|
</a>
|
|
)}
|
|
|
|
{item.status === "FAILED" && displayJobItemError(item.error) && (
|
|
<p className="mt-2 text-sm text-red-400">{displayJobItemError(item.error)}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{allDone && (
|
|
<Link
|
|
href="/dashboard"
|
|
className="inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
|
>
|
|
Create another video
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|