commit 4f29120768d01bdea23562a5d71b608533e87331 Author: Atakan Doğan Özban Date: Sun Jul 12 15:40:35 2026 +0200 Initial commit from Create Next App diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1bc6b9e --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt" +REDIS_URL="redis://localhost:6379" +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="generate-a-random-secret-here" +GOOGLE_CLIENT_ID="your-google-client-id" +GOOGLE_CLIENT_SECRET="your-google-client-secret" +UPLOAD_DIR="./uploads" diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..291d773 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +uploads/ +node_modules/ +.next/ +.env +.env.local diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d04329 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# s2yt + +Create YouTube videos from an image and audio files. + +## Stack + +- Next.js 15 (App Router, TypeScript, Tailwind) +- PostgreSQL + Prisma +- Redis + BullMQ +- NextAuth (Google OAuth with YouTube scopes) +- FFmpeg for video encoding +- YouTube Data API v3 + +## Setup + +1. Copy environment variables: + +```bash +cp .env.example .env +``` + +2. Start PostgreSQL and Redis: + +```bash +docker compose up -d +``` + +3. Install dependencies and run migrations: + +```bash +npm install +npm run db:push +``` + +4. Configure Google OAuth in [Google Cloud Console](https://console.cloud.google.com/): + - Enable YouTube Data API v3 + - Create OAuth 2.0 credentials + - Add redirect URI: `http://localhost:3000/api/auth/callback/google` + - Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env` + +5. Install FFmpeg on your system (required for the worker). + +6. Start the web app and worker in separate terminals: + +```bash +npm run dev +npm run worker +``` + +## Free Plan + +- 14 videos/month (each audio = 1 video) +- Max 720p resolution +- 30 MB max per file +- Watermark required +- Per-video metadata (title auto-filled from audio filename) + +## Scripts + +- `npm run dev` — Next.js dev server +- `npm run worker` — Background job processor +- `npm run db:push` — Push Prisma schema to database +- `npm run build` — Production build diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7b38c1b --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts new file mode 100644 index 0000000..64f6c18 --- /dev/null +++ b/app/api/jobs/[id]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { requireAuth } from "@/lib/session"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { error, user } = await requireAuth(); + if (error || !user) return error!; + + const { id } = await params; + + const job = await prisma.job.findFirst({ + where: { id, userId: user.id }, + include: { + items: { + orderBy: { audioFilename: "asc" }, + select: { + id: true, + audioFilename: true, + title: true, + description: true, + tags: true, + privacy: true, + categoryId: true, + resolution: true, + status: true, + youtubeVideoId: true, + error: true, + }, + }, + }, + }); + + if (!job) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + return NextResponse.json({ + id: job.id, + status: job.status, + createdAt: job.createdAt.toISOString(), + completedAt: job.completedAt?.toISOString() ?? null, + items: job.items, + }); +} diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts new file mode 100644 index 0000000..a332698 --- /dev/null +++ b/app/api/jobs/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { Privacy } from "@prisma/client"; +import { FREE_PLAN, isAllowedResolution } from "@/lib/constants"; +import { prisma } from "@/lib/db"; +import { enqueueVideoJob } from "@/lib/queue/client"; +import { checkQuota } from "@/lib/quota"; +import { requireAuth } from "@/lib/session"; +import { getJobDir } from "@/lib/storage"; +import type { CreateJobPayload } from "@/lib/types"; + +function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) { + if (!metadata.title?.trim()) return "Each video must have a title"; + if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution"; + if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) { + return "Invalid privacy setting"; + } + return null; +} + +export async function POST(req: NextRequest) { + const { error, user } = await requireAuth(); + if (error || !user) return error!; + + const body = (await req.json()) as CreateJobPayload; + + if (!body.imagePath || !body.items?.length) { + return NextResponse.json({ error: "Image and at least one audio file required" }, { status: 400 }); + } + + for (const item of body.items) { + const metaError = validateItemMetadata(item.metadata); + if (metaError) { + return NextResponse.json({ error: metaError }, { status: 400 }); + } + if (user.plan === "FREE" && !item.metadata.includeWatermark) { + return NextResponse.json({ error: "Watermark is required on the free plan" }, { status: 400 }); + } + } + + const quotaCheck = await checkQuota(user.id, body.items.length); + if (!quotaCheck.ok) { + return NextResponse.json({ error: quotaCheck.error }, { status: 403 }); + } + + try { + await fs.access(body.imagePath); + for (const item of body.items) { + await fs.access(item.audioPath); + const stat = await fs.stat(item.audioPath); + if (stat.size > FREE_PLAN.maxFileSizeBytes) { + return NextResponse.json({ error: `Audio file ${item.audioFilename} exceeds size limit` }, { status: 400 }); + } + } + const imageStat = await fs.stat(body.imagePath); + if (imageStat.size > FREE_PLAN.maxFileSizeBytes) { + return NextResponse.json({ error: "Image exceeds size limit" }, { status: 400 }); + } + } catch { + return NextResponse.json({ error: "One or more uploaded files not found" }, { status: 400 }); + } + + const job = await prisma.job.create({ + data: { + userId: user.id, + imagePath: body.imagePath, + items: { + create: body.items.map((item) => ({ + audioPath: item.audioPath, + audioFilename: item.audioFilename, + title: item.metadata.title.trim(), + description: item.metadata.description || "", + tags: item.metadata.tags || "", + privacy: item.metadata.privacy as Privacy, + categoryId: item.metadata.categoryId || "10", + resolution: item.metadata.resolution, + notifySubscribers: item.metadata.notifySubscribers, + madeForKids: item.metadata.madeForKids, + embeddable: item.metadata.embeddable, + creativeCommons: item.metadata.creativeCommons, + includeWatermark: user.plan === "FREE" ? true : item.metadata.includeWatermark, + })), + }, + }, + include: { items: true }, + }); + + const jobDir = getJobDir(user.id, job.id); + await fs.mkdir(jobDir, { recursive: true }); + + const imageExt = path.extname(body.imagePath); + const newImagePath = path.join(jobDir, `image${imageExt}`); + await fs.copyFile(body.imagePath, newImagePath); + await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } }); + + for (const item of job.items) { + const audioExt = path.extname(item.audioPath); + const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`); + await fs.copyFile(item.audioPath, newAudioPath); + await prisma.jobItem.update({ + where: { id: item.id }, + data: { audioPath: newAudioPath }, + }); + + await enqueueVideoJob({ + jobItemId: item.id, + userId: user.id, + jobId: job.id, + }); + } + + return NextResponse.json({ jobId: job.id }); +} + +export async function GET() { + const { error, user } = await requireAuth(); + if (error || !user) return error!; + + const jobs = await prisma.job.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + take: 20, + include: { + items: { + select: { + id: true, + audioFilename: true, + title: true, + status: true, + youtubeVideoId: true, + error: true, + }, + }, + }, + }); + + return NextResponse.json({ jobs }); +} diff --git a/app/api/quota/route.ts b/app/api/quota/route.ts new file mode 100644 index 0000000..8ef46ce --- /dev/null +++ b/app/api/quota/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getQuotaInfo } from "@/lib/quota"; +import { getSessionUser } from "@/lib/session"; + +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const quota = await getQuotaInfo(user.id); + return NextResponse.json(quota); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..8bee9d7 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import { FREE_PLAN } from "@/lib/constants"; +import { getSessionUser } from "@/lib/session"; +import { getUploadDir } from "@/lib/storage"; + +const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const ALLOWED_AUDIO_TYPES = [ + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/x-wav", + "audio/ogg", + "audio/flac", + "audio/aac", + "audio/mp4", + "audio/x-m4a", +]; + +export async function POST(req: NextRequest) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const type = formData.get("type") as string | null; + + if (!file || !type) { + return NextResponse.json({ error: "Missing file or type" }, { status: 400 }); + } + + if (file.size > FREE_PLAN.maxFileSizeBytes) { + return NextResponse.json( + { error: `File exceeds ${FREE_PLAN.maxFileSizeBytes / (1024 * 1024)} MB limit` }, + { status: 400 }, + ); + } + + const allowedTypes = type === "image" ? ALLOWED_IMAGE_TYPES : ALLOWED_AUDIO_TYPES; + if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)) { + return NextResponse.json({ error: "Invalid file type" }, { status: 400 }); + } + + const sessionDir = path.join(getUploadDir(), user.id, "sessions", Date.now().toString()); + await fs.mkdir(sessionDir, { recursive: true }); + + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const filePath = path.join(sessionDir, safeName); + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(filePath, buffer); + + return NextResponse.json({ + path: filePath, + filename: file.name, + size: file.size, + }); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..bd04417 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { UploadForm } from "@/components/UploadForm"; +import { SignOutButton } from "@/components/SignOutButton"; + +export default async function DashboardPage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/"); + + return ( +
+
+
+
+ + s2yt + + {session.user.channelTitle && ( +

Channel: {session.user.channelTitle}

+ )} +
+ +
+
+ +
+

