Initial commit from Create Next App
This commit is contained in:
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<a href="/" className="text-xl font-bold text-white">
|
||||
s2yt
|
||||
</a>
|
||||
{session.user.channelTitle && (
|
||||
<p className="text-xs text-gray-500">Channel: {session.user.channelTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Create Videos</h1>
|
||||
<UploadForm />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<a href="/dashboard" className="text-xl font-bold text-white">
|
||||
s2yt
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<JobProgress jobId={id} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<span className="text-xl font-bold text-white">s2yt</span>
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<SignInButton />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto max-w-3xl px-6 py-20 text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold text-white">
|
||||
Turn images and audio into YouTube videos
|
||||
</h1>
|
||||
<p className="mb-8 text-lg text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="mb-12 grid gap-4 text-left sm:grid-cols-3">
|
||||
<Feature title="Batch creation" desc="One image, many audios — each becomes a separate video." />
|
||||
<Feature title="Per-video settings" desc="Title, tags, privacy, and category for every upload." />
|
||||
<Feature title="Direct upload" desc="Connect YouTube once and publish automatically." />
|
||||
</div>
|
||||
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-block rounded bg-accent px-8 py-3 font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<SignInButton large />
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-sm text-gray-500">
|
||||
Free plan: up to 14 videos/month, 720p max, 30 MB per file
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Feature({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-4">
|
||||
<h3 className="mb-1 font-medium text-white">{title}</h3>
|
||||
<p className="text-sm text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
Reference in New Issue
Block a user