commit 2aa8a84781461d888938edf9caf99ab3cc6835a1 Author: Songs2YT Date: Fri Jul 17 02:23:59 2026 +0200 Initial open-source self-hosted Songs2YT edition diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c9f11f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +website +uploads +*.md +.env +.env.* +!.env.example +deploy-*.tgz +scripts +bg-video diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e65c022 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt" +REDIS_URL="redis://localhost:6379" +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="replace-with-a-long-random-secret" +GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-google-client-secret" +UPLOAD_DIR="./uploads" +S2YT_EDITION="selfhosted" +NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2YT/songs2yt/issues" +NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2YT/songs2yt" +NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2yt" 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..672c1b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +uploads/ +node_modules/ +.next/ +.env +.env.local +website/node_modules/ +website/build/ +website/.docusaurus/ +*.tgz +.DS_Store +basibozuk_cover.jpg +bg-video/ +scripts/ +_restore/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c08491a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +COPY prisma ./prisma +RUN npm ci + +FROM node:22-bookworm-slim AS builder +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npx prisma generate && npm run build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV S2YT_EDITION=selfhosted +ENV UPLOAD_DIR=/app/uploads +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssl ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/package-lock.json ./package-lock.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/worker ./worker +COPY --from=builder /app/lib ./lib +COPY --from=builder /app/tsconfig.json ./tsconfig.json + +RUN mkdir -p /app/uploads \ + && chown -R nextjs:nodejs /app +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f5f1a8 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Songs2YT + +Open-source, self-hosted Songs2YT. Set `S2YT_EDITION=selfhosted` for unlimited quotas (default). + +## Docker + +```bash +cp .env.example .env +docker compose up -d --build +``` + +## Development + +```bash +cp .env.example .env +docker compose -f docker-compose.dev.yml up -d +npm install +npm run db:push +npm run dev +npm run worker +``` + +https://git.atakanozban.com/Songs2YT/songs2yt diff --git a/app/api/account/api-key/route.ts b/app/api/account/api-key/route.ts new file mode 100644 index 0000000..0cc8388 --- /dev/null +++ b/app/api/account/api-key/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys"; +import { hasProFeatures } from "@/lib/edition"; +import { getSessionUser } from "@/lib/session"; + +async function requireApiKeyAccess() { + const user = await getSessionUser(); + if (!user) { + return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null }; + } + if (!hasProFeatures(user.plan)) { + return { + error: NextResponse.json( + { error: "API keys are available on the Pro plan only" }, + { status: 403 }, + ), + user: null, + }; + } + return { error: null, user }; +} + +export async function GET() { + const { error, user } = await requireApiKeyAccess(); + if (error || !user) return error!; + + const status = await getUserApiKeyStatus(user.id); + return NextResponse.json(status); +} + +export async function POST() { + const { error, user } = await requireApiKeyAccess(); + if (error || !user) return error!; + + const { token, prefix } = await createUserApiKey(user.id); + return NextResponse.json({ + apiKey: token, + prefix, + message: "Copy this key now. It will not be shown again.", + }); +} + +export async function DELETE() { + const { error, user } = await requireApiKeyAccess(); + if (error || !user) return error!; + + await revokeUserApiKey(user.id); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/account/api-rate-limit-extension-request/route.ts b/app/api/account/api-rate-limit-extension-request/route.ts new file mode 100644 index 0000000..78c95ca --- /dev/null +++ b/app/api/account/api-rate-limit-extension-request/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createQuotaExtensionRequest, + EXTENSION_KIND, + getQuotaExtensionUsage, +} from "@/lib/quota-extensions"; +import { hasProFeatures } from "@/lib/edition"; +import { getSessionUser } from "@/lib/session"; + +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasProFeatures(user.plan)) { + return NextResponse.json({ error: "Pro plan required" }, { status: 403 }); + } + + const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT); + return NextResponse.json(usage); +} + +export async function POST(req: NextRequest) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasProFeatures(user.plan)) { + return NextResponse.json({ error: "Pro plan required" }, { status: 403 }); + } + + let message = ""; + try { + const body = await req.json(); + if (typeof body.message === "string") message = body.message; + } catch { + // optional body + } + + try { + const result = await createQuotaExtensionRequest( + user.id, + message, + EXTENSION_KIND.API_RATE_LIMIT, + ); + return NextResponse.json(result); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to submit request"; + return NextResponse.json({ error: msg }, { status: 400 }); + } +} diff --git a/app/api/account/api-rate-limit/route.ts b/app/api/account/api-rate-limit/route.ts new file mode 100644 index 0000000..a7f142d --- /dev/null +++ b/app/api/account/api-rate-limit/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; +import { hasProFeatures } from "@/lib/edition"; +import { getSessionUser } from "@/lib/session"; + +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasProFeatures(user.plan)) { + return NextResponse.json( + { error: "API rate limits are available on the Pro plan only" }, + { status: 403 }, + ); + } + + const status = await getApiRateLimitStatus(user.id); + return NextResponse.json(status); +} diff --git a/app/api/account/cancel-subscription/route.ts b/app/api/account/cancel-subscription/route.ts new file mode 100644 index 0000000..5c40f1c --- /dev/null +++ b/app/api/account/cancel-subscription/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getInitialQuotaResetAt } from "@/lib/quota"; +import { getSessionUser } from "@/lib/session"; + +export async function POST() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.plan !== "PREMIUM") { + return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + plan: "FREE", + cardLast4: null, + subscribedAt: null, + apiKeyHash: null, + apiKeyPrefix: null, + apiRateLimitBonus: 0, + quotaResetAt: getInitialQuotaResetAt(), + }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/account/delete/route.ts b/app/api/account/delete/route.ts new file mode 100644 index 0000000..8e7bb10 --- /dev/null +++ b/app/api/account/delete/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getSessionUser } from "@/lib/session"; + +export async function POST() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await prisma.user.delete({ where: { id: user.id } }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/account/quota-extension-request/route.ts b/app/api/account/quota-extension-request/route.ts new file mode 100644 index 0000000..2111378 --- /dev/null +++ b/app/api/account/quota-extension-request/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions"; +import { getSessionUser } from "@/lib/session"; + +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const usage = await getQuotaExtensionUsage(user.id); + return NextResponse.json(usage); +} + +export async function POST(req: NextRequest) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let message = ""; + try { + const body = await req.json(); + if (typeof body.message === "string") message = body.message; + } catch { + // optional body + } + + try { + const result = await createQuotaExtensionRequest(user.id, message); + return NextResponse.json(result); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to submit request"; + return NextResponse.json({ error: msg }, { status: 400 }); + } +} diff --git a/app/api/admin/quota-extension-request/[id]/route.ts b/app/api/admin/quota-extension-request/[id]/route.ts new file mode 100644 index 0000000..48bdde5 --- /dev/null +++ b/app/api/admin/quota-extension-request/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + approveQuotaExtensionRequest, + rejectQuotaExtensionRequest, +} from "@/lib/quota-extensions"; + +function isAuthorized(req: NextRequest) { + const key = process.env.ADMIN_API_KEY; + if (!key) return false; + const auth = req.headers.get("authorization"); + return auth === `Bearer ${key}`; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + let action: "approve" | "reject" = "approve"; + let bonusQuota: number | undefined; + let bonusRateLimit: number | undefined; + let adminNote: string | undefined; + + try { + const body = await req.json(); + if (body.action === "reject") action = "reject"; + if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) { + bonusQuota = body.bonusQuota; + } + if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) { + bonusRateLimit = body.bonusRateLimit; + } + if (typeof body.adminNote === "string") adminNote = body.adminNote; + } catch { + // defaults + } + + try { + if (action === "reject") { + await rejectQuotaExtensionRequest(id, adminNote); + } else { + await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote }); + } + return NextResponse.json({ ok: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to process request"; + return NextResponse.json({ error: msg }, { status: 400 }); + } +} 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..7d88075 --- /dev/null +++ b/app/api/jobs/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createVideoJob } from "@/lib/jobs/create-job"; +import { prisma } from "@/lib/db"; +import { requireAuth } from "@/lib/session"; +import type { CreateJobPayload } from "@/lib/types"; + +export const maxDuration = 120; + +export async function POST(req: NextRequest) { + const { error, user } = await requireAuth(); + if (error || !user) return error!; + + const body = (await req.json()) as CreateJobPayload; + + try { + const job = await createVideoJob(user, body); + return NextResponse.json({ jobId: job.id }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create job"; + const status = message.includes("Quota exceeded") ? 403 : 400; + return NextResponse.json({ error: message }, { status }); + } +} + +export async function GET(req: NextRequest) { + const { error, user } = await requireAuth(); + if (error || !user) return error!; + + const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100); + + const jobs = await prisma.job.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + take: limit, + include: { + items: { + select: { + id: true, + audioFilename: true, + title: true, + status: true, + youtubeVideoId: true, + error: true, + }, + }, + }, + }); + + return NextResponse.json({ + jobs: jobs.map((job) => ({ + id: job.id, + status: job.status, + createdAt: job.createdAt.toISOString(), + completedAt: job.completedAt?.toISOString() ?? null, + items: job.items, + })), + }); +} 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..8262965 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { saveUploadedFile } from "@/lib/jobs/create-job"; +import { getSessionUser } from "@/lib/session"; + +export const maxDuration = 120; + +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; + const sessionKey = (formData.get("session") as string | null)?.trim() || undefined; + + if (!file || !type || (type !== "image" && type !== "audio")) { + return NextResponse.json({ error: "Missing file or type" }, { status: 400 }); + } + + try { + const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey }); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/v1/jobs/[id]/route.ts b/app/api/v1/jobs/[id]/route.ts new file mode 100644 index 0000000..aca6d3d --- /dev/null +++ b/app/api/v1/jobs/[id]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { prisma } from "@/lib/db"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { error, user } = await requirePaidApiUser(_req); + 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/v1/jobs/batch/route.ts b/app/api/v1/jobs/batch/route.ts new file mode 100644 index 0000000..260e3e6 --- /dev/null +++ b/app/api/v1/jobs/batch/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Privacy } from "@prisma/client"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job"; +import { + applyCreatePlaylistToItems, + parseCreatePlaylistInput, +} from "@/lib/jobs/resolve-playlist"; +import { filenameWithoutExtension } from "@/lib/constants"; +import { mapWithConcurrency } from "@/lib/fs-utils"; +import { getPlanLimits } from "@/lib/plans"; +import { checkQuota } from "@/lib/quota"; +import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types"; + +export const maxDuration = 300; + +type BatchItemInput = Partial & { + filename?: string; + title?: string; +}; + +function defaultMetadata(title: string): ItemMetadata { + return { + title, + description: "", + tags: "", + privacy: "PUBLIC", + categoryId: "10", + resolution: "1920x1080", + notifySubscribers: true, + madeForKids: false, + embeddable: true, + creativeCommons: false, + includeWatermark: false, + playlistId: null, + }; +} + +export async function POST(req: NextRequest) { + try { + const { error, user } = await requirePaidApiUser(req); + if (error || !user) return error!; + + const formData = await req.formData(); + const image = formData.get("image") as File | null; + const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File); + const metadataRaw = formData.get("metadata"); + + if (!image || audioFiles.length === 0) { + return NextResponse.json( + { error: "Provide multipart fields: image (file), audio (one or more files)" }, + { status: 400 }, + ); + } + + const limits = getPlanLimits(user.plan); + if (audioFiles.length > limits.maxBatchSize) { + return NextResponse.json( + { error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` }, + { status: 400 }, + ); + } + + const quotaCheck = await checkQuota(user.id, audioFiles.length); + if (!quotaCheck.ok) { + return NextResponse.json({ error: quotaCheck.error }, { status: 403 }); + } + + let itemMeta: BatchItemInput[] = []; + let defaults: Partial = {}; + let createPlaylist: CreatePlaylistRequest | null = null; + + if (metadataRaw) { + try { + const parsed = JSON.parse(String(metadataRaw)) as { + items?: BatchItemInput[]; + defaults?: Partial; + createPlaylist?: unknown; + }; + itemMeta = parsed.items ?? []; + defaults = parsed.defaults ?? {}; + createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist); + if (parsed.createPlaylist && !createPlaylist) { + return NextResponse.json( + { + error: + "createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)", + }, + { status: 400 }, + ); + } + } catch { + return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 }); + } + } + + const sessionKey = Date.now().toString(); + const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey }); + + const uploads = await mapWithConcurrency(audioFiles, 4, (audio) => + saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }), + ); + + const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => { + const audio = audioFiles[i]; + const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {}; + const base = defaultMetadata( + metaInput.title?.trim() || filenameWithoutExtension(audio.name), + ); + const metadata: ItemMetadata = { + ...base, + ...defaults, + ...metaInput, + title: (metaInput.title ?? defaults.title ?? base.title).trim(), + privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy, + }; + + return { + audioPath: upload.path, + audioFilename: upload.filename, + metadata, + }; + }); + + const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist); + + const job = await createVideoJob(user, { + imagePath: imageUpload.path, + items, + }); + + return NextResponse.json({ + jobId: job.id, + itemCount: job.items.length, + status: job.status, + playlist: playlist + ? { id: playlist.id, title: playlist.title, privacy: playlist.privacy } + : null, + }); + } catch (err) { + console.error("Batch upload failed:", err); + const message = err instanceof Error ? err.message : "Batch upload failed"; + const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded") + ? 403 + : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/app/api/v1/jobs/route.ts b/app/api/v1/jobs/route.ts new file mode 100644 index 0000000..6d57d94 --- /dev/null +++ b/app/api/v1/jobs/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { createVideoJob } from "@/lib/jobs/create-job"; +import { + applyCreatePlaylistToItems, + parseCreatePlaylistInput, +} from "@/lib/jobs/resolve-playlist"; +import { prisma } from "@/lib/db"; +import type { CreateJobPayload } from "@/lib/types"; + +export async function POST(req: NextRequest) { + const { error, user } = await requirePaidApiUser(req); + if (error || !user) return error!; + + const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown }; + + try { + const createPlaylist = parseCreatePlaylistInput(body.createPlaylist); + if (body.createPlaylist && !createPlaylist) { + return NextResponse.json( + { + error: + "createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)", + }, + { status: 400 }, + ); + } + + const { items, playlist } = await applyCreatePlaylistToItems( + user, + body.items, + createPlaylist, + ); + + const job = await createVideoJob(user, { + imagePath: body.imagePath, + items, + }); + + return NextResponse.json({ + jobId: job.id, + itemCount: job.items.length, + status: job.status, + playlist: playlist + ? { id: playlist.id, title: playlist.title, privacy: playlist.privacy } + : null, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create job"; + const status = message.includes("Quota exceeded") ? 403 : 400; + return NextResponse.json({ error: message }, { status }); + } +} + +export async function GET(req: NextRequest) { + const { error, user } = await requirePaidApiUser(req); + if (error || !user) return error!; + + const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100); + + const jobs = await prisma.job.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + take: limit, + include: { + items: { + select: { + id: true, + audioFilename: true, + title: true, + status: true, + youtubeVideoId: true, + error: true, + }, + }, + }, + }); + + return NextResponse.json({ + jobs: jobs.map((job) => ({ + id: job.id, + status: job.status, + createdAt: job.createdAt.toISOString(), + completedAt: job.completedAt?.toISOString() ?? null, + items: job.items, + })), + }); +} diff --git a/app/api/v1/playlists/route.ts b/app/api/v1/playlists/route.ts new file mode 100644 index 0000000..52523fc --- /dev/null +++ b/app/api/v1/playlists/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist"; +import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload"; + +export async function GET(req: NextRequest) { + const { error, user } = await requirePaidApiUser(req); + if (error || !user) return error!; + + try { + const playlists = await listYouTubePlaylists(user.id); + return NextResponse.json({ playlists }); + } catch (err) { + console.error("List playlists failed:", err); + const message = err instanceof Error ? err.message : "Failed to list playlists"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + const { error, user } = await requirePaidApiUser(req); + if (error || !user) return error!; + + try { + const body = await req.json(); + const input = parseCreatePlaylistInput(body); + if (!input) { + return NextResponse.json( + { + error: + "Provide JSON body: { title, description?, privacy? } where privacy is public|unlisted|private", + }, + { status: 400 }, + ); + } + + const playlist = await createYouTubePlaylist(user.id, input); + return NextResponse.json({ playlist }); + } catch (err) { + console.error("Create playlist failed:", err); + const message = err instanceof Error ? err.message : "Failed to create playlist"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/v1/route.ts b/app/api/v1/route.ts new file mode 100644 index 0000000..f00f845 --- /dev/null +++ b/app/api/v1/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + name: "Songs2YT API", + version: "1.0", + authentication: "Authorization: Bearer ", + requirements: ["Pro subscription", "YouTube account connected"], + rateLimit: "60 requests per minute per account (plus any approved bonus)", + guidance: { + recommended: + "For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths", + batch: + "POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'", + }, + endpoints: [ + { + method: "POST", + path: "/api/v1/upload", + description: "Upload a single image or audio file (use for two-step flow)", + body: "multipart/form-data: file, type (image|audio)", + }, + { + method: "POST", + path: "/api/v1/jobs", + description: "Create a video job from uploaded file paths (recommended for large batches)", + body: "application/json: { imagePath, items[] }", + }, + { + method: "POST", + path: "/api/v1/jobs/batch", + description: + "One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs", + body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)", + }, + { + method: "GET", + path: "/api/v1/playlists", + description: "List YouTube playlists for the authenticated Pro account", + }, + { + method: "POST", + path: "/api/v1/playlists", + description: "Create a YouTube playlist", + body: "application/json: { title, description?, privacy? (public|unlisted|private) }", + }, + { + method: "GET", + path: "/api/v1/jobs", + description: "List recent jobs", + query: "limit (default 20, max 100)", + }, + { + method: "GET", + path: "/api/v1/jobs/:id", + description: "Get job status and item details", + }, + ], + docs: "/dashboard/api-docs", + }); +} diff --git a/app/api/v1/upload/route.ts b/app/api/v1/upload/route.ts new file mode 100644 index 0000000..454b12a --- /dev/null +++ b/app/api/v1/upload/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { saveUploadedFile } from "@/lib/jobs/create-job"; + +export const maxDuration = 120; + +export async function POST(req: NextRequest) { + const { error, user } = await requirePaidApiUser(req); + if (error || !user) return error!; + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const type = formData.get("type") as string | null; + + if (!file || !type || (type !== "image" && type !== "audio")) { + return NextResponse.json( + { error: "Provide multipart fields: file, type (image|audio)" }, + { status: 400 }, + ); + } + + try { + const result = await saveUploadedFile(user.id, file, type, user.plan); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/app/api/youtube/playlists/route.ts b/app/api/youtube/playlists/route.ts new file mode 100644 index 0000000..13f0c07 --- /dev/null +++ b/app/api/youtube/playlists/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hasProFeatures } from "@/lib/edition"; +import { getSessionUser } from "@/lib/session"; +import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist"; +import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload"; + +async function requirePremiumYouTubeUser() { + const user = await getSessionUser(); + if (!user) { + return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null }; + } + if (!hasProFeatures(user.plan)) { + return { + error: NextResponse.json( + { error: "Playlist features are available on the Pro plan only" }, + { status: 403 }, + ), + user: null, + }; + } + if (!user.youtubeConnection) { + return { + error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }), + user: null, + }; + } + return { error: null, user }; +} + +export async function GET() { + const { error, user } = await requirePremiumYouTubeUser(); + if (error || !user) return error!; + + try { + const playlists = await listYouTubePlaylists(user.id); + return NextResponse.json({ playlists }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list playlists"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + const { error, user } = await requirePremiumYouTubeUser(); + if (error || !user) return error!; + + try { + const body = await req.json(); + const input = parseCreatePlaylistInput(body); + if (!input) { + return NextResponse.json( + { error: "Provide title, and optionally description and privacy (public|unlisted|private)" }, + { status: 400 }, + ); + } + + const playlist = await createYouTubePlaylist(user.id, input); + return NextResponse.json({ playlist }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create playlist"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/dashboard/api-docs/page.tsx b/app/dashboard/api-docs/page.tsx new file mode 100644 index 0000000..6f13ade --- /dev/null +++ b/app/dashboard/api-docs/page.tsx @@ -0,0 +1,219 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { hasProFeatures } from "@/lib/edition"; +import { prisma } from "@/lib/db"; +import { DashboardShell } from "@/components/DashboardShell"; +import { UpgradeProLink } from "@/components/UpgradeProLink"; + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + +export default async function ApiDocsPage() { + const session = await getServerSession(authOptions); + if (!session?.user?.email) redirect("/"); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { youtubeConnection: true }, + }); + if (!user) redirect("/"); + + const isPro = hasProFeatures(user.plan); + + return ( + +
+
+ + ← Back to settings + +

