Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.

Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.
This commit is contained in:
Atakan Doğan Özban
2026-07-27 16:26:19 +02:00
parent 5c18d199fd
commit 9404efd86c
206 changed files with 37078 additions and 638 deletions
+32 -8
View File
@@ -1,7 +1,10 @@
"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;
@@ -17,7 +20,13 @@ const STATUS_LABELS: Record<string, string> = {
export function JobProgress({ jobId }: Props) {
const [job, setJob] = useState<JobResponse | null>(null);
const [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null);
const [quota, setQuota] = useState<{
remaining: number;
limit: number;
totalAvailable: number;
plan: string;
resetsIn: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -36,7 +45,14 @@ export function JobProgress({ jobId }: Props) {
if (quotaRes.ok) {
const quotaData = await quotaRes.json();
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit });
if (active)
setQuota({
remaining: quotaData.remaining,
limit: quotaData.limit,
totalAvailable: quotaData.totalAvailable ?? quotaData.remaining,
plan: quotaData.plan,
resetsIn: quotaData.resetsIn,
});
}
} catch (err) {
if (active) setError(err instanceof Error ? err.message : "Error loading job");
@@ -60,6 +76,9 @@ export function JobProgress({ jobId }: Props) {
}
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">
@@ -72,11 +91,16 @@ export function JobProgress({ jobId }: Props) {
</div>
{quota && (
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
{quota.remaining} / {quota.limit} videos remaining this month
{quota.remaining} / {quota.limit} plan · {quota.totalAvailable} total available ·{" "}
{quota.plan === "FREE"
? `resets ${quota.resetsIn}`
: `monthly resets on ${quota.resetsIn}`}
</div>
)}
</div>
{youtubeLimitHit && <YouTubeLimitBanner />}
<div className="space-y-3">
{job.items.map((item) => (
<div
@@ -108,24 +132,24 @@ export function JobProgress({ jobId }: Props) {
rel="noopener noreferrer"
className="mt-2 inline-block text-sm text-accent hover:underline"
>
View on YouTube
View on YouTube
</a>
)}
{item.error && (
<p className="mt-2 text-sm text-red-400">{item.error}</p>
{item.status === "FAILED" && displayJobItemError(item.error) && (
<p className="mt-2 text-sm text-red-400">{displayJobItemError(item.error)}</p>
)}
</div>
))}
</div>
{allDone && (
<a
<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
</a>
</Link>
)}
</div>
);