Create Videos

+ +
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..3389368 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,15 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-surface-dark text-gray-100 antialiased; + } +} + +@layer components { + .input-field { + @apply w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-accent focus:outline-none; + } +} diff --git a/app/jobs/[id]/page.tsx b/app/jobs/[id]/page.tsx new file mode 100644 index 0000000..fc40d32 --- /dev/null +++ b/app/jobs/[id]/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { JobProgress } from "@/components/JobProgress"; + +type Props = { + params: Promise<{ id: string }>; +}; + +export default async function JobPage({ params }: Props) { + const session = await getServerSession(authOptions); + if (!session) redirect("/"); + + const { id } = await params; + + return ( +
+
+
+ + s2yt + +
+
+ +
+ +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..625ca38 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { Providers } from "./providers"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "s2yt — Image + Audio to YouTube", + description: "Create and upload YouTube videos from an image and audio files", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..80902df --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { SignInButton } from "@/components/SignInButton"; + +export default async function HomePage() { + const session = await getServerSession(authOptions); + + return ( +
+
+
+ s2yt + {session ? ( + + Dashboard + + ) : ( + + )} +
+
+ +
+

+ Turn images and audio into YouTube videos +

+

+ Upload one image and one or more audio files. Each audio becomes its own video + with individual metadata, then uploads directly to your YouTube channel. +

+ +
+ + + +
+ + {session ? ( + + Go to Dashboard + + ) : ( + + )} + +

+ Free plan: up to 14 videos/month, 720p max, 30 MB per file +

+
+
+ ); +} + +function Feature({ title, desc }: { title: string; desc: string }) { + return ( +
+

{title}

+

{desc}

+
+ ); +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..f4cd92d --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/components/CategorySelect.tsx b/components/CategorySelect.tsx new file mode 100644 index 0000000..4d4d56a --- /dev/null +++ b/components/CategorySelect.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { YOUTUBE_CATEGORIES } from "@/lib/constants"; + +type Props = { + value: string; + onChange: (value: string) => void; +}; + +export function CategorySelect({ value, onChange }: Props) { + return ( + + ); +} diff --git a/components/JobProgress.tsx b/components/JobProgress.tsx new file mode 100644 index 0000000..171defc --- /dev/null +++ b/components/JobProgress.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { JobResponse } from "@/lib/types"; + +type Props = { + jobId: string; +}; + +const STATUS_LABELS: Record = { + PENDING: "Queued", + ENCODING: "Encoding video…", + UPLOADING: "Uploading to YouTube…", + COMPLETED: "Completed", + FAILED: "Failed", +}; + +export function JobProgress({ jobId }: Props) { + const [job, setJob] = useState(null); + const [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null); + const [error, setError] = useState(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 }); + } + } 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
{error}
; + } + + if (!job) { + return
Loading job status…
; + } + + const allDone = job.items.every((i) => i.status === "COMPLETED" || i.status === "FAILED"); + + return ( +
+
+
+

Job Status

+

+ Overall: {job.status} +

+
+ {quota && ( +
+ {quota.remaining} / {quota.limit} videos remaining this month +
+ )} +
+ +
+ {job.items.map((item) => ( +
+
+
+

{item.title}

+

{item.audioFilename}

+
+ + {STATUS_LABELS[item.status] || item.status} + +
+ + {item.youtubeVideoId && ( + + View on YouTube → + + )} + + {item.error && ( +

{item.error}

+ )} +
+ ))} +
+ + {allDone && ( + + Create another video + + )} +
+ ); +} diff --git a/components/PrivacyToggle.tsx b/components/PrivacyToggle.tsx new file mode 100644 index 0000000..a514787 --- /dev/null +++ b/components/PrivacyToggle.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Privacy } from "@prisma/client"; + +type Props = { + value: Privacy; + onChange: (value: Privacy) => void; +}; + +const OPTIONS: { value: Privacy; label: string }[] = [ + { value: "PUBLIC", label: "Public" }, + { value: "PRIVATE", label: "Private" }, + { value: "UNLISTED", label: "Unlisted" }, +]; + +export function PrivacyToggle({ value, onChange }: Props) { + return ( +
+ {OPTIONS.map((opt) => ( + + ))} +
+ ); +} diff --git a/components/ResolutionSelect.tsx b/components/ResolutionSelect.tsx new file mode 100644 index 0000000..5296a42 --- /dev/null +++ b/components/ResolutionSelect.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { RESOLUTIONS } from "@/lib/constants"; + +type Props = { + value: string; + onChange: (value: string) => void; + disabled?: boolean; +}; + +export function ResolutionSelect({ value, onChange, disabled }: Props) { + return ( + + ); +} diff --git a/components/SignInButton.tsx b/components/SignInButton.tsx new file mode 100644 index 0000000..9df4e4a --- /dev/null +++ b/components/SignInButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { signIn } from "next-auth/react"; + +type Props = { + large?: boolean; +}; + +export function SignInButton({ large }: Props) { + return ( + + ); +} diff --git a/components/SignOutButton.tsx b/components/SignOutButton.tsx new file mode 100644 index 0000000..83216f3 --- /dev/null +++ b/components/SignOutButton.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { signOut } from "next-auth/react"; + +export function SignOutButton() { + return ( + + ); +} diff --git a/components/UploadForm.tsx b/components/UploadForm.tsx new file mode 100644 index 0000000..c20cd31 --- /dev/null +++ b/components/UploadForm.tsx @@ -0,0 +1,405 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Privacy } from "@prisma/client"; +import { filenameWithoutExtension } from "@/lib/constants"; +import type { ItemMetadata } from "@/lib/types"; +import { CategorySelect } from "./CategorySelect"; +import { PrivacyToggle } from "./PrivacyToggle"; +import { ResolutionSelect } from "./ResolutionSelect"; + +type AudioItem = { + id: string; + file: File; + path: string | null; + uploading: boolean; + metadata: ItemMetadata; +}; + +function defaultMetadata(title = ""): ItemMetadata { + return { + title, + description: "", + tags: "", + privacy: "PUBLIC" as Privacy, + categoryId: "10", + resolution: "1280x720", + notifySubscribers: true, + madeForKids: false, + embeddable: true, + creativeCommons: false, + includeWatermark: true, + }; +} + +export function UploadForm() { + const router = useRouter(); + const [imageFile, setImageFile] = useState(null); + const [imagePath, setImagePath] = useState(null); + const [imageUploading, setImageUploading] = useState(false); + const [audioItems, setAudioItems] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null); + + const loadQuota = useCallback(async () => { + const res = await fetch("/api/quota"); + if (res.ok) { + const data = await res.json(); + setQuota({ remaining: data.remaining, limit: data.limit }); + } + }, []); + + useEffect(() => { + loadQuota(); + }, [loadQuota]); + + async function uploadFile(file: File, type: "image" | "audio"): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + const res = await fetch("/api/upload", { method: "POST", body: formData }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Upload failed"); + } + const data = await res.json(); + return data.path; + } + + async function handleImageChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setError(null); + setImageFile(file); + setImageUploading(true); + try { + const path = await uploadFile(file, "image"); + setImagePath(path); + } catch (err) { + setError(err instanceof Error ? err.message : "Image upload failed"); + setImageFile(null); + setImagePath(null); + } finally { + setImageUploading(false); + } + } + + async function handleAudioChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files || []); + if (!files.length) return; + setError(null); + + for (const file of files) { + const id = crypto.randomUUID(); + const autoTitle = filenameWithoutExtension(file.name); + + setAudioItems((prev) => [ + ...prev, + { + id, + file, + path: null, + uploading: true, + metadata: defaultMetadata(autoTitle), + }, + ]); + + try { + const path = await uploadFile(file, "audio"); + setAudioItems((prev) => + prev.map((item) => + item.id === id ? { ...item, path, uploading: false } : item, + ), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Audio upload failed"); + setAudioItems((prev) => prev.filter((item) => item.id !== id)); + } + } + + e.target.value = ""; + } + + function updateItemMetadata(id: string, updates: Partial) { + setAudioItems((prev) => + prev.map((item) => + item.id === id + ? { ...item, metadata: { ...item.metadata, ...updates } } + : item, + ), + ); + } + + function removeAudioItem(id: string) { + setAudioItems((prev) => prev.filter((item) => item.id !== id)); + } + + const readyAudios = audioItems.filter((a) => a.path && !a.uploading); + const canSubmit = + imagePath && + !imageUploading && + readyAudios.length > 0 && + readyAudios.every((a) => a.metadata.title.trim()) && + !submitting; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit || !imagePath) return; + + setSubmitting(true); + setError(null); + + try { + const res = await fetch("/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + imagePath, + items: readyAudios.map((item) => ({ + audioPath: item.path!, + audioFilename: item.file.name, + metadata: item.metadata, + })), + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to create job"); + + router.push(`/jobs/${data.jobId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Submission failed"); + setSubmitting(false); + } + } + + return ( +
+ {quota && ( +
+ {quota.remaining} of {quota.limit} videos remaining this month +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+

Files

+ +
+
+ + +
+ + + {imageUploading ? "Uploading…" : imageFile?.name || "No image selected"} + +
+
+ +
+ + +
+ 0} /> + + {audioItems.length === 0 + ? "No audio files selected" + : `${readyAudios.length} of ${audioItems.length} ready`} + +
+
+
+
+ + {audioItems.length > 0 && ( +
+

Video details (per audio)

+

+ Each audio file gets its own metadata. Title is auto-filled from the filename. +

+ + {audioItems.map((item, index) => ( +
+
+
+

+ Video {index + 1}: {item.file.name} +

+ {item.uploading && ( +

Uploading…

+ )} +
+ +
+ +
+ + updateItemMetadata(item.id, { title: e.target.value })} + className="input-field" + required + /> + + + + updateItemMetadata(item.id, { categoryId: v })} + /> + +
+ + +