REST API

+

+ Programmatic uploads and batch jobs for Pro subscribers. Generate your API key in{" "} + + Settings + + . +

+ {!isPro && ( +

+ API access requires Pro. +

+ )} +
+ +
+

Authentication

+

+ Send your API key on every request. Keys start with s2yt_live_. +

+ {`Authorization: Bearer s2yt_live_your_key_here`} +
+ +
+

Rate limits

+

+ Default is 60 requests per minute per account. Approved rate-limit extensions increase + that ceiling. Check live usage and request an extension in{" "} + + Settings → API access + + . Video quota limits from your Pro plan still apply to job creation. +

+
+ +
+

Choosing a flow

+
+

+ Recommended for most jobs (especially 5+ audio files):{" "} + two-step: upload each file with POST /api/v1/upload, then + create the job with POST /api/v1/jobs (JSON). +

+

+ One-shot batch{" "} + (POST /api/v1/jobs/batch) is for small packs only, typically + 1 cover + up to a few audio files. Large multipart bodies often fail with{" "} + failed to parse body as FormData. Prefer two-step for albums + or long tracklists. +

+
    +
  • Do not set Content-Type manually for multipart; the client must include the boundary.
  • +
  • For Postman: Body → form-data, each audio row key must be exactly audio (type File).
  • +
  • If a file field shows a warning triangle, re-select the file from disk.
  • +
+
+
+ +
+

Discovery

+ {`GET /api/v1`} +

Returns endpoint list and requirements (no auth).

+
+ +
+

1. Upload a file (two-step)

+ {`curl -X POST "$BASE_URL/api/v1/upload" \\ + -H "Authorization: Bearer $API_KEY" \\ + -F "file=@cover.jpg" \\ + -F "type=image"`} + {`curl -X POST "$BASE_URL/api/v1/upload" \\ + -H "Authorization: Bearer $API_KEY" \\ + -F "file=@track1.mp3" \\ + -F "type=audio"`} +

+ Response includes path,{" "} + filename, and{" "} + size. Upload the image once, then each audio file. Keep the{" "} + path values for the next step. +

+
+ +
+

2. Create job from paths (recommended)

+

+ Use the exact path strings returned by upload. Add one{" "} + items[] entry per track. +

+ {`curl -X POST "$BASE_URL/api/v1/jobs" \\ + -H "Authorization: Bearer $API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "imagePath": "/uploads/.../cover.jpg", + "items": [{ + "audioPath": "/uploads/.../track.mp3", + "audioFilename": "track.mp3", + "metadata": { + "title": "My Track", + "description": "", + "tags": "electronic", + "privacy": "PUBLIC", + "categoryId": "10", + "resolution": "1920x1080", + "notifySubscribers": true, + "madeForKids": false, + "embeddable": true, + "creativeCommons": false, + "includeWatermark": false, + "playlistId": null + } + }] + }'`} +
+ +
+

YouTube playlists (Pro)

+

+ List existing playlists, create a new one, or create one inline when starting a job. + Pass playlistId in item metadata / batch{" "} + defaults, or use{" "} + createPlaylist to make a playlist and attach all + videos to it. Privacy may be public,{" "} + unlisted, or{" "} + private. If playlist permission was just added, + sign out and sign in again for youtube.force-ssl. +

+ {`# List playlists +curl "$BASE_URL/api/v1/playlists" \\ + -H "Authorization: Bearer $API_KEY"`} + {`# Create a playlist +curl -X POST "$BASE_URL/api/v1/playlists" \\ + -H "Authorization: Bearer $API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{"title":"My Album","description":"From Songs2YT","privacy":"unlisted"}'`} + {`# Use an existing playlist ID in metadata / defaults: +{ "playlistId": "PLxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } + +# Or create one inline with a job / batch request: +{ + "createPlaylist": { + "title": "My Album", + "description": "Uploaded via Songs2YT", + "privacy": "private" + }, + "defaults": { "privacy": "PUBLIC" }, + "items": [{ "title": "Track One" }] +}`} +
+ +
+

One-shot batch (small packs only)

+

+ Upload one cover image and a few audio files in a single multipart request. Not recommended for + large batches: use two-step instead if you see FormData parse errors. +

+ {`curl -X POST "$BASE_URL/api/v1/jobs/batch" \\ + -H "Authorization: Bearer $API_KEY" \\ + -F "image=@cover.jpg" \\ + -F "audio=@track1.mp3" \\ + -F "audio=@track2.mp3" \\ + -F 'metadata={"createPlaylist":{"title":"My Album","privacy":"unlisted"},"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'`} +

+ Optional metadata JSON supports{" "} + defaults applied to every item and per-item overrides in{" "} + items. Item order should match the order of{" "} + audio files. +

+
+ +
+

Poll job status

+ {`curl "$BASE_URL/api/v1/jobs/JOB_ID" \\ + -H "Authorization: Bearer $API_KEY"`} + {`curl "$BASE_URL/api/v1/jobs?limit=10" \\ + -H "Authorization: Bearer $API_KEY"`} +
+
+
+ ); +} diff --git a/app/dashboard/history/page.tsx b/app/dashboard/history/page.tsx new file mode 100644 index 0000000..91e6e5c --- /dev/null +++ b/app/dashboard/history/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { DashboardShell } from "@/components/DashboardShell"; +import { JobHistory } from "@/components/JobHistory"; + +export default async function HistoryPage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/"); + + return ( + +
+

History

+ +
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..091fa1d --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { DashboardShell } from "@/components/DashboardShell"; +import { RecentYouTubeLimitAlert } from "@/components/RecentYouTubeLimitAlert"; +import { UploadForm } from "@/components/UploadForm"; + +export default async function DashboardPage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/"); + + return ( + +
+

Create Videos

