Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user