Initial commit from Create Next App

This commit is contained in:
Atakan Doğan Özban
2026-07-12 15:40:35 +02:00
commit a0ca85a63d
52 changed files with 10164 additions and 0 deletions
+24
View File
@@ -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 (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none"
>
{YOUTUBE_CATEGORIES.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
);
}
+132
View File
@@ -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<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 } | 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 });
}
} 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");
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 this month
</div>
)}
</div>
<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.error && (
<p className="mt-2 text-sm text-red-400">{item.error}</p>
)}
</div>
))}
</div>
{allDone && (
<a
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>
)}
</div>
);
}
+35
View File
@@ -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 (
<div className="flex rounded border border-gray-600 overflow-hidden">
{OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={`flex-1 px-3 py-2 text-sm transition-colors ${
value === opt.value
? "bg-accent text-white"
: "bg-surface-light text-gray-300 hover:bg-gray-700"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+26
View File
@@ -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 (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none disabled:opacity-50"
>
{RESOLUTIONS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { signIn } from "next-auth/react";
type Props = {
large?: boolean;
};
export function SignInButton({ large }: Props) {
return (
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className={`rounded bg-white font-medium text-gray-900 transition-colors hover:bg-gray-100 ${
large ? "px-8 py-3 text-base" : "px-4 py-2 text-sm"
}`}
>
Sign in with Google
</button>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { signOut } from "next-auth/react";
export function SignOutButton() {
return (
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:bg-surface-light"
>
Sign out
</button>
);
}
+405
View File
@@ -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<File | null>(null);
const [imagePath, setImagePath] = useState<string | null>(null);
const [imageUploading, setImageUploading] = useState(false);
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<string> {
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<HTMLInputElement>) {
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<HTMLInputElement>) {
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<ItemMetadata>) {
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 (
<form onSubmit={handleSubmit} className="space-y-6">
{quota && (
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
{quota.remaining} of {quota.limit} videos remaining this month
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<section className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4">
<h2 className="text-lg font-medium text-white">Files</h2>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm text-gray-400">Image</label>
<input
type="file"
accept="image/*"
onChange={handleImageChange}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
<div className="mt-2 flex items-center gap-2 text-sm">
<StatusDot ok={!!imagePath && !imageUploading} />
<span className="text-gray-400">
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
</span>
</div>
</div>
<div>
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
<input
type="file"
accept="audio/*"
multiple
onChange={handleAudioChange}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
/>
<div className="mt-2 flex items-center gap-2 text-sm">
<StatusDot ok={readyAudios.length > 0} />
<span className="text-gray-400">
{audioItems.length === 0
? "No audio files selected"
: `${readyAudios.length} of ${audioItems.length} ready`}
</span>
</div>
</div>
</div>
</section>
{audioItems.length > 0 && (
<section className="space-y-4">
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
<p className="text-sm text-gray-400">
Each audio file gets its own metadata. Title is auto-filled from the filename.
</p>
{audioItems.map((item, index) => (
<div
key={item.id}
className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">
Video {index + 1}: {item.file.name}
</h3>
{item.uploading && (
<p className="text-xs text-yellow-400">Uploading</p>
)}
</div>
<button
type="button"
onClick={() => removeAudioItem(item.id)}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Title">
<input
type="text"
value={item.metadata.title}
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
className="input-field"
required
/>
</Field>
<Field label="Category">
<CategorySelect
value={item.metadata.categoryId}
onChange={(v) => updateItemMetadata(item.id, { categoryId: v })}
/>
</Field>
</div>
<Field label="Description">
<textarea
value={item.metadata.description}
onChange={(e) => updateItemMetadata(item.id, { description: e.target.value })}
rows={3}
className="input-field resize-y"
/>
</Field>
<Field label="Tags">
<input
type="text"
value={item.metadata.tags}
onChange={(e) => updateItemMetadata(item.id, { tags: e.target.value })}
placeholder='Separate with spaces or commas. Use "quoted phrases" for multi-word tags.'
className="input-field"
/>
</Field>
<Field label="Privacy">
<PrivacyToggle
value={item.metadata.privacy}
onChange={(v) => updateItemMetadata(item.id, { privacy: v })}
/>
</Field>
<Field label="Video size">
<ResolutionSelect
value={item.metadata.resolution}
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Checkbox
label="Notify subscribers about this upload"
checked={item.metadata.notifySubscribers}
onChange={(v) => updateItemMetadata(item.id, { notifySubscribers: v })}
/>
<Checkbox
label="Made For Kids?"
checked={item.metadata.madeForKids}
onChange={(v) => updateItemMetadata(item.id, { madeForKids: v })}
/>
<Checkbox
label="Embeddable?"
checked={item.metadata.embeddable}
onChange={(v) => updateItemMetadata(item.id, { embeddable: v })}
/>
<Checkbox
label="Creative Commons?"
checked={item.metadata.creativeCommons}
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
/>
<Checkbox
label="Include s2yt watermark?"
checked={item.metadata.includeWatermark}
onChange={() => {}}
disabled
/>
</div>
</div>
))}
</section>
)}
<p className="text-sm text-accent">
Upgrade your account to remove watermarks, go ad-free, and unlock advanced settings.
</p>
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded bg-accent px-6 py-3 font-medium text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting
? "Creating videos…"
: `Create ${readyAudios.length || ""} Video${readyAudios.length !== 1 ? "s" : ""}`}
</button>
</form>
);
}
function StatusDot({ ok }: { ok: boolean }) {
return (
<span
className={`inline-block h-3 w-3 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`}
/>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-sm text-gray-400">{label}</label>
{children}
</div>
);
}
function Checkbox({
label,
checked,
onChange,
disabled,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<label className={`flex items-center gap-2 text-sm text-gray-300 ${disabled ? "opacity-60" : ""}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="rounded border-gray-600 bg-surface-light text-accent focus:ring-accent"
/>
{label}
</label>
);
}