+ + +
+
+ ); +} diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..6c4a3d6 --- /dev/null +++ b/app/dashboard/settings/page.tsx @@ -0,0 +1,224 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { AccountPrivacyActions } from "@/components/AccountPrivacyActions"; +import { PlanBillingActions } from "@/components/PlanBillingActions"; +import { DashboardShell } from "@/components/DashboardShell"; +import { UpgradeProLink } from "@/components/UpgradeProLink"; +import { getUserApiKeyStatus } from "@/lib/api-keys"; +import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; +import { ApiKeySettings } from "@/components/ApiKeySettings"; +import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition"; +import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions"; +import { getQuotaInfo } from "@/lib/quota"; + +const PLAN_LABELS = { + FREE: "Bedroom Producer", + PREMIUM: "Pro", +} as const; + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export default async function SettingsPage() { + const session = await getServerSession(authOptions); + if (!session?.user?.email) redirect("/"); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { youtubeConnection: true }, + }); + if (!user) redirect("/"); + + const quota = await getQuotaInfo(user.id); + const selfHosted = isSelfHostedEdition(); + const proFeatures = hasProFeatures(user.plan); + const extensionUsage = + !selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null; + const apiRateExtensionUsage = proFeatures + ? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT) + : null; + const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null; + const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null; + const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan]; + const youtube = user.youtubeConnection; + const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null; + + return ( + +
+

Settings

+ +
+
+

Account information

+
+ + +
+ +
+

+ YouTube +

+ {youtube ? ( +
+ + +
+ ) : ( +

YouTube account not connected.

+ )} + + {channelUrl && ( + + Go to your channel + + )} + +

+ To refresh your YouTube permissions,{" "} + + sign out and sign in again + + . +

+
+
+ +
+

+ {selfHosted ? "Usage" : "Plan & billing"} +

+ +
+
+
+

+ Videos processed +

+

+ {selfHosted ? ( + quota.used + ) : ( + <> + {quota.remaining} + + {" "} + of {quota.limit} videos + + + )} +

+
+ {!selfHosted && ( +

+ {quota.used} used + {quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""} +

+ )} +
+ {!selfHosted && ( + <> +
+
+
+

+ {user.plan === "PREMIUM" + ? `Monthly quota resets on ${quota.resetsIn}` + : `Quota resets in ${quota.resetsIn}`} +

+ + )} + {selfHosted && ( +

+ Self-hosted edition: no video or API quotas. +

+ )} +
+ +
+ + {!selfHosted && user.plan === "PREMIUM" && user.subscribedAt && ( + + )} + {!selfHosted && user.plan === "PREMIUM" && ( + + )} + {extensionUsage && ( + + )} + + +
+ + {!selfHosted && user.plan === "FREE" && ( +

+ for more videos, 1080p, and lossless audio. +

+ )} + {!selfHosted && user.plan === "PREMIUM" && extensionUsage && ( + + )} +
+ + {proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && ( +
+

API access

+ +
+ )} + +
+

Account deletion & data

+

+ Request a copy of your data or permanently delete your account and associated uploads. +

+ +
+
+
+
+ ); +} 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..cc93f9a --- /dev/null +++ b/app/globals.css @@ -0,0 +1,248 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + scroll-behavior: smooth; + } + + 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; + } + + .mockup-upload-box { + animation: mockup-border-pulse 3s ease-in-out infinite; + } + + .mockup-upload-box-image { + animation: mockup-border-pulse 3s ease-in-out infinite, + mockup-float 4s ease-in-out infinite; + } + + .mockup-upload-box-audio { + animation: mockup-border-pulse 3s ease-in-out infinite 0.5s, + mockup-float 4s ease-in-out infinite 0.5s; + } + + .mockup-icon-image { + animation: mockup-icon-glow 3s ease-in-out infinite; + } + + .mockup-icon-audio { + animation: mockup-icon-glow 3s ease-in-out infinite 0.5s; + } + + .mockup-progress-shimmer { + animation: mockup-shimmer 2s ease-in-out infinite; + } + + .mockup-dot-1 { + animation: processing-dot 1.4s ease-in-out infinite; + } + + .mockup-dot-2 { + animation: processing-dot 1.4s ease-in-out infinite 0.2s; + } + + .mockup-dot-3 { + animation: processing-dot 1.4s ease-in-out infinite 0.4s; + } + + .benefits-flow-line { + animation: benefits-flow-pulse 2s ease-in-out infinite; + } + + .benefits-process-icon { + animation: benefits-process-pulse 2.5s ease-in-out infinite; + } + + .benefits-flow-path { + stroke-dasharray: 6 8; + animation: benefits-flow-dash 2s linear infinite; + } + + .mobile-sidebar-panel-open { + animation: mobile-sidebar-open 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + .mobile-sidebar-panel-close { + animation: mobile-sidebar-close 0.3s cubic-bezier(0.4, 0, 1, 1) forwards; + } + + .mobile-sidebar-close-btn-open { + animation: mobile-sidebar-close-btn-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + .mobile-sidebar-close-btn-close { + animation: mobile-sidebar-close-btn-out 0.2s ease-in forwards; + } + + .mobile-sidebar-link { + @apply relative block overflow-hidden rounded-lg px-4 py-3 text-base font-medium text-gray-300 transition-all duration-300 ease-out; + } + + .mobile-sidebar-link::before { + content: ""; + @apply absolute bottom-2 left-0 top-2 w-1 origin-left scale-y-0 rounded-r bg-accent transition-transform duration-300 ease-out; + } + + .mobile-sidebar-link:hover { + @apply translate-x-1 bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.12)]; + } + + .mobile-sidebar-link:hover::before { + @apply scale-y-100; + } + + .mobile-sidebar-link-active { + @apply bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.2)]; + } + + .mobile-sidebar-link-active::before { + @apply scale-y-100; + } + + .mobile-sidebar-link-danger:hover { + @apply bg-red-500/10 text-red-300 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.2)]; + } + + .mobile-sidebar-link-danger:hover::before { + @apply bg-red-400; + } +} + +@keyframes mockup-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} + +@keyframes mockup-border-pulse { + 0%, + 100% { + border-color: rgb(75 85 99 / 0.5); + box-shadow: 0 0 0 0 rgb(255 255 255 / 0); + } + 50% { + border-color: rgb(156 163 175 / 0.7); + box-shadow: 0 0 12px 0 rgb(255 255 255 / 0.04); + } +} + +@keyframes mockup-icon-glow { + 0%, + 100% { + opacity: 0.5; + transform: scale(1); + } + 50% { + opacity: 0.85; + transform: scale(1.05); + } +} + +@keyframes mockup-shimmer { + 0% { + left: -30%; + opacity: 0; + } + 30% { + opacity: 0.6; + } + 100% { + left: 100%; + opacity: 0; + } +} + +@keyframes processing-dot { + 0%, + 20% { + opacity: 0; + } + 40%, + 100% { + opacity: 1; + } +} + +@keyframes benefits-flow-pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +@keyframes benefits-process-pulse { + 0%, + 100% { + box-shadow: 0 0 20px rgba(239, 68, 68, 0.2); + } + 50% { + box-shadow: 0 0 28px rgba(239, 68, 68, 0.4); + } +} + +@keyframes benefits-flow-dash { + to { + stroke-dashoffset: -28; + } +} + +@keyframes mobile-sidebar-open { + from { + opacity: 0.85; + transform: translateX(100%) scale(0.96); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes mobile-sidebar-close { + from { + opacity: 1; + transform: translateX(0) scale(1); + } + to { + opacity: 0.85; + transform: translateX(100%) scale(0.96); + } +} + +@keyframes mobile-sidebar-close-btn-in { + from { + opacity: 0; + transform: scale(0.6) rotate(-90deg); + } + to { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} + +@keyframes mobile-sidebar-close-btn-out { + from { + opacity: 1; + transform: scale(1) rotate(0deg); + } + to { + opacity: 0; + transform: scale(0.6) rotate(90deg); + } +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..dc0557d Binary files /dev/null and b/app/icon.png differ diff --git a/app/jobs/[id]/page.tsx b/app/jobs/[id]/page.tsx new file mode 100644 index 0000000..84cd169 --- /dev/null +++ b/app/jobs/[id]/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { DashboardShell } from "@/components/DashboardShell"; +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 ( + +
+ +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a4aa869 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,26 @@ +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: "Songs2YT", + description: + "Songs2YT is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.", +}; + +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..4ff6d01 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,92 @@ +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { BenefitsSection } from "@/components/BenefitsSection"; +import { DownloadSection } from "@/components/DownloadSection"; +import { LandingNavbar } from "@/components/LandingNavbar"; +import { SignInButton } from "@/components/SignInButton"; +import { PricingSection } from "@/components/PricingSection"; +import { StepsSection } from "@/components/StepsSection"; +import { Footer } from "@/components/Footer"; +import { SupportSection } from "@/components/SupportSection"; + +export default async function HomePage() { + const session = await getServerSession(authOptions); + + return ( +
+
+ +
+ + + + + + + + + +
+
+ ); +} diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..21c8a90 --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,180 @@ +import type { Metadata } from "next"; +import { LegalPageLayout } from "@/components/LegalPageLayout"; +import { LEGAL_OPERATOR } from "@/lib/legal/constants"; + +export const metadata: Metadata = { + title: "Privacy Policy | Songs2YT", + description: "How Songs2YT collects, uses, and protects your personal data.", +}; + +export default function PrivacyPage() { + return ( + +

1. Overview

+

+ This Privacy Policy explains how {LEGAL_OPERATOR.name} ("we", "us") + processes personal data when you use our website, hosted cloud service, and related features + that convert audio and images into videos for upload to YouTube. +

+

+ We process personal data in accordance with applicable data protection laws, including the + General Data Protection Regulation (GDPR) where it applies. +

+ +

2. Data controller

+

+ {LEGAL_OPERATOR.legalName} +
+ {LEGAL_OPERATOR.address} +
+ {LEGAL_OPERATOR.city} +
+ Email: {LEGAL_OPERATOR.email} +

+ +

3. What data we collect

+

3.1 Account and authentication data

+

When you sign in with Google, we receive and store:

+
    +
  • Your name, email address, and profile image (from Google)
  • +
  • OAuth tokens required to authenticate your session
  • +
  • YouTube connection data, including channel ID and title
  • +
  • Encrypted YouTube API access and refresh tokens needed to upload videos on your behalf
  • +
+ +

3.2 Uploaded content

+

When you use the service, we temporarily process:

+
    +
  • Image and audio files you upload
  • +
  • Generated video files
  • +
  • Per-video metadata you provide (title, description, tags, privacy settings, etc.)
  • +
+ +

3.3 Usage and technical data

+
    +
  • Plan type, quota usage, and job processing status
  • +
  • IP address, browser type, device information, and request logs
  • +
  • Error reports and operational diagnostics
  • +
+ +

3.4 Payment data

+

+ If you purchase a paid plan, payment processing is handled by our payment provider. We do + not store full payment card details on our servers. We may receive billing status, + subscription identifiers, and transaction references. +

+ +

4. Why we process your data

+
    +
  • + Contract performance: to provide video encoding, metadata handling, and + YouTube upload features you request +
  • +
  • + Legitimate interests: to secure our service, prevent abuse, improve + reliability, and enforce our terms +
  • +
  • + Legal obligations: where required by tax, accounting, or regulatory law +
  • +
  • + Consent: where you have given explicit consent, such as optional + marketing communications if offered +
  • +
+ +

5. Third-party services

+

We use trusted third parties to operate Songs2YT, including:

+
    +
  • + Google / YouTube: authentication and video uploads via Google OAuth and + the YouTube Data API +
  • +
  • + Hosting and infrastructure providers: servers, databases, queues, and + storage +
  • +
  • + Payment processors: for Pro and Enterprise billing when available +
  • +
+

+ These providers process data only as necessary to deliver their services and under + appropriate contractual safeguards where required. +

+ +

6. Data retention

+
    +
  • Uploaded source files and generated outputs are retained only as long as needed to complete your jobs
  • +
  • Account data is kept while your account remains active
  • +
  • Billing records may be retained as required by law
  • +
  • Logs are retained for a limited period for security and troubleshooting
  • +
+

You may request deletion of your account data subject to legal retention obligations.

+ +

7. Self-hosted deployments

+

+ If you deploy Songs2YT on your own infrastructure, you are the data controller for data + processed on your instance. This Privacy Policy applies to the hosted cloud service + operated by us, not to independent self-hosted installations unless we provide managed + hosting for you under contract. +

+ +

8. Your rights

+

Depending on your location, you may have the right to:

+
    +
  • Access the personal data we hold about you
  • +
  • Request correction or deletion
  • +
  • Restrict or object to certain processing
  • +
  • Data portability
  • +
  • Withdraw consent where processing is consent-based
  • +
  • Lodge a complaint with a supervisory authority
  • +
+

+ To exercise these rights, contact{" "} + {LEGAL_OPERATOR.email}. +

+ +

9. Cookies and local storage

+

+ We use essential cookies and similar technologies for authentication, session management, + and security. We do not use non-essential tracking cookies unless disclosed separately and + enabled with your consent where required. +

+ +

10. Security

+

+ We implement appropriate technical and organizational measures to protect your data, + including encryption in transit, access controls, and isolated processing environments. + No method of transmission or storage is 100% secure. +

+ +

11. International transfers

+

+ If data is transferred outside your country, we ensure appropriate safeguards such as + standard contractual clauses or equivalent mechanisms where required by law. +

+ +

12. Children

+

+ Songs2YT is not directed at children under 16. We do not knowingly collect personal data + from children. If you believe a child has provided us data, please contact us. +

+ +

13. Changes to this policy

+

+ We may update this Privacy Policy from time to time. Material changes will be posted on + this page with an updated effective date. +

+ +

14. Contact

+

+ Questions about this Privacy Policy:{" "} + {LEGAL_OPERATOR.email} +

+
+ ); +} 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/app/refund/page.tsx b/app/refund/page.tsx new file mode 100644 index 0000000..0d3981e --- /dev/null +++ b/app/refund/page.tsx @@ -0,0 +1,106 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { LegalPageLayout } from "@/components/LegalPageLayout"; +import { LEGAL_OPERATOR } from "@/lib/legal/constants"; + +export const metadata: Metadata = { + title: "Refund Policy | Songs2YT", + description: "Refund and cancellation policy for Songs2YT paid plans.", +}; + +export default function RefundPage() { + return ( + +

1. Overview

+

+ This Refund Policy explains how refunds and cancellations work for paid Songs2YT plans. The + free Bedroom Producer plan does not involve payments and is not subject to refunds. +

+ +

2. Free plan

+

+ The free plan is provided at no charge. No payment information is required and no refunds + apply. +

+ +

3. Pro subscriptions

+

3.1 Billing cycle

+

+ Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout. + Your subscription renews automatically until cancelled. +

+ +

3.2 14-day refund window

+

+ If you are a new Pro subscriber, you may request a full refund within 14 days of + your initial purchase, provided you have not substantially consumed paid entitlements + (for example, a large portion of your monthly video quota or premium-only features). +

+ +

3.3 After the refund window

+

+ After 14 days, subscription fees are generally non-refundable for the current billing + period. You may cancel at any time to prevent future renewals. Access typically continues + until the end of the paid period. +

+ +

3.4 Cancellation

+

+ You can cancel your subscription through your account billing settings or by contacting{" "} + {LEGAL_OPERATOR.email}. Cancellation stops + future charges; it does not automatically delete your account or uploaded content history + unless you request account deletion separately. +

+ +

4. Enterprise and custom agreements

+

+ Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual + quote or contract signed with us. Refund terms for those services are specified in your + agreement. Contact{" "} + {LEGAL_OPERATOR.salesEmail} for + contract-related billing questions. +

+ +

5. Non-refundable situations

+

Refunds are generally not provided when:

+
    +
  • The refund request is made outside the applicable refund window
  • +
  • The account was terminated for violation of our Terms of Service
  • +
  • The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)
  • +
  • You simply changed your mind after substantial use of paid quota or features
  • +
  • One-time setup or license fees after delivery of agreed setup work, unless required by law or contract
  • +
+ +

6. Chargebacks

+

+ If you believe a charge is incorrect, please contact us before initiating a chargeback so + we can resolve the issue promptly. Unjustified chargebacks may result in account suspension. +

+ +

7. How to request a refund

+

Email us at {LEGAL_OPERATOR.email} with:

+
    +
  • Your account email address
  • +
  • Date of purchase and invoice or transaction reference if available
  • +
  • Reason for the refund request
  • +
+

We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.

+ +

8. Consumer rights

+

+ Nothing in this policy limits mandatory statutory rights you may have as a consumer under + applicable law, including withdrawal rights where required by EU or local consumer + protection regulations. +

+ +

9. Changes

+

+ We may update this Refund Policy from time to time. The version published on this page + applies to purchases made after the effective date shown at the top. +

+
+ ); +} diff --git a/app/terms/page.tsx b/app/terms/page.tsx new file mode 100644 index 0000000..ec97d7a --- /dev/null +++ b/app/terms/page.tsx @@ -0,0 +1,198 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { LegalPageLayout } from "@/components/LegalPageLayout"; +import { LEGAL_OPERATOR } from "@/lib/legal/constants"; + +export const metadata: Metadata = { + title: "Terms of Service | Songs2YT", + description: "Terms and conditions for using the Songs2YT hosted service.", +}; + +export default function TermsPage() { + return ( + +

1. Agreement

+

+ These Terms of Service ("Terms") govern your access to and use of the Songs2YT + website and hosted cloud service (the "Service") operated by{" "} + {LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using + the Service, you agree to these Terms. +

+

+ If you do not agree, do not use the Service. If you self-host the open-source software on + your own infrastructure without using our hosted Service, these Terms apply only to the + extent you use our website, support channels, or paid services we provide. +

+ +

2. The Service

+

+ Songs2YT converts user-provided images and audio files into videos and can upload them to + YouTube using your connected Google/YouTube account. Features, limits, and availability + depend on your plan. +

+
    +
  • + Bedroom Producer (Free): limited quota, 720p, MP3, optional watermark +
  • +
  • + Independent Artist (Pro): expanded limits and features as described on + our pricing page +
  • +
  • + Enterprise: custom terms as agreed in writing +
  • +
+

+ We may modify features, limits, or pricing with reasonable notice where required. The + open-source software is provided separately under its applicable open-source license. +

+ +

3. Eligibility and accounts

+
    +
  • You must be at least 16 years old or the age required in your jurisdiction
  • +
  • You must have a valid Google account and authorized access to the YouTube channel you connect
  • +
  • You are responsible for maintaining the security of your account and OAuth connection
  • +
  • You must provide accurate information and promptly update it if it changes
  • +
+ +

4. Your content and responsibilities

+

You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.

+

You represent and warrant that:

+
    +
  • You own or have all necessary rights to the content you upload
  • +
  • Your content and use of the Service comply with applicable law and YouTube policies
  • +
  • Your content does not infringe third-party rights or contain unlawful material
  • +
  • You have configured metadata (including "Made for Kids" and privacy settings) accurately
  • +
+

+ You are solely responsible for content published to your YouTube channel through the + Service. +

+ +

5. Acceptable use

+

You agree not to:

+
    +
  • Use the Service for unlawful, harmful, or abusive purposes
  • +
  • Upload malware, attempt unauthorized access, or interfere with the Service
  • +
  • Circumvent quotas, plan limits, or technical restrictions
  • +
  • Resell or commercially exploit the hosted Service without authorization
  • +
  • Use the Service in a way that violates Google, YouTube, or third-party terms
  • +
+

We may suspend or terminate access for violations or risks to the Service or other users.

+ +

6. YouTube and third-party services

+

+ The Service integrates with Google OAuth and the YouTube API. Your use of those services is + subject to Google's and YouTube's terms and policies. We are not responsible for + changes, outages, quota limits, or enforcement actions taken by YouTube. +

+ +

7. Open-source software

+

+ Portions of Songs2YT are available as open-source software. Self-hosting is permitted under + the applicable open-source license. The hosted Service, enterprise features, managed + infrastructure, and certain premium capabilities may require a separate commercial license + or subscription. +

+ +

8. Fees and billing

+

+ Paid plans are billed according to the pricing displayed at the time of purchase. Taxes may + apply. Subscriptions renew automatically unless cancelled in accordance with our{" "} + Refund Policy. Failure to pay may result in downgrade or + suspension. +

+ +

8.1 Pro plan quota

+

+ Independent Artist (Pro) subscribers receive a monthly video quota that resets on the{" "} + 1st day of each calendar month (UTC). Unused quota does not roll over to + the next month unless we expressly grant an extension in writing. +

+

+ Pro subscribers may contact{" "} + {LEGAL_OPERATOR.email} to request a manual + quota reset or temporary extension. Each Pro account is entitled to up to{" "} + five (5) manual monthly quota resets per calendar year. We review requests + in good faith and may decline requests that are abusive, repetitive without cause, or + inconsistent with fair use. Approved resets do not increase the annual limit of five + requests. +

+ +

8.2 Pro API access

+

+ Independent Artist (Pro) subscribers may generate an API key in account settings to upload + files and create batch video jobs programmatically. API access requires an active Pro + subscription, a connected YouTube account, and compliance with the same quota, file-type, and + resolution limits as the web interface. API keys are personal, must be kept confidential, and + may be revoked by you or by us if misused. We may apply rate limits and suspend API access + for abuse, security incidents, or plan downgrades. +

+ +

9. Availability and support

+

+ We strive for high availability but do not guarantee uninterrupted access. Maintenance, + updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY + deployments without a paid setup are community-supported unless otherwise agreed in + writing. +

+ +

10. Disclaimer of warranties

+

+ THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" TO THE MAXIMUM EXTENT + PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING, + UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED. +

+ +

11. Limitation of liability

+

+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, + SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR + GOODWILL. OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICE IS + LIMITED TO THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE EVENT GIVING RISE TO + THE CLAIM, OR EUR 100 IF YOU USE THE FREE PLAN ONLY. +

+

+ Some jurisdictions do not allow certain limitations, so some of the above may not apply to + you. +

+ +

12. Indemnification

+

+ You agree to indemnify and hold us harmless from claims arising out of your content, your + use of the Service, or your violation of these Terms or applicable law. +

+ +

13. Termination

+

+ You may stop using the Service at any time. We may suspend or terminate your access if you + breach these Terms, create risk or legal exposure, or where required by law. Upon + termination, your right to use the hosted Service ends. Provisions that by nature should + survive will survive. +

+ +

14. Governing law

+

+ These Terms are governed by the laws of [Your jurisdiction / country], excluding conflict + of law rules. Courts in [Your jurisdiction] shall have exclusive jurisdiction unless + mandatory consumer protection laws in your country provide otherwise. +

+ +

15. Changes

+

+ We may update these Terms from time to time. Continued use after changes become effective + constitutes acceptance of the revised Terms, where permitted by law. +

+ +

16. Contact

+

+ Questions about these Terms:{" "} + {LEGAL_OPERATOR.email} +

+
+ ); +} diff --git a/assets/Purple minimalist Tech Company Logo 120x120.png b/assets/Purple minimalist Tech Company Logo 120x120.png new file mode 100644 index 0000000..bc4755f Binary files /dev/null and b/assets/Purple minimalist Tech Company Logo 120x120.png differ diff --git a/assets/Purple minimalist Tech Company Logo.png b/assets/Purple minimalist Tech Company Logo.png new file mode 100644 index 0000000..e58fd99 Binary files /dev/null and b/assets/Purple minimalist Tech Company Logo.png differ diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..22d6fdf --- /dev/null +++ b/assets/README.md @@ -0,0 +1,5 @@ +# Watermark image + +Place `watermark.png` here for the bottom-right video overlay. +The image should include the full attribution: "Uploaded through Songs2YT.com". +If missing, FFmpeg falls back to bottom-right drawtext with the same message. diff --git a/assets/bg-video.mp4 b/assets/bg-video.mp4 new file mode 100644 index 0000000..0c6b137 Binary files /dev/null and b/assets/bg-video.mp4 differ diff --git a/assets/database.png b/assets/database.png new file mode 100644 index 0000000..b7c3f0b Binary files /dev/null and b/assets/database.png differ diff --git a/assets/favicon-s2yt.png b/assets/favicon-s2yt.png new file mode 100644 index 0000000..dc0557d Binary files /dev/null and b/assets/favicon-s2yt.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..6d1f3d8 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/watermark.png b/assets/watermark.png new file mode 100644 index 0000000..d2cc482 Binary files /dev/null and b/assets/watermark.png differ diff --git a/components/AccountPrivacyActions.tsx b/components/AccountPrivacyActions.tsx new file mode 100644 index 0000000..c9b8cc3 --- /dev/null +++ b/components/AccountPrivacyActions.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { useState } from "react"; +import { SUPPORT_EMAIL } from "@/lib/plans"; + +type Props = { + email: string; +}; + +export function AccountPrivacyActions({ email }: Props) { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + async function handleDeleteAccount() { + if ( + !confirm( + "Delete your account permanently? This removes your jobs, uploads, and YouTube connection. This cannot be undone.", + ) + ) { + return; + } + + setDeleting(true); + setError(null); + + try { + const res = await fetch("/api/account/delete", { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to delete account"); + await signOut({ callbackUrl: "/" }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete account"); + setDeleting(false); + } + } + + const dataRequestSubject = encodeURIComponent("Data export request"); + const dataRequestBody = encodeURIComponent( + `Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`, + ); + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + Request my data + + + +
+ +

+ Data requests are handled within the timelines described in our{" "} + + Privacy Policy + + . +

+
+ ); +} diff --git a/components/ApiKeySettings.tsx b/components/ApiKeySettings.tsx new file mode 100644 index 0000000..61fb8c1 --- /dev/null +++ b/components/ApiKeySettings.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type ApiKeyStatus = { + configured: boolean; + prefix: string | null; +}; + +type RateLimitStatus = { + limit: number; + used: number; + remaining: number; + windowSeconds: number; + resetsInSeconds: number; + bonus?: number; +}; + +type ExtensionRequest = { + id: string; + status: string; + message: string; + requestedAt: string; + processedAt: string | null; + adminNote: string | null; +}; + +type ExtensionUsage = { + used: number; + limit: number; + remaining: number; + requests: ExtensionRequest[]; +}; + +type Props = { + initialStatus: ApiKeyStatus; + initialRateLimit: RateLimitStatus; + initialExtensionUsage: ExtensionUsage; +}; + +export function ApiKeySettings({ + initialStatus, + initialRateLimit, + initialExtensionUsage, +}: Props) { + const [status, setStatus] = useState(initialStatus); + const [rateLimit, setRateLimit] = useState(initialRateLimit); + const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage); + const [newKey, setNewKey] = useState(null); + const [loading, setLoading] = useState(false); + const [requesting, setRequesting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + let active = true; + const refresh = () => { + fetch("/api/account/api-rate-limit") + .then(async (res) => { + if (!res.ok) return; + const data = await res.json(); + if (active) setRateLimit(data); + }) + .catch(() => {}); + }; + refresh(); + const id = setInterval(refresh, 5000); + return () => { + active = false; + clearInterval(id); + }; + }, []); + + async function generateKey() { + if ( + status.configured && + !confirm("This will replace your existing API key. Continue?") + ) { + return; + } + + setLoading(true); + setError(null); + setNewKey(null); + setSuccess(null); + + try { + const res = await fetch("/api/account/api-key", { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to generate API key"); + + setNewKey(data.apiKey); + setStatus({ configured: true, prefix: data.prefix }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate API key"); + } finally { + setLoading(false); + } + } + + async function revokeKey() { + if (!confirm("Revoke your API key? External integrations will stop working.")) return; + + setLoading(true); + setError(null); + setSuccess(null); + + try { + const res = await fetch("/api/account/api-key", { method: "DELETE" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to revoke API key"); + + setStatus({ configured: false, prefix: null }); + setNewKey(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to revoke API key"); + } finally { + setLoading(false); + } + } + + async function requestRateLimitExtension() { + if (extensionUsage.remaining <= 0) return; + + const reason = prompt( + "Optional: tell us why you need a higher API rate limit (leave blank to skip).", + ); + if (reason === null) return; + + setRequesting(true); + setError(null); + setSuccess(null); + + try { + const res = await fetch("/api/account/api-rate-limit-extension-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: reason }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to submit request"); + + setExtensionUsage({ + used: data.used, + limit: data.limit, + remaining: data.remaining, + requests: data.requests ?? extensionUsage.requests, + }); + setSuccess( + `Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`, + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit request"); + } finally { + setRequesting(false); + } + } + + const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING"); + const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100)); + + return ( +
+

+ Use the REST API to upload files and create batch video jobs programmatically. Pro plan + only. +

+ +
+
+

API rate limit

+

+ Resets in {rateLimit.resetsInSeconds}s +

+
+

+ {rateLimit.used} / {rateLimit.limit} requests used this minute + {rateLimit.bonus ? ( + (includes +{rateLimit.bonus} bonus) + ) : null} +

+
+
= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent" + }`} + style={{ width: `${usedPct}%` }} + /> +
+

+ {rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s +

+ +
+

+ Extension requests this year: {extensionUsage.used} / {extensionUsage.limit} +

+ +
+ + {extensionUsage.requests.length > 0 && ( +
    + {extensionUsage.requests.slice(0, 5).map((req) => ( +
  • + {new Date(req.requestedAt).toLocaleDateString()} · {req.status} + {req.adminNote ? ` · ${req.adminNote}` : ""} +
  • + ))} +
+ )} +
+ + {status.configured && status.prefix && ( +

+ Active key: {status.prefix}… +

+ )} + + {newKey && ( +
+

Your new API key (copy now)

+ + {newKey} + +
+ )} + + {success && ( +
+ {success} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + {status.configured && ( + + )} +
+ +
+

Endpoints

+
    +
  • POST /api/v1/upload: upload image or audio file
  • +
  • POST /api/v1/jobs: create job from paths (recommended for large batches)
  • +
  • POST /api/v1/jobs/batch: small packs only (one-shot multipart)
  • +
  • GET /api/v1/playlists: list YouTube playlists
  • +
  • POST /api/v1/playlists: create a YouTube playlist
  • +
  • GET /api/v1/jobs: list jobs
  • +
  • GET /api/v1/jobs/:id: job status
  • +
+

+ Send Authorization: Bearer YOUR_API_KEY on every + request. For many audio files, upload each file then call{" "} + /api/v1/jobs (avoid large one-shot batches).{" "} + + Full API docs + +

+
+
+ ); +} diff --git a/components/BenefitsSection.tsx b/components/BenefitsSection.tsx new file mode 100644 index 0000000..ad4a1b1 --- /dev/null +++ b/components/BenefitsSection.tsx @@ -0,0 +1,436 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { ScrollReveal } from "@/components/ScrollReveal"; +import { useInView } from "@/hooks/useInView"; +import { useMockupProgress } from "@/hooks/useMockupProgress"; +import { SectionScrollTitle } from "@/components/SectionScrollTitle"; + +function FilmStripIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function YouTubeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function CoinsIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} + +function PlaylistIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function NoteIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function WaveIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function GearsIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +const BENEFITS = [ + { + title: "No editing required", + desc: "Skip complex video editors. Just upload an image and audio, and Songs2YT handles the rest.", + Icon: FilmStripIcon, + }, + { + title: "YouTube-ready output", + desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.", + Icon: YouTubeIcon, + }, + { + title: "Free tier included", + desc: "Start creating with 14 videos every 12 hours at up to 720p. No credit card needed.", + Icon: CoinsIcon, + }, + { + title: "YouTube playlists", + desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.", + Icon: PlaylistIcon, + }, +] as const; + +const STOCK_THUMBS = [ + "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format", + "https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format", +] as const; + +function TypewriterText({ text }: { text: string }) { + const [displayed, setDisplayed] = useState(""); + const [done, setDone] = useState(false); + + useEffect(() => { + let index = 0; + const interval = setInterval(() => { + index += 1; + setDisplayed(text.slice(0, index)); + if (index >= text.length) { + clearInterval(interval); + setDone(true); + } + }, 45); + + return () => clearInterval(interval); + }, [text]); + + return ( + + {displayed} + {!done && |} + + ); +} + +function BenefitCard({ + title, + desc, + Icon, +}: { + title: string; + desc: string; + Icon: typeof FilmStripIcon; +}) { + return ( +
+ +

{title}

+

{desc}

+
+ ); +} + +function CheckIcon({ className }: { className?: string }) { + return ( + + ); +} + +function MockupProgressRow({ + initialWidth, + midWidth, + active, + phaseOneMs = 3000, + phaseTwoMs = 2000, +}: { + initialWidth: string; + midWidth: string; + active: boolean; + phaseOneMs?: number; + phaseTwoMs?: number; +}) { + const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs); + + const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%"; + + return ( +
+

{processed ? "Processed" : "Processing..."}

+
+
+
= 1 ? `width ${transitionMs}ms ease-out` : "none", + }} + /> +
+ +
+
+ ); +} + +function DashboardMockup() { + const { ref, inView } = useInView(); + const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const; + + return ( +
+
+
+
+ {sidebarIcons.map((Icon, i) => ( +
+ +
+ ))} +
+ +
+
+ +
+
+
+ Title +
+
+ genre + audio +
+
+ Category Music ▾ +
+
+ + +
+
+
+
+ ); +} + +function ProcessFlow() { + return ( +
+ + +
+
+
+ + +
+ +
+
+ Process +
+ Process +
+ +
+
+ {STOCK_THUMBS.map((src) => ( +
+ +
+ + ▶ + +
+ ))} +
+ + + +
+ +
+

YouTube

+

DIRECT UPLOAD

+
+
+
+
+
+
+ ); +} + +function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) { + return ( +
+
+ {icon === "audio" ? ( + + ) : ( + + + + + + )} +
+ {label} +
+ ); +} + +function BenefitsVisual() { + return ( +
+ + +
+ ); +} + +function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject }) { + return ; +} + +export function BenefitsSection() { + const sectionRef = useRef(null); + + return ( +
+ +
+ +

Benefits

+
+ +
+
+ {BENEFITS.map((benefit, index) => ( + + + + ))} +
+ + + + +
+
+
+ ); +} 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/DashboardNav.tsx b/components/DashboardNav.tsx new file mode 100644 index 0000000..40dc2e8 --- /dev/null +++ b/components/DashboardNav.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import { signOut } from "next-auth/react"; +import { Logo } from "@/components/Logo"; +import { SignOutButton } from "@/components/SignOutButton"; +import { MobileMenuButton, MobileSidebar, dashboardSidebarLinkClass } from "@/components/MobileSidebar"; + +const NAV_LINKS = [ + { href: "/dashboard", label: "Create", match: (path: string) => path === "/dashboard" }, + { + href: "/dashboard/history", + label: "History", + match: (path: string) => path.startsWith("/dashboard/history"), + }, + { + href: "/dashboard/settings", + label: "Settings", + match: (path: string) => path.startsWith("/dashboard/settings"), + }, +] as const; + +type Props = { + channelTitle?: string | null; +}; + +export function DashboardNav({ channelTitle }: Props) { + const pathname = usePathname(); + const [menuOpen, setMenuOpen] = useState(false); + + return ( + <> +
+
+
+ + {channelTitle && ( +

Channel: {channelTitle}

+ )} +
+ +
+ + +
+ + setMenuOpen((prev) => !prev)} + /> +
+
+ + setMenuOpen(false)} title="Dashboard"> + {NAV_LINKS.map(({ href, label, match }) => ( + setMenuOpen(false)} + className={dashboardSidebarLinkClass(match(pathname))} + > + {label} + + ))} + + + + ); +} diff --git a/components/DashboardShell.tsx b/components/DashboardShell.tsx new file mode 100644 index 0000000..06ac120 --- /dev/null +++ b/components/DashboardShell.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { DashboardNav } from "@/components/DashboardNav"; +import { LegalFooter } from "@/components/LegalFooter"; + +type Props = { + channelTitle?: string | null; + children: ReactNode; +}; + +export function DashboardShell({ channelTitle, children }: Props) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/components/DownloadSection.tsx b/components/DownloadSection.tsx new file mode 100644 index 0000000..88ca379 --- /dev/null +++ b/components/DownloadSection.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useRef } from "react"; +import { ScrollReveal } from "@/components/ScrollReveal"; +import { SectionScrollTitle } from "@/components/SectionScrollTitle"; +import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans"; + +function DockerIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function GiteaIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +const DEPLOY_CARDS = [ + { + name: "Docker Hub", + desc: "Pull the image and run Songs2YT on your own server with Docker Compose.", + Icon: DockerIcon, + cta: "View on Docker Hub", + href: DOCKER_HUB_URL, + accent: "text-[#2496ED]", + hover: + "hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20", + titleHover: "group-hover:text-[#2496ED]", + }, + { + name: "Gitea", + desc: "Clone the open-source repository and deploy from source on your infrastructure.", + Icon: GiteaIcon, + cta: "View on Gitea", + href: GITEA_URL, + accent: "text-[#609926]", + hover: + "hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20", + titleHover: "group-hover:text-[#609926]", + }, +] as const; + +function DeployCard({ + name, + desc, + Icon, + cta, + href, + accent, + hover, + titleHover, +}: (typeof DEPLOY_CARDS)[number]) { + return ( + +
+ + + Open Source + +
+

+ {name} +

+

+ {desc} +

+

+ {cta} +

+
+ ); +} + +export function DownloadSection() { + const sectionRef = useRef(null); + + return ( +
+ + +
+ +

Download

+

+ Songs2YT is open source. Self-host on your own server with Docker or deploy from source. +

+
+ +
+ +
+
+

Run it yourself

+

+ Host Songs2YT on your infrastructure with full control over data, queues, and + storage. Ideal for teams and creators who want a private deployment. +

+
+ +
    +
  • + + Docker image published to Docker Hub +
  • +
  • + + Source code available on Gitea +
  • +
  • + + Gitea Issues community support +
  • +
  • + + PostgreSQL, Redis, FFmpeg, and worker included +
  • +
+ +
+

Quick start

+
+                  {`docker pull atakanozban/songs2yt:latest\ndocker compose up -d`}
+                
+
+
+
+ +
+ {DEPLOY_CARDS.map((card, index) => ( + + + + ))} +
+
+
+
+ ); +} diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..29c4657 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,192 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { Logo } from "@/components/Logo"; +import { + DOCKER_HUB_URL, + GITEA_ISSUES_URL, + GITEA_URL, + SUPPORT_EMAIL, +} from "@/lib/plans"; + +function GiteaIcon({ className }: { className?: string }) { + return ( + + ); +} + +function DockerIcon({ className }: { className?: string }) { + return ( + + ); +} + +function FooterLink({ + href, + children, + external, +}: { + href: string; + children: ReactNode; + external?: boolean; +}) { + const className = "transition-colors duration-300 hover:text-white"; + + if (external || href.startsWith("#") || href.startsWith("mailto:")) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +function LegalLink({ + href, + children, + external, +}: { + href: string; + children: ReactNode; + external?: boolean; +}) { + const className = "transition-colors duration-300 hover:text-gray-300"; + + if (external) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +const STATUS_URL = "https://status.atakanozban.com/status/2"; + +export function Footer() { + const year = new Date().getFullYear(); + + return ( +
+
+
+
+ +

+ Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and + fully open-source. +

+ +
+ +
+
+ + Product + + Pricing + Download + Benefits + Privacy Policy + Terms of Service + Refund Policy + + Service Status + +
+ +
+ + Open Source + + + Gitea Instance + + + Docker Image + + + Report a Bug + +
+ +
+ + Contact + + Support Email + Response within 24h for Pro users +
+
+
+ +
+ © {year} Songs2YT. All rights reserved. + + Privacy Policy + + Terms of Service + + Refund Policy + + + Service Status + +
+
+
+ ); +} diff --git a/components/JobHistory.tsx b/components/JobHistory.tsx new file mode 100644 index 0000000..050ca8d --- /dev/null +++ b/components/JobHistory.tsx @@ -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 = { + 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(); + + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading history…
; + } + + if (error) { + return ( +
{error}
+ ); + } + + if (dayGroups.length === 0) { + return ( +
+

No videos created yet.

+ + Create your first video + +
+ ); + } + + return ( +
+ {youtubeLimitHit && } + + {dayGroups.map((group) => ( +
+

{group.label}

+ +
+ {group.jobs.map((job) => ( +
+
+
+

+ {new Date(job.createdAt).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + {" · "} + {job.items.length} video{job.items.length === 1 ? "" : "s"} +

+

+ Status: {job.status} +

+
+ + View job + +
+ +
+ {job.items.map((item) => { + const itemError = + item.status === "FAILED" ? displayJobItemError(item.error) : null; + + return ( +
+
+
+

{item.title}

+

{item.audioFilename}

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

{itemError}

+ )} +
+ ); + })} +
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/components/JobProgress.tsx b/components/JobProgress.tsx new file mode 100644 index 0000000..ace0739 --- /dev/null +++ b/components/JobProgress.tsx @@ -0,0 +1,148 @@ +"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; +}; + +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; + plan: string; + resetsIn: string; + } | 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, plan: quotaData.plan, resetsIn: quotaData.resetsIn }); + } + } 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"); + const youtubeLimitHit = job.items.some( + (i) => i.status === "FAILED" && i.error && isYouTubeUploadLimitError(i.error), + ); + + return ( +
+
+
+

Job Status

+

+ Overall: {job.status} +

+
+ {quota && ( +
+ {quota.remaining} / {quota.limit} videos remaining ·{" "} + {quota.plan === "FREE" + ? `resets in ${quota.resetsIn}` + : `monthly quota resets on ${quota.resetsIn}`} +
+ )} +
+ + {youtubeLimitHit && } + +
+ {job.items.map((item) => ( +
+
+
+

{item.title}

+

{item.audioFilename}

+
+ + {STATUS_LABELS[item.status] || item.status} + +
+ + {item.youtubeVideoId && ( + + View on YouTube + + )} + + {item.status === "FAILED" && displayJobItemError(item.error) && ( +

{displayJobItemError(item.error)}

+ )} +
+ ))} +
+ + {allDone && ( + + Create another video + + )} +
+ ); +} diff --git a/components/LandingNavbar.tsx b/components/LandingNavbar.tsx new file mode 100644 index 0000000..49e885b --- /dev/null +++ b/components/LandingNavbar.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { Logo } from "@/components/Logo"; +import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar"; + +const NAV_LINKS = [ + { href: "#benefits", label: "Benefits" }, + { href: "#download", label: "Download" }, + { href: "#pricing", label: "Pricing" }, + { href: "#support", label: "Support" }, +] as const; + +function NavAnchor({ + href, + label, + onNavigate, + className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white", +}: { + href: string; + label: string; + onNavigate?: () => void; + className?: string; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + const id = href.replace("#", ""); + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); + window.history.pushState(null, "", href); + onNavigate?.(); + }; + + return ( + + {label} + + ); +} + +export function LandingNavbar() { + const [menuOpen, setMenuOpen] = useState(false); + + return ( + <> +
+
+ + +
+ + + setMenuOpen((prev) => !prev)} + /> +
+
+
+ + setMenuOpen(false)} title="Menu"> + {NAV_LINKS.map(({ href, label }) => ( + setMenuOpen(false)} + className={sidebarLinkClass()} + /> + ))} + + + ); +} diff --git a/components/LegalFooter.tsx b/components/LegalFooter.tsx new file mode 100644 index 0000000..d1d3c7a --- /dev/null +++ b/components/LegalFooter.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +function LegalLink({ + href, + children, + external, +}: { + href: string; + children: ReactNode; + external?: boolean; +}) { + const className = "transition-colors duration-300 hover:text-gray-300"; + + if (external) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +export function LegalFooter() { + const year = new Date().getFullYear(); + + return ( +
+
+ © {year} Songs2YT. All rights reserved. + + Privacy Policy + + Terms of Service + + Refund Policy + + + Service Status + +
+
+ ); +} diff --git a/components/LegalPageLayout.tsx b/components/LegalPageLayout.tsx new file mode 100644 index 0000000..311d57e --- /dev/null +++ b/components/LegalPageLayout.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { Logo } from "@/components/Logo"; +import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants"; + +type Props = { + title: string; + description?: string; + children: ReactNode; +}; + +export function LegalPageLayout({ title, description, children }: Props) { + return ( +
+
+
+ + + ← Back to home + +
+
+ +
+

+ Last updated: {LEGAL_LAST_UPDATED} +

+

{title}

+ {description &&

{description}

} + +
+ {children} +
+
+ + +
+ ); +} diff --git a/components/Logo.tsx b/components/Logo.tsx new file mode 100644 index 0000000..11d8587 --- /dev/null +++ b/components/Logo.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +type Props = { + href?: string; + size?: "sm" | "md"; +}; + +export function Logo({ href = "/", size = "md" }: Props) { + const textSize = size === "sm" ? "text-xl" : "text-2xl"; + + const content = ( + + Songs + 2YT + + ); + + if (href) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/components/MobileSidebar.tsx b/components/MobileSidebar.tsx new file mode 100644 index 0000000..5ac53a8 --- /dev/null +++ b/components/MobileSidebar.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { Children, useEffect, useState, type ReactNode } from "react"; + +type Props = { + open: boolean; + onClose: () => void; + title?: string; + children: ReactNode; +}; + +const PANEL_MS = 420; +const ITEM_BASE_MS = 90; + +function CloseIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function MobileSidebar({ open, onClose, title = "Menu", children }: Props) { + const [mounted, setMounted] = useState(false); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (open) { + setMounted(true); + const frame = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(frame); + } + + setVisible(false); + const timer = window.setTimeout(() => setMounted(false), PANEL_MS); + return () => window.clearTimeout(timer); + }, [open]); + + useEffect(() => { + if (!mounted) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", onKeyDown); + + return () => { + document.body.style.overflow = ""; + window.removeEventListener("keydown", onKeyDown); + }; + }, [mounted, onClose]); + + if (!mounted) return null; + + return ( +
+ + + +
+ ); +} + +export function MobileMenuButton({ + onClick, + open = false, +}: { + onClick: () => void; + open?: boolean; +}) { + return ( + + ); +} + +export function sidebarLinkClass(active = false) { + return `block rounded-lg px-4 py-3 text-base font-medium transition-all duration-200 hover:translate-x-0.5 ${ + active ? "bg-surface-light text-white" : "text-gray-300 hover:bg-surface-light hover:text-white" + }`; +} + +export function dashboardSidebarLinkClass(active = false, danger = false) { + const classes = ["mobile-sidebar-link"]; + if (active) classes.push("mobile-sidebar-link-active"); + if (danger) classes.push("mobile-sidebar-link-danger"); + return classes.join(" "); +} diff --git a/components/PlanBillingActions.tsx b/components/PlanBillingActions.tsx new file mode 100644 index 0000000..0ee3088 --- /dev/null +++ b/components/PlanBillingActions.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type ExtensionRequest = { + id: string; + status: string; + message: string; + requestedAt: string; + processedAt: string | null; + adminNote: string | null; +}; + +type ExtensionUsage = { + used: number; + limit: number; + remaining: number; + requests: ExtensionRequest[]; +}; + +type Props = { + initialUsage: ExtensionUsage; +}; + +export function PlanBillingActions({ initialUsage }: Props) { + const router = useRouter(); + const [usage, setUsage] = useState(initialUsage); + const [cancelling, setCancelling] = useState(false); + const [requesting, setRequesting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + async function handleCancelPlan() { + if ( + !confirm( + "Cancel your Pro plan? You will be moved to the free plan immediately and lose Pro benefits.", + ) + ) { + return; + } + + setCancelling(true); + setError(null); + setSuccess(null); + + try { + const res = await fetch("/api/account/cancel-subscription", { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to cancel subscription"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to cancel subscription"); + } finally { + setCancelling(false); + } + } + + async function handleQuotaRequest() { + if (usage.remaining <= 0) return; + + const reason = prompt( + "Optional: tell us why you need a quota reset or extension (leave blank to skip).", + ); + if (reason === null) return; + + setRequesting(true); + setError(null); + setSuccess(null); + + try { + const res = await fetch("/api/account/quota-extension-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: reason }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to submit request"); + + setUsage({ + used: data.used, + limit: data.limit, + remaining: data.remaining, + requests: data.requests ?? usage.requests, + }); + setSuccess( + `Request submitted. ${data.used} of ${data.limit} extension requests used this year. Support will process it shortly.`, + ); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit request"); + } finally { + setRequesting(false); + } + } + + const hasPending = usage.requests.some((r) => r.status === "PENDING"); + + return ( +
+
+

+ Extension requests this year:{" "} + + {usage.used} / {usage.limit} + + {usage.remaining > 0 ? ( + · {usage.remaining} remaining + ) : ( + · limit reached + )} +

+
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ + + +
+ + {usage.requests.length > 0 && ( +
+

+ Request history +

+
    + {usage.requests.slice(0, 5).map((req) => ( +
  • +
    + + {new Date(req.requestedAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + {req.status} + +
    + {req.message &&

    {req.message}

    } +

    ID: {req.id}

    +
  • + ))} +
+
+ )} + +

+ Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "} + + Terms of Service + + . +

+
+ ); +} diff --git a/components/PlaylistSelect.tsx b/components/PlaylistSelect.tsx new file mode 100644 index 0000000..63b9c59 --- /dev/null +++ b/components/PlaylistSelect.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { UpgradeProLink } from "./UpgradeProLink"; + +type Playlist = { + id: string; + title: string; + itemCount: number; +}; + +type Props = { + value: string; + onChange: (playlistId: string) => void; + enabled: boolean; +}; + +export function PlaylistSelect({ value, onChange, enabled }: Props) { + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [privacy, setPrivacy] = useState<"public" | "unlisted" | "private">("private"); + + const loadPlaylists = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/youtube/playlists"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load playlists"); + setPlaylists(data.playlists ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load playlists"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!enabled) return; + void loadPlaylists(); + }, [enabled, loadPlaylists]); + + async function handleCreate() { + if (!title.trim()) { + setError("Playlist title is required"); + return; + } + + setCreating(true); + setError(null); + try { + const res = await fetch("/api/youtube/playlists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + description: description.trim() || undefined, + privacy, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to create playlist"); + + const playlist = data.playlist as Playlist; + setPlaylists((prev) => [playlist, ...prev]); + onChange(playlist.id); + setShowCreate(false); + setTitle(""); + setDescription(""); + setPrivacy("private"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create playlist"); + } finally { + setCreating(false); + } + } + + if (!enabled) { + return ( +

+ Add uploaded videos to a YouTube playlist with{" "} + . +

+ ); + } + + return ( +
+
+ + +
+ +
+ + +
+ + {showCreate && ( +
+
+ + setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} + className="input-field w-full" + placeholder="My Songs2YT uploads" + /> +
+
+ + setDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} + className="input-field w-full" + /> +
+
+ + +
+ +
+ )} + + {loading &&

Loading playlists…

} + {error &&

{error}

} + {!loading && !error && playlists.length === 0 && !showCreate && ( +

+ No playlists yet. Create one above. If playlist access was just added, sign out and sign + in again to grant the new Google permission. +

+ )} +
+ ); +} diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx new file mode 100644 index 0000000..c775989 --- /dev/null +++ b/components/PricingSection.tsx @@ -0,0 +1,330 @@ +"use client"; + +import Link from "next/link"; +import { useRef } from "react"; +import { ScrollReveal } from "@/components/ScrollReveal"; +import { SectionScrollTitle } from "@/components/SectionScrollTitle"; +import { SignInButton } from "@/components/SignInButton"; +import { SALES_EMAIL } from "@/lib/plans"; + +const FEATURE_LABELS = [ + "Infrastructure", + "Limit", + "Batch mode", + "File type", + "Playlists", + "API support", + "ID3 tags", + "Support", + "Watermark", +] as const; + +type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>; + +type InfrastructureChip = "cloud" | "self-hosted"; + +type PlanConfig = { + name: string; + badge: string; + price: string; + priceNote: string; + features: PlanFeatures; + infrastructureChips: InfrastructureChip[]; + watermarkNote?: string; + cta: + | { type: "signin" } + | { type: "link"; label: string; href: string } + | { type: "disabled"; label: string } + | { type: "mailto"; label: string; email: string; subject: string }; + highlighted: boolean; + hover: string; + accent: string; + badgeClass: string; + ctaClass?: string; +}; + +const PLANS: PlanConfig[] = [ + { + name: "Bedroom Producer", + badge: "Free", + price: "€0", + priceNote: "/ forever", + features: { + Infrastructure: "Cloud", + Limit: "14 videos / 12 hours · 720p", + "Batch mode": "Limited batch · up to 3 files", + "File type": "MP3", + Playlists: "Not included", + "API support": "Not included", + "ID3 tags": "Auto-fill title & metadata from MP3 tags", + Support: "Community", + Watermark: "Optional · support badge", + }, + infrastructureChips: ["cloud"], + watermarkNote: + "Show \"Uploaded through Songs2YT.com\" to support open-source development - opt out anytime for a clean video.", + cta: { type: "signin" }, + highlighted: false, + hover: + "hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15", + accent: "text-red-400", + badgeClass: "bg-gray-800 text-gray-400", + }, + { + name: "Independent Artist", + badge: "Pro", + price: "€5", + priceNote: "/ mo", + features: { + Infrastructure: "Cloud", + Limit: "50 videos* / month · 1080p", + "Batch mode": "Full batch · up to 5 files", + "File type": "MP3 / WAV / FLAC", + Playlists: "Create & add uploads to YouTube playlists", + "API support": "REST API · upload, batch & playlists", + "ID3 tags": "Extended metadata support", + Support: "E-Mail", + Watermark: "Fully customizable · remove completely", + }, + infrastructureChips: ["cloud"], + cta: { type: "disabled", label: "Coming soon" }, + highlighted: true, + hover: + "hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20", + accent: "text-accent", + badgeClass: "bg-accent/15 text-accent", + }, + { + name: "Record Label / Studio", + badge: "Enterprise", + price: "Custom", + priceNote: "/ contact sales", + features: { + Infrastructure: "Cloud or self-hosted", + Limit: "Unlimited 4K · zero limit", + "Batch mode": "Unlimited synchronized batch processing", + "File type": "WAV / FLAC / lossless", + Playlists: "Org-wide playlist workflows", + "API support": "Full API access · custom integrations & SLAs", + "ID3 tags": "Full metadata · custom mapping", + Support: "Top-priority**", + Watermark: "Fully customizable · remove completely", + }, + infrastructureChips: ["cloud", "self-hosted"], + cta: { + type: "mailto", + label: "Contact Sales", + email: SALES_EMAIL, + subject: "Songs2YT Enterprise Inquiry", + }, + highlighted: false, + hover: + "hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20", + accent: "text-[#609926]", + badgeClass: "bg-[#609926]/15 text-[#609926]", + ctaClass: + "border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20", + }, +]; + +function FeatureValue({ value }: { value: string }) { + return {value}; +} + +function CloudIcon({ className }: { className?: string }) { + return ( + + ); +} + +function ServerIcon({ className }: { className?: string }) { + return ( + + ); +} + +function InfrastructureChips({ + chips, +}: { + chips: InfrastructureChip[]; +}) { + return ( +
+ {chips.includes("cloud") && ( + + + Cloud + + )} + {chips.includes("self-hosted") && ( + + + Self-hosted + + )} +
+ ); +} + +function PricingCard({ plan }: { plan: PlanConfig }) { + const { + name, + badge, + price, + priceNote, + features, + watermarkNote, + infrastructureChips, + cta, + highlighted, + hover, + accent, + badgeClass, + ctaClass, + } = plan; + + return ( +
+ {highlighted && ( + + Most popular + + )} + +
+
+

{name}

+ + {badge} + +
+ +
+ {price} + {priceNote} +
+
+ +
    + {FEATURE_LABELS.map((label) => ( +
  • +

    {label}

    + {label === "Infrastructure" ? ( + + ) : ( + + )} + {label === "Watermark" && watermarkNote && ( +

    + {watermarkNote} +

    + )} +
  • + ))} +
+ +
+ {cta.type === "signin" && } + {cta.type === "link" && ( + + {cta.label} + + )} + {cta.type === "disabled" && ( + + )} + {cta.type === "mailto" && ( + + {cta.label} + + )} +
+
+ ); +} + +export function PricingSection() { + const sectionRef = useRef(null); + + return ( +
+ + +
+ +

Pricing

+

+ Free for getting started, Pro for creators, or Enterprise for teams. +

+
+ +
+ {PLANS.map((plan, index) => ( + + + + ))} +
+ + +

+ *Pro is a monthly subscription with included quota (extensions via support). Self-hosted + open-source deployments have no quotas. +

+

+ Technical support is strictly reserved for Managed Cloud and paid Professional Setup + agreements; independent self-hosted deployments are community-supported. +

+
+
+
+ ); +} 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/RecentYouTubeLimitAlert.tsx b/components/RecentYouTubeLimitAlert.tsx new file mode 100644 index 0000000..9276710 --- /dev/null +++ b/components/RecentYouTubeLimitAlert.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { isYouTubeUploadLimitError } from "@/lib/youtube/errors"; +import { YouTubeLimitBanner } from "./YouTubeLimitBanner"; + +/** Shows a dashboard alert when a recent job hit YouTube's upload limit. */ +export function RecentYouTubeLimitAlert() { + const [show, setShow] = useState(false); + + useEffect(() => { + let active = true; + + fetch("/api/jobs?limit=10") + .then(async (res) => { + if (!res.ok) return; + const data = await res.json(); + const hit = (data.jobs ?? []).some((job: { items?: Array<{ status: string; error?: string | null }> }) => + (job.items ?? []).some( + (item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error), + ), + ); + if (active) setShow(hit); + }) + .catch(() => {}); + + return () => { + active = false; + }; + }, []); + + if (!show) return null; + return ; +} diff --git a/components/ResolutionSelect.tsx b/components/ResolutionSelect.tsx new file mode 100644 index 0000000..e73edb7 --- /dev/null +++ b/components/ResolutionSelect.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Plan } from "@prisma/client"; +import { getResolutionsForPlan } from "@/lib/plans"; + +type Props = { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + plan?: Plan; +}; + +export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) { + const resolutions = getResolutionsForPlan(plan); + + return ( + + ); +} diff --git a/components/ScrollReveal.tsx b/components/ScrollReveal.tsx new file mode 100644 index 0000000..e75181b --- /dev/null +++ b/components/ScrollReveal.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +type Direction = "up" | "left" | "right"; + +type Props = { + children: ReactNode; + className?: string; + delay?: number; + direction?: Direction; +}; + +const OFFSET: Record = { + up: "translate-y-10", + left: "-translate-x-10", + right: "translate-x-10", +}; + +export function ScrollReveal({ + children, + className = "", + delay = 0, + direction = "up", +}: Props) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.unobserve(el); + } + }, + { threshold: 0.15, rootMargin: "0px 0px -40px 0px" }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/components/SectionScrollTitle.tsx b/components/SectionScrollTitle.tsx new file mode 100644 index 0000000..ab09b3b --- /dev/null +++ b/components/SectionScrollTitle.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState, type RefObject } from "react"; + +type Props = { + sectionRef: RefObject; + title: string; + offset?: number; +}; + +export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) { + const [offsetX, setOffsetX] = useState(0); + + useEffect(() => { + const section = sectionRef.current; + if (!section) return; + + const update = () => { + const rect = section.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const progress = Math.min( + 1, + Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)), + ); + setOffsetX(progress * offset); + }; + + update(); + window.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update); + return () => { + window.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [sectionRef, offset]); + + return ( + + ); +} diff --git a/components/SignInButton.tsx b/components/SignInButton.tsx new file mode 100644 index 0000000..56da464 --- /dev/null +++ b/components/SignInButton.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { signIn } from "next-auth/react"; + +function GoogleIcon() { + return ( + + ); +} + +type Props = { + large?: boolean; + fullWidth?: boolean; +}; + +export function SignInButton({ large, fullWidth }: 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/StepsSection.tsx b/components/StepsSection.tsx new file mode 100644 index 0000000..931a9f2 --- /dev/null +++ b/components/StepsSection.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { ScrollReveal } from "@/components/ScrollReveal"; +import { useInView } from "@/hooks/useInView"; +import { useMockupProgress } from "@/hooks/useMockupProgress"; + +function LayersIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function SlidersIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function CloudUploadIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function ImageIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function AudioIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +const STEPS = [ + { + number: 1, + title: "Single/Batch creation", + desc: "One image, many audios, each becomes a separate video.", + Icon: LayersIcon, + }, + { + number: 2, + title: "Per-video settings", + desc: "Title, tags, privacy, and category for every upload.", + Icon: SlidersIcon, + }, + { + number: 3, + title: "Direct upload", + desc: "Connect YouTube once and publish automatically.", + Icon: CloudUploadIcon, + }, +] as const; + +function CheckIcon({ className }: { className?: string }) { + return ( + + ); +} + +function AppMockup() { + const phaseOneMs = 3000; + const phaseTwoMs = 2000; + const initialWidth = "20%"; + const midWidth = "45%"; + const { ref, inView } = useInView(); + const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs); + + const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%"; + + return ( +
+
+ + + +
+ +
+
+
+
+ + IMAGE +
+
+ + AUDIO +
+
+ +

+ {processed ? ( + "Processed" + ) : ( + <> + Processing + . + . + . + + )} +

+
+
+
= 1 ? `width ${transitionMs}ms ease-out` : "none", + }} + > + {!processed && ( + + )} +
+
+ +
+
+
+
+ ); +} + +function StepCard({ + number, + title, + desc, + Icon, +}: { + number: number; + title: string; + desc: string; + Icon: typeof LayersIcon; +}) { + return ( +
+ + +
+
+ +
+
+

+ {title} +

+

+ {desc} +

+
+
+
+ ); +} + +export function StepsSection() { + return ( +
+
+ +
+

+ Upload your content in 3 simple steps: +

+

+ From a single track to a full batch, Songs2YT walks you through creating and + publishing videos to YouTube without touching a video editor. +

+ +
+
+ +
+ {STEPS.map((step, index) => ( + + + + ))} +
+
+
+ ); +} diff --git a/components/SupportSection.tsx b/components/SupportSection.tsx new file mode 100644 index 0000000..8ce8738 --- /dev/null +++ b/components/SupportSection.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useRef } from "react"; +import { ScrollReveal } from "@/components/ScrollReveal"; +import { SectionScrollTitle } from "@/components/SectionScrollTitle"; +import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans"; + +type SupportCardProps = { + title: string; + description: string; + href: string; + cta: string; + external?: boolean; + hover: string; + titleHover: string; + linkClass: string; + linkHover: string; +}; + +function SupportCard({ + title, + description, + href, + cta, + external, + hover, + titleHover, + linkClass, + linkHover, +}: SupportCardProps) { + return ( + +

+ {title} +

+

+ {description} +

+ + {cta} + +
+ ); +} + +const SUPPORT_CARDS: SupportCardProps[] = [ + { + title: "🛠️ Community & Self-Hosting", + description: + "Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.", + href: GITEA_ISSUES_URL, + cta: "Open Gitea Issues", + external: true, + hover: + "hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20", + titleHover: "group-hover:text-[#609926]", + linkClass: "text-[#609926]", + linkHover: "group-hover:text-[#7ab33a]", + }, + { + title: "📩 Billing & Premium Support", + description: + "Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.", + href: `mailto:${SUPPORT_EMAIL}`, + cta: SUPPORT_EMAIL, + hover: + "hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20", + titleHover: "group-hover:text-accent", + linkClass: "text-accent", + linkHover: "group-hover:text-accent-hover", + }, +]; + +export function SupportSection() { + const sectionRef = useRef(null); + + return ( +
+ + +
+ +

Support

+

+ Community help for self-hosters, or reach us directly for billing and premium support. +

+
+ +
+ {SUPPORT_CARDS.map((card, index) => ( + + + + ))} +
+
+
+ ); +} diff --git a/components/UpgradeProLink.tsx b/components/UpgradeProLink.tsx new file mode 100644 index 0000000..cb82ad5 --- /dev/null +++ b/components/UpgradeProLink.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { UPGRADE_URL } from "@/lib/plans"; + +const PRO_UPGRADE_SUFFIX = " Upload up to 50 videos with Pro!"; + +type Props = { + className?: string; + children?: ReactNode; +}; + +export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) { + return ( + + {children ?? "Upgrade to Pro"} + + ); +} + +export function QuotaErrorMessage({ message }: { message: string }) { + if (message.endsWith(PRO_UPGRADE_SUFFIX)) { + return ( + <> + {message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "} + + Upload up to 50 videos with Pro! + + + ); + } + + return <>{message}; +} diff --git a/components/UploadForm.tsx b/components/UploadForm.tsx new file mode 100644 index 0000000..c28e25c --- /dev/null +++ b/components/UploadForm.tsx @@ -0,0 +1,526 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Plan, Privacy } from "@prisma/client"; +import { filenameWithoutExtension } from "@/lib/constants"; +import { audioTagsToMetadata } from "@/lib/audio-tags"; +import type { ItemMetadata } from "@/lib/types"; +import { CategorySelect } from "./CategorySelect"; +import { PlaylistSelect } from "./PlaylistSelect"; +import { PrivacyToggle } from "./PrivacyToggle"; +import { ResolutionSelect } from "./ResolutionSelect"; +import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink"; + +const UPLOAD_CONCURRENCY = 4; + +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, + playlistId: null, + }; +} + +export function UploadForm() { + const router = useRouter(); + const uploadSessionRef = useRef(crypto.randomUUID()); + 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 [playlistId, setPlaylistId] = useState(""); + const [quota, setQuota] = useState<{ + remaining: number; + limit: number; + plan: Plan; + maxBatchSize: number; + resetsIn: string; + videoCredits: number; + totalAvailable: number; + used: number; + selfHosted?: boolean; + } | 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, + plan: data.plan, + maxBatchSize: data.maxBatchSize, + resetsIn: data.resetsIn, + videoCredits: data.videoCredits ?? 0, + totalAvailable: data.totalAvailable ?? data.remaining, + used: data.used ?? 0, + selfHosted: Boolean(data.selfHosted), + }); + } + }, []); + + useEffect(() => { + loadQuota(); + }, [loadQuota]); + + async function uploadFile( + file: File, + type: "image" | "audio", + ): Promise<{ path: string; audioTags?: Parameters[0] | null }> { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + formData.append("session", uploadSessionRef.current); + 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 { path: data.path, audioTags: data.audioTags }; + } + + async function uploadFilesWithConcurrency(files: File[]) { + const pending = files.map((file) => { + const id = crypto.randomUUID(); + const autoTitle = filenameWithoutExtension(file.name); + + setAudioItems((prev) => [ + ...prev, + { + id, + file, + path: null, + uploading: true, + metadata: { + ...defaultMetadata(autoTitle), + playlistId: playlistId || null, + }, + }, + ]); + + return { id, file, autoTitle }; + }); + + for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) { + const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY); + const results = await Promise.allSettled( + chunk.map(async ({ id, file, autoTitle }) => { + const upload = await uploadFile(file, "audio"); + const tagMetadata = upload.audioTags + ? audioTagsToMetadata(upload.audioTags, autoTitle) + : null; + + setAudioItems((prev) => + prev.map((item) => + item.id === id + ? { + ...item, + path: upload.path, + uploading: false, + metadata: tagMetadata + ? { ...item.metadata, ...tagMetadata } + : item.metadata, + } + : item, + ), + ); + }), + ); + + for (let j = 0; j < results.length; j++) { + if (results[j].status === "rejected") { + const { id } = chunk[j]; + const reason = results[j] as PromiseRejectedResult; + setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed"); + setAudioItems((prev) => prev.filter((item) => item.id !== id)); + } + } + } + } + + 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.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); + + const maxBatch = quota?.maxBatchSize ?? 3; + if (audioItems.length + files.length > maxBatch) { + setError(`Your plan allows up to ${maxBatch} audio files per batch.`); + e.target.value = ""; + return; + } + + await uploadFilesWithConcurrency(files); + e.target.value = ""; + } + + function updateItemMetadata(id: string, updates: Partial) { + setAudioItems((prev) => + prev.map((item) => + item.id === id + ? { ...item, metadata: { ...item.metadata, ...updates } } + : item, + ), + ); + } + + function handlePlaylistChange(nextPlaylistId: string) { + setPlaylistId(nextPlaylistId); + setAudioItems((prev) => + prev.map((item) => ({ + ...item, + metadata: { + ...item.metadata, + playlistId: nextPlaylistId || null, + }, + })), + ); + } + + 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.selfHosted ? ( + <> + Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per + batch + + ) : ( + <> + {quota.remaining} of {quota.limit} plan videos remaining + {quota.plan === "FREE" && quota.videoCredits > 0 + ? ` · ${quota.videoCredits} pay-as-you-go credits` + : ""}{" "} + ·{" "} + {quota.plan === "FREE" + ? `resets in ${quota.resetsIn}` + : `monthly quota resets on ${quota.resetsIn}`}{" "} + · up to {quota.maxBatchSize} files per batch + + )} +
+ )} + + {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 })} + /> + +
+ + +