commit 7d7043b150ba4aee530cc2043c82ae93aee1cbe8 Author: Songs2VID OSS Date: Mon Aug 3 06:15:05 2026 +0200 Initial OSS scaffold from Songs2VID (pre-strip) 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..814754c --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +DATABASE_URL="postgresql://s2yt:s2yt@localhost:5433/s2yt" +REDIS_URL="redis://localhost:6380" +NEXTAUTH_URL="http://localhost:3000" +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET="replace-with-a-long-random-secret" +# Used to encrypt YouTube OAuth tokens at rest (falls back to NEXTAUTH_SECRET if unset) +# TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" +# Server-side Google OAuth 2.0 credentials from Google Cloud Console +GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-google-client-secret" +UPLOAD_DIR="./uploads" +# Open-source / self-hosted: unlimited video quota, API, and Pro features +# S2VID_EDITION="selfhosted" +# Admin API key for approving/rejecting quota extension requests (Bearer token) +# ADMIN_API_KEY="your-secret-admin-key" +# Admin UI: /admin (footer badges) — unlock with the same Bearer key +# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY +# Admin badges: GET/POST /api/admin/badges, PATCH/DELETE /api/admin/badges/:id +# Public badges: GET /api/badges +# Pro users generate API keys in Dashboard → Settings (stored hashed; shown once) +# Optional: override bundled ffmpeg-static binary (leave unset to use npm package) +# FFMPEG_PATH="C:/path/to/ffmpeg.exe" +# Public Gitea issues URL for community support links +NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues" +NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID" +NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid" +# Docusaurus docs — local: npm run docs:dev → http://localhost:3001 +# NEXT_PUBLIC_DOCS_URL="http://localhost:3001" +# Production: NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com" +# Stripe billing (Pro €5/mo + Free 1–15 credit top-ups @ €0.25) +# STRIPE_SECRET_KEY="sk_test_..." +# Prefer a restricted key (rk_...) with Checkout + Customers + Billing Portal + Webhooks only +# STRIPE_WEBHOOK_SECRET="whsec_..." +# Optional Dashboard Price ID for Pro (otherwise inline price_data €5/mo is used) +# STRIPE_PRO_PRICE_ID="price_..." +# Collect VAT/GST via Stripe Tax AFTER adding Tax registrations in Dashboard: +# STRIPE_AUTOMATIC_TAX="true" +# Force local mock upgrades/grants without Stripe: BILLING_DEV_MOCK=true +# Disable auto-mock when Stripe key missing: BILLING_DEV_MOCK=false +# Point Stripe webhook to: POST /api/stripe/webhook +# Events (required): +# checkout.session.completed +# invoice.payment_succeeded +# invoice.payment_failed +# customer.subscription.updated +# customer.subscription.deleted +# Dashboard: enable Customer portal (Settings → Billing → Customer portal) +# Optional: Managed Payments (MoR) enable in Dashboard + set managed_payments on Checkout if you use it +# Dev helpers: POST /api/dev/grant-credits { "action": "set-pro" | "free-top-up" | "grant-credits" } +# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY +# See also /admin for footer badge management 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..709b0ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +uploads/ +node_modules/ +.next/ +.env +.env.local +website/node_modules/ +website/build/ +website/.docusaurus/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed0c708 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 +# Hosted Songs2VID image (cloud plans + PAYG/Stripe). Do not bake selfhosted edition. + +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 install + +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 UPLOAD_DIR=/app/uploads +ENV FFMPEG_PATH=ffmpeg +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV HOME=/home/nextjs +ENV npm_config_cache=/tmp/npm-cache + +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 --create-home --home-dir /home/nextjs nextjs \ + && mkdir -p /app/uploads \ + && chown nextjs:nodejs /app /app/uploads + +USER nextjs + +COPY --chown=nextjs:nodejs package.json package-lock.json ./ +COPY --chown=nextjs:nodejs prisma ./prisma +RUN npm install --omit=dev \ + && npx prisma generate \ + && rm -rf /tmp/npm-cache + +COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone/server.js ./server.js +COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone/.next ./.next +COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static +COPY --chown=nextjs:nodejs --from=builder /app/public ./public +COPY --chown=nextjs:nodejs --from=builder /app/assets ./assets +COPY --chown=nextjs:nodejs --from=builder /app/worker ./worker +COPY --chown=nextjs:nodejs --from=builder /app/lib ./lib +COPY --chown=nextjs:nodejs --from=builder /app/tsconfig.json ./tsconfig.json + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a6eac4 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Songs2VID + +Create YouTube videos from an image and audio files. + +## Stack + +- Next.js 15 (App Router, TypeScript, Tailwind) +- PostgreSQL + Prisma +- Redis + BullMQ +- NextAuth (Google OAuth 2.0 with YouTube scopes) +- FFmpeg for video encoding +- YouTube Data API v3 + +## Editions + +| Edition | How | Limits | +|---------|-----|--------| +| **Hosted** (default) | Leave `S2VID_EDITION` unset | Free / Pro quotas and API gates | +| **Self-hosted / OSS** | `S2VID_EDITION=selfhosted` | No video quota, no API rate caps; playlists + API unlocked | + +Docker Compose sets `S2VID_EDITION=selfhosted` automatically. + +## Quick start (Docker) + +1. Copy env and set Google OAuth + secrets: + +```bash +cp .env.example .env +``` + +2. Build and run the full stack (web, worker, Postgres, Redis): + +```bash +docker compose up -d --build +``` + +Open [http://localhost:3000](http://localhost:3000). + +Add Google OAuth redirect URI: `http://localhost:3000/api/auth/callback/google` (or your public URL). + +## Local development + +1. Copy environment variables: + +```bash +cp .env.example .env +``` + +2. Start Postgres and Redis only: + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +3. Install dependencies and push the schema: + +```bash +npm install +npm run db:push +``` + +4. Configure Google OAuth in [Google Cloud Console](https://console.cloud.google.com/): + - Enable YouTube Data API v3 + - Create OAuth 2.0 credentials + - Add redirect URI: `http://localhost:3000/api/auth/callback/google` + - Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env` + +5. Optional for unlimited local use: `S2VID_EDITION=selfhosted` in `.env` + +6. Start the app, worker, and docs: + +```bash +npm run dev:all +``` + +- App: http://localhost:3000 +- Docs: http://localhost:3001 + +Or run them separately with `npm run dev`, `npm run worker`, and `npm run docs:dev`. + +## Authentication + +Users sign in with Google via OAuth 2.0. The app handles YouTube channel connection automatically end users never enter API keys for YouTube. + +## Self-hosted edition + +Set `S2VID_EDITION=selfhosted` (Docker Compose does this by default): + +- Unlimited video allowance +- API access and playlists +- Art-track layouts with blur backgrounds and fine-tuning (padding, gaps, text offsets) +- Custom watermarks (text/logo), curated or uploaded fonts + +Composition details: see `website/docs/video-editing.md` (or open Video editing in the docs site after `npm run docs:dev`). + +## Scripts + +- `npm run dev` - Next.js dev server +- `npm run worker` - Background job processor +- `npm run dev:all` - App (3000) + worker + docs (3001) together +- `npm run db:push` - Push Prisma schema to database +- `npm run build` - Production build +- `npm run docs:dev` - Documentation site only (Docusaurus on port 3001) + +## Documentation + +Docs live in `website/` (Docusaurus). API reference: + +```bash +npm run docs:dev +``` + +Open [http://localhost:3001/docs/api/overview](http://localhost:3001/docs/api/overview). + +The app “Full API docs” link and `/dashboard/api-docs` redirect use local docs when `NEXTAUTH_URL` is localhost (or set `NEXT_PUBLIC_DOCS_URL`). +## Source + +- Gitea: https://git.atakanozban.com/Songs2VID +- Docker Hub: https://hub.docker.com/r/atakanozban/songs2vid diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..cbea81a --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,26 @@ +import { AdminBadgePanel } from "@/components/AdminBadgePanel"; + +export const metadata = { + title: "Admin · Footer badges · Songs2VID", + robots: { index: false, follow: false }, +}; + +export default function AdminBadgesPage() { + return ( +
+
+
+

+ Admin +

+

Footer badges

+

+ Manage the infinite badge marquee on the public site footer. Only active badges are + shown. +

+
+ +
+
+ ); +} 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/billing-portal/route.ts b/app/api/account/billing-portal/route.ts new file mode 100644 index 0000000..a42d02c --- /dev/null +++ b/app/api/account/billing-portal/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getSessionUser } from "@/lib/session"; +import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; + +/** + * Stripe Customer Portal update payment method, view invoices, cancel (per Dashboard config). + * @see https://docs.stripe.com/customer-management/integrate-customer-portal + */ +export async function POST(req: NextRequest) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (isBillingDevMock()) { + return NextResponse.json( + { error: "Billing portal is unavailable in mock mode." }, + { status: 503 }, + ); + } + + if (!isStripeConfigured()) { + return NextResponse.json( + { error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." }, + { status: 503 }, + ); + } + + const dbUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { stripeCustomerId: true }, + }); + + if (!dbUser.stripeCustomerId) { + return NextResponse.json( + { error: "No Stripe customer on this account. Complete a checkout first." }, + { status: 400 }, + ); + } + + const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin; + const stripe = getStripe(); + + try { + const session = await stripe.billingPortal.sessions.create({ + customer: dbUser.stripeCustomerId, + return_url: `${origin}/dashboard/settings`, + }); + return NextResponse.json({ url: session.url }); + } catch (err) { + const message = err instanceof Error ? err.message : "Could not open billing portal"; + console.error("[stripe] billing portal failed", err); + return NextResponse.json( + { + error: + message.includes("No configuration") || message.includes("portal") + ? "Billing portal is not configured in Stripe Dashboard yet. Enable it under Settings → Billing → Customer portal." + : message, + }, + { status: 502 }, + ); + } +} diff --git a/app/api/account/cancel-subscription/route.ts b/app/api/account/cancel-subscription/route.ts new file mode 100644 index 0000000..ab93490 --- /dev/null +++ b/app/api/account/cancel-subscription/route.ts @@ -0,0 +1,217 @@ +import { NextRequest, NextResponse } from "next/server"; +import { downgradeToFreePlan } from "@/lib/billing"; +import { prisma } from "@/lib/db"; +import { getSessionUser } from "@/lib/session"; +import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; + +type CancelWhen = "immediate" | "period_end"; +type CancelAction = "cancel" | "resume"; + +function periodEndIso(sub: { + cancel_at?: number | null; + current_period_end?: number; + items?: { data?: Array<{ current_period_end?: number }> }; +}): string | null { + const fromItem = sub.items?.data?.[0]?.current_period_end; + const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null; + return ts ? new Date(ts * 1000).toISOString() : null; +} + +/** + * Cancel Pro subscription via Stripe. + * Body: { when: "immediate" | "period_end" } or { action: "resume" } + */ +export async function POST(req: NextRequest) { + 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 }); + } + + let action: CancelAction = "cancel"; + let when: CancelWhen = "immediate"; + try { + const body = await req.json(); + if (body?.action === "resume") action = "resume"; + if (body?.when === "period_end" || body?.when === "immediate") when = body.when; + } catch { + // empty body → default immediate cancel (legacy) + } + + const dbUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { stripeSubscriptionId: true }, + }); + + const subId = dbUser.stripeSubscriptionId; + const isMockSub = !subId || subId.startsWith("dev_sub_"); + const useStripe = + Boolean(subId) && + !isMockSub && + isStripeConfigured() && + !isBillingDevMock(); + + // Resume (undo cancel-at-period-end) + if (action === "resume") { + if (useStripe && subId) { + try { + const stripe = getStripe(); + const sub = await stripe.subscriptions.update(subId, { + cancel_at_period_end: false, + }); + return NextResponse.json({ + ok: true, + action: "resume", + cancelAtPeriodEnd: false, + currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }), + }); + } catch (err) { + console.error("[stripe] resume subscription failed", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to keep subscription" }, + { status: 502 }, + ); + } + } + return NextResponse.json({ + ok: true, + action: "resume", + cancelAtPeriodEnd: false, + mocked: true, + }); + } + + // Cancel + if (when === "period_end") { + if (useStripe && subId) { + try { + const stripe = getStripe(); + const sub = await stripe.subscriptions.update(subId, { + cancel_at_period_end: true, + }); + const endsAt = periodEndIso( + sub as { + cancel_at?: number | null; + current_period_end?: number; + items?: { data?: Array<{ current_period_end?: number }> }; + }, + ); + return NextResponse.json({ + ok: true, + when: "period_end", + cancelAtPeriodEnd: true, + endsAt, + // Keep Pro until Stripe sends customer.subscription.deleted + }); + } catch (err) { + console.error("[stripe] cancel at period end failed", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to schedule cancellation" }, + { status: 502 }, + ); + } + } + + // Mock / no Stripe id: schedule locally by leaving Pro and reporting next month + const endsAt = new Date(); + endsAt.setMonth(endsAt.getMonth() + 1); + return NextResponse.json({ + ok: true, + when: "period_end", + cancelAtPeriodEnd: true, + endsAt: endsAt.toISOString(), + mocked: true, + }); + } + + // immediate + if (useStripe && subId) { + try { + const stripe = getStripe(); + await stripe.subscriptions.cancel(subId); + } catch (err) { + console.error("[stripe] cancel subscription failed", err); + return NextResponse.json( + { + error: + err instanceof Error + ? err.message + : "Stripe could not cancel the subscription. Your plan was not changed.", + }, + { status: 502 }, + ); + } + } + + await downgradeToFreePlan(user.id); + return NextResponse.json({ + ok: true, + when: "immediate", + cancelAtPeriodEnd: false, + plan: "FREE", + }); +} + +/** Current Stripe subscription cancel status for settings UI. */ +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.plan !== "PREMIUM") { + return NextResponse.json({ + plan: user.plan, + cancelAtPeriodEnd: false, + endsAt: null, + }); + } + + const dbUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { stripeSubscriptionId: true }, + }); + + const subId = dbUser.stripeSubscriptionId; + if ( + !subId || + subId.startsWith("dev_sub_") || + !isStripeConfigured() || + isBillingDevMock() + ) { + return NextResponse.json({ + plan: "PREMIUM", + cancelAtPeriodEnd: false, + endsAt: null, + mocked: true, + }); + } + + try { + const stripe = getStripe(); + const sub = await stripe.subscriptions.retrieve(subId); + return NextResponse.json({ + plan: "PREMIUM", + status: sub.status, + cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end), + endsAt: periodEndIso( + sub as { + cancel_at?: number | null; + current_period_end?: number; + items?: { data?: Array<{ current_period_end?: number }> }; + }, + ), + }); + } catch (err) { + console.error("[stripe] retrieve subscription failed", err); + return NextResponse.json({ + plan: "PREMIUM", + cancelAtPeriodEnd: false, + endsAt: null, + error: "Could not load subscription status from Stripe", + }); + } +} diff --git a/app/api/account/credits/route.ts b/app/api/account/credits/route.ts new file mode 100644 index 0000000..2b560f5 --- /dev/null +++ b/app/api/account/credits/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CREDIT_PRICE_CENTS, + FREE_EXTRA_CREDITS_MAX, + FREE_TOP_UP_MAX, + FREE_TOP_UP_MIN, + formatCreditPrice, + validateFreeTopUpAmount, +} from "@/lib/credits"; +import { prisma } from "@/lib/db"; +import { getSessionUser } from "@/lib/session"; +import { + checkoutTaxAndReferenceOptions, + ensureStripeCustomer, + priceDataTaxFields, + productDataWithTaxCode, +} from "@/lib/stripe-checkout"; +import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; +import { grantExtraCredits } from "@/lib/billing"; + +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dbUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + extraCredits: true, + plan: true, + monthlyCredits: true, + videosUsed: true, + }, + }); + + const purchases = await prisma.creditPurchase.findMany({ + where: { userId: user.id, status: "COMPLETED" }, + orderBy: { completedAt: "desc" }, + take: 10, + }); + + const cap = FREE_EXTRA_CREDITS_MAX; + const room = Math.max(0, cap - dbUser.extraCredits); + + return NextResponse.json({ + extraCredits: dbUser.extraCredits, + videoCredits: dbUser.extraCredits, + topUpMin: FREE_TOP_UP_MIN, + topUpMax: FREE_TOP_UP_MAX, + priceCentsPerCredit: CREDIT_PRICE_CENTS, + priceLabelPerCredit: formatCreditPrice(1), + creditBalanceMax: cap, + monthlyCredits: dbUser.monthlyCredits, + creditsUsed: dbUser.videosUsed, + plan: dbUser.plan, + stripeConfigured: isStripeConfigured() || isBillingDevMock(), + room, + atExtraCap: room === 0, + purchases: purchases.map((p) => ({ + id: p.id, + credits: p.credits, + amountCents: p.amountCents, + completedAt: p.completedAt?.toISOString() ?? null, + })), + }); +} + +/** Free tier: top up 1–15 extra credits (price = credits × €0.25). */ +export async function POST(req: NextRequest) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.plan === "PREMIUM") { + return NextResponse.json( + { + error: + "Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.", + }, + { status: 403 }, + ); + } + + let credits = 0; + try { + const body = await req.json(); + credits = Math.floor(Number(body.credits)); + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const dbUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true }, + }); + + const monthlyRemaining = Math.max( + 0, + dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed, + ); + const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining); + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { credits: amount, amountCents } = validation; + + if (isBillingDevMock()) { + const purchase = await prisma.creditPurchase.create({ + data: { + userId: user.id, + credits: amount, + amountCents, + status: "COMPLETED", + completedAt: new Date(), + stripeSessionId: `dev_topup_${Date.now()}`, + }, + }); + await grantExtraCredits(user.id, amount); + return NextResponse.json({ + mocked: true, + granted: amount, + amountCents, + purchaseId: purchase.id, + }); + } + + if (!isStripeConfigured()) { + return NextResponse.json( + { error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." }, + { status: 503 }, + ); + } + + const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin; + const purchase = await prisma.creditPurchase.create({ + data: { + userId: user.id, + credits: amount, + amountCents, + status: "PENDING", + }, + }); + + try { + const stripe = getStripe(); + const customerId = await ensureStripeCustomer(user.id, dbUser.email); + const session = await stripe.checkout.sessions.create({ + mode: "payment", + customer: customerId, + line_items: [ + { + quantity: amount, + price_data: { + currency: "eur", + unit_amount: CREDIT_PRICE_CENTS, + ...priceDataTaxFields(), + product_data: productDataWithTaxCode( + "Songs2VID video credit", + `1 credit = 1 video upload (${formatCreditPrice(1)} each)`, + ), + }, + }, + ], + metadata: { + type: "free_top_up", + userId: user.id, + purchaseId: purchase.id, + credits: String(amount), + }, + ...checkoutTaxAndReferenceOptions(user.id), + success_url: `${origin}/dashboard/settings?credits=success`, + cancel_url: `${origin}/dashboard/settings?credits=cancelled`, + }); + + await prisma.creditPurchase.update({ + where: { id: purchase.id }, + data: { stripeSessionId: session.id }, + }); + + return NextResponse.json({ url: session.url, sessionId: session.id }); + } catch (err) { + await prisma.creditPurchase.update({ + where: { id: purchase.id }, + data: { status: "FAILED" }, + }); + const message = err instanceof Error ? err.message : "Checkout failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} 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/account/subscribe/route.ts b/app/api/account/subscribe/route.ts new file mode 100644 index 0000000..30a697d --- /dev/null +++ b/app/api/account/subscribe/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { activateProPlan } from "@/lib/billing"; +import { PRO_PRICE_CENTS } from "@/lib/credits"; +import { prisma } from "@/lib/db"; +import { getSessionUser } from "@/lib/session"; +import { + checkoutTaxAndReferenceOptions, + ensureStripeCustomer, + priceDataTaxFields, + productDataWithTaxCode, +} from "@/lib/stripe-checkout"; +import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; + +/** Start Pro subscription Checkout (€5/mo) or mock-upgrade in development. */ +export async function POST(req: NextRequest) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.plan === "PREMIUM") { + return NextResponse.json({ error: "Already on Pro." }, { status: 400 }); + } + + const dbUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { email: true, stripeCustomerId: true }, + }); + + if (isBillingDevMock()) { + await activateProPlan(user.id, { + stripeCustomerId: dbUser.stripeCustomerId, + stripeSubscriptionId: `dev_sub_${Date.now()}`, + cardLast4: "4242", + }); + return NextResponse.json({ mocked: true, plan: "PREMIUM" }); + } + + if (!isStripeConfigured()) { + return NextResponse.json( + { error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." }, + { status: 503 }, + ); + } + + const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin; + const stripe = getStripe(); + const customerId = await ensureStripeCustomer(user.id, dbUser.email); + const priceId = process.env.STRIPE_PRO_PRICE_ID; + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + customer: customerId, + line_items: priceId + ? [{ price: priceId, quantity: 1 }] + : [ + { + quantity: 1, + price_data: { + currency: "eur", + unit_amount: PRO_PRICE_CENTS, + recurring: { interval: "month" }, + ...priceDataTaxFields(), + product_data: productDataWithTaxCode( + "Songs2VID Pro", + "50 video credits / month · 1080p · API access · Includes Custom Branding, Watermark Positioning, and Bulk Image Matching", + ), + }, + }, + ], + metadata: { + type: "pro_subscription", + userId: user.id, + }, + subscription_data: { + metadata: { + type: "pro_subscription", + userId: user.id, + }, + }, + ...checkoutTaxAndReferenceOptions(user.id), + success_url: `${origin}/dashboard/settings?upgrade=success`, + cancel_url: `${origin}/dashboard/settings?upgrade=cancelled`, + }); + + return NextResponse.json({ url: session.url, sessionId: session.id }); +} diff --git a/app/api/admin/badges/[id]/route.ts b/app/api/admin/badges/[id]/route.ts new file mode 100644 index 0000000..e2d0f2d --- /dev/null +++ b/app/api/admin/badges/[id]/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { prisma } from "@/lib/db"; +import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges"; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function PATCH(req: NextRequest, ctx: Ctx) { + const denied = requireAdmin(req); + if (denied) return denied; + + const { id } = await ctx.params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const existing = await prisma.footerBadge.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Badge not found" }, { status: 404 }); + } + + const input = (body ?? {}) as Record; + const patch: { + name?: string; + embedHtml?: string | null; + imageUrl?: string | null; + linkUrl?: string | null; + isActive?: boolean; + sortOrder?: number; + } = {}; + + if ("name" in input) patch.name = typeof input.name === "string" ? input.name : existing.name; + if ("embedHtml" in input) { + patch.embedHtml = typeof input.embedHtml === "string" ? input.embedHtml : null; + } + if ("imageUrl" in input) { + patch.imageUrl = typeof input.imageUrl === "string" ? input.imageUrl : null; + } + if ("linkUrl" in input) { + patch.linkUrl = typeof input.linkUrl === "string" ? input.linkUrl : null; + } + if ("isActive" in input && typeof input.isActive === "boolean") { + patch.isActive = input.isActive; + } + if ("sortOrder" in input && typeof input.sortOrder === "number") { + patch.sortOrder = input.sortOrder; + } + + const fields = normalizeBadgeFields({ + name: patch.name ?? existing.name, + embedHtml: "embedHtml" in patch ? patch.embedHtml : existing.embedHtml, + imageUrl: "imageUrl" in patch ? patch.imageUrl : existing.imageUrl, + linkUrl: "linkUrl" in patch ? patch.linkUrl : existing.linkUrl, + isActive: patch.isActive ?? existing.isActive, + sortOrder: patch.sortOrder ?? existing.sortOrder, + }); + + // Toggle-only updates should not re-validate empty content + const contentChanging = + "name" in input || "embedHtml" in input || "imageUrl" in input || "linkUrl" in input; + if (contentChanging) { + const error = validateBadgeFields(fields); + if (error) return NextResponse.json({ error }, { status: 400 }); + } + + const badge = await prisma.footerBadge.update({ + where: { id }, + data: contentChanging + ? fields + : { + ...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}), + ...(patch.sortOrder !== undefined ? { sortOrder: fields.sortOrder } : {}), + }, + }); + + return NextResponse.json({ badge }); +} + +export async function DELETE(req: NextRequest, ctx: Ctx) { + const denied = requireAdmin(req); + if (denied) return denied; + + const { id } = await ctx.params; + const existing = await prisma.footerBadge.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Badge not found" }, { status: 404 }); + } + + await prisma.footerBadge.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/badges/route.ts b/app/api/admin/badges/route.ts new file mode 100644 index 0000000..8a7421d --- /dev/null +++ b/app/api/admin/badges/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { prisma } from "@/lib/db"; +import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges"; + +export async function GET(req: NextRequest) { + const denied = requireAdmin(req); + if (denied) return denied; + + const badges = await prisma.footerBadge.findMany({ + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + }); + + return NextResponse.json({ badges }); +} + +export async function POST(req: NextRequest) { + const denied = requireAdmin(req); + if (denied) return denied; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const input = (body ?? {}) as Record; + const fields = normalizeBadgeFields({ + name: typeof input.name === "string" ? input.name : "", + embedHtml: typeof input.embedHtml === "string" ? input.embedHtml : null, + imageUrl: typeof input.imageUrl === "string" ? input.imageUrl : null, + linkUrl: typeof input.linkUrl === "string" ? input.linkUrl : null, + isActive: typeof input.isActive === "boolean" ? input.isActive : true, + sortOrder: typeof input.sortOrder === "number" ? input.sortOrder : undefined, + }); + + const error = validateBadgeFields(fields); + if (error) return NextResponse.json({ error }, { status: 400 }); + + if (fields.sortOrder === 0) { + const max = await prisma.footerBadge.aggregate({ _max: { sortOrder: true } }); + fields.sortOrder = (max._max.sortOrder ?? -1) + 1; + } + + const badge = await prisma.footerBadge.create({ data: fields }); + return NextResponse.json({ badge }, { status: 201 }); +} 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/admin/reset-credits/route.ts b/app/api/admin/reset-credits/route.ts new file mode 100644 index 0000000..a4f53c2 --- /dev/null +++ b/app/api/admin/reset-credits/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { adminResetUserCredits } from "@/lib/billing"; +import { prisma } from "@/lib/db"; + +/** + * Admin utility: reset / adjust a user's credits. + * Authorization: Bearer ${ADMIN_API_KEY} + * + * Body: { userId | email, plan?, monthlyCredits?, creditsUsed?, extraCredits?, clearFreeTopUp? } + */ +export async function POST(req: NextRequest) { + const adminKey = process.env.ADMIN_API_KEY; + if (!adminKey) { + return NextResponse.json({ error: "Admin API not configured" }, { status: 503 }); + } + + const auth = req.headers.get("authorization"); + if (auth !== `Bearer ${adminKey}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { + userId?: string; + email?: string; + plan?: "FREE" | "PREMIUM"; + monthlyCredits?: number; + creditsUsed?: number; + extraCredits?: number; + clearFreeTopUp?: boolean; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + let userId = body.userId; + if (!userId && body.email) { + const found = await prisma.user.findUnique({ where: { email: body.email } }); + if (!found) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + userId = found.id; + } + if (!userId) { + return NextResponse.json({ error: "Provide userId or email" }, { status: 400 }); + } + + const updated = await adminResetUserCredits(userId, { + plan: body.plan, + monthlyCredits: body.monthlyCredits, + creditsUsed: body.creditsUsed, + extraCredits: body.extraCredits, + clearFreeTopUp: body.clearFreeTopUp, + }); + + return NextResponse.json({ + ok: true, + user: { + id: updated.id, + email: updated.email, + plan: updated.plan, + monthlyCredits: updated.monthlyCredits, + creditsUsed: updated.videosUsed, + extraCredits: updated.extraCredits, + freeTopUpPurchased: updated.freeTopUpPurchased, + }, + }); +} 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/badges/route.ts b/app/api/badges/route.ts new file mode 100644 index 0000000..7f11607 --- /dev/null +++ b/app/api/badges/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +/** Public: active footer badges for the landing marquee. */ +export async function GET() { + const badges = await prisma.footerBadge.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + id: true, + name: true, + embedHtml: true, + imageUrl: true, + linkUrl: true, + sortOrder: true, + }, + }); + + return NextResponse.json({ badges }, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + }, + }); +} diff --git a/app/api/branding/watermark/route.ts b/app/api/branding/watermark/route.ts new file mode 100644 index 0000000..f81741b --- /dev/null +++ b/app/api/branding/watermark/route.ts @@ -0,0 +1,17 @@ +import fs from "fs/promises"; +import { NextResponse } from "next/server"; +import { getWatermarkPath } from "@/lib/storage"; + +export async function GET() { + try { + const buf = await fs.readFile(getWatermarkPath()); + return new NextResponse(buf, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=86400, immutable", + }, + }); + } catch { + return NextResponse.json({ error: "Watermark asset not found" }, { status: 404 }); + } +} diff --git a/app/api/dev/grant-credits/route.ts b/app/api/dev/grant-credits/route.ts new file mode 100644 index 0000000..2e9d825 --- /dev/null +++ b/app/api/dev/grant-credits/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + activateProPlan, + adminResetUserCredits, + grantExtraCredits, + markFreeTopUpPurchased, +} from "@/lib/billing"; +import { FREE_TOP_UP_CREDITS } from "@/lib/credits"; +import { isBillingDevMock } from "@/lib/stripe"; +import { getSessionUser } from "@/lib/session"; + +/** + * Local-only billing helpers (no Stripe CLI required). + * Enabled when NODE_ENV=development (unless BILLING_DEV_MOCK=false) or BILLING_DEV_MOCK=true. + */ +function assertDev() { + if (!isBillingDevMock()) { + return NextResponse.json({ error: "Not available outside development mock mode" }, { status: 404 }); + } + return null; +} + +export async function POST(req: NextRequest) { + const blocked = assertDev(); + if (blocked) return blocked; + + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { + action?: string; + credits?: number; + plan?: "FREE" | "PREMIUM"; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const action = body.action ?? "grant-credits"; + + switch (action) { + case "grant-credits": { + const credits = Math.floor(Number(body.credits ?? FREE_TOP_UP_CREDITS)); + if (!Number.isFinite(credits) || credits <= 0) { + return NextResponse.json({ error: "Invalid credits" }, { status: 400 }); + } + await grantExtraCredits(user.id, credits); + return NextResponse.json({ ok: true, granted: credits }); + } + case "free-top-up": { + await markFreeTopUpPurchased(user.id); + return NextResponse.json({ ok: true, granted: FREE_TOP_UP_CREDITS }); + } + case "set-pro": { + await activateProPlan(user.id, { + stripeSubscriptionId: `dev_sub_${Date.now()}`, + cardLast4: "4242", + }); + return NextResponse.json({ ok: true, plan: "PREMIUM" }); + } + case "set-free": { + await adminResetUserCredits(user.id, { plan: "FREE", creditsUsed: 0 }); + return NextResponse.json({ ok: true, plan: "FREE" }); + } + case "reset-cycle": { + await adminResetUserCredits(user.id, { + plan: body.plan, + creditsUsed: 0, + }); + return NextResponse.json({ ok: true, reset: true }); + } + default: + return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 }); + } +} + +/** Convenience GET for quick browser testing: /api/dev/grant-credits?action=set-pro */ +export async function GET(req: NextRequest) { + const blocked = assertDev(); + if (blocked) return blocked; + + const action = req.nextUrl.searchParams.get("action") ?? "grant-credits"; + const credits = Number(req.nextUrl.searchParams.get("credits") ?? FREE_TOP_UP_CREDITS); + + const fake = new NextRequest(req.url, { + method: "POST", + headers: { "content-type": "application/json", cookie: req.headers.get("cookie") ?? "" }, + body: JSON.stringify({ action, credits }), + }); + return POST(fake); +} diff --git a/app/api/fonts/[key]/route.ts b/app/api/fonts/[key]/route.ts new file mode 100644 index 0000000..3ad57fc --- /dev/null +++ b/app/api/fonts/[key]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs/promises"; +import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts"; +import { resolveCuratedFontPath } from "@/lib/fonts-server"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ key: string }> }, +) { + const { key } = await params; + if (!isCuratedFontKey(key)) { + return NextResponse.json({ error: "Unknown font" }, { status: 404 }); + } + + const fontPath = resolveCuratedFontPath(key); + try { + const buf = await fs.readFile(fontPath); + const meta = CURATED_FONTS.find((f) => f.key === key)!; + return new NextResponse(buf, { + headers: { + "Content-Type": "font/ttf", + "Content-Disposition": `inline; filename="${meta.file}"`, + "Cache-Control": "public, max-age=86400, immutable", + }, + }); + } catch { + return NextResponse.json({ error: "Font file not found" }, { status: 404 }); + } +} 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..55bca49 --- /dev/null +++ b/app/api/jobs/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +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) { + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } + 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/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..d0351f1 --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -0,0 +1,255 @@ +import { NextRequest, NextResponse } from "next/server"; +import type Stripe from "stripe"; +import { + activateProPlan, + applyMonthlyCreditRenewal, + downgradeToFreePlan, + grantExtraCredits, +} from "@/lib/billing"; +import { monthlyCreditsForPlan } from "@/lib/credits"; +import { prisma } from "@/lib/db"; +import { getNextMonthlyQuotaReset } from "@/lib/plans"; +import { getStripe } from "@/lib/stripe"; + +export const runtime = "nodejs"; + +/** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */ +function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null { + const legacy = (invoice as Stripe.Invoice & { + subscription?: string | Stripe.Subscription | null; + }).subscription; + if (typeof legacy === "string" && legacy) return legacy; + if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) { + return String(legacy.id); + } + + const parent = ( + invoice as Stripe.Invoice & { + parent?: { + type?: string | null; + subscription_details?: { subscription?: string | Stripe.Subscription | null } | null; + } | null; + } + ).parent; + + const fromParent = parent?.subscription_details?.subscription; + if (typeof fromParent === "string" && fromParent) return fromParent; + if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) { + return String(fromParent.id); + } + return null; +} + +async function fulfillFreeTopUp(session: Stripe.Checkout.Session) { + const purchaseId = session.metadata?.purchaseId; + const userId = session.metadata?.userId; + if (!purchaseId || !userId) { + console.error("[stripe] free_top_up missing metadata", session.id); + return; + } + + const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } }); + if (!purchase || purchase.userId !== userId) { + throw new Error(`Purchase not found: ${purchaseId}`); + } + if (purchase.status === "COMPLETED") return; + + await grantExtraCredits(userId, purchase.credits); + + await prisma.creditPurchase.update({ + where: { id: purchaseId }, + data: { + status: "COMPLETED", + completedAt: new Date(), + stripeSessionId: session.id, + stripePaymentIntentId: + typeof session.payment_intent === "string" + ? session.payment_intent + : session.payment_intent?.id ?? null, + }, + }); +} + +async function fulfillProCheckout(session: Stripe.Checkout.Session) { + const userId = session.metadata?.userId; + if (!userId) { + console.error("[stripe] pro_subscription missing userId", session.id); + return; + } + + const subscriptionId = + typeof session.subscription === "string" + ? session.subscription + : session.subscription?.id ?? null; + + const customerId = + typeof session.customer === "string" ? session.customer : session.customer?.id ?? null; + + await activateProPlan(userId, { + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + }); +} + +async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) { + const subscriptionId = subscriptionIdFromInvoice(invoice); + if (!subscriptionId) { + console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id); + return; + } + + // Skip the first invoice if checkout already activated Pro (billing_reason subscription_create) + const user = await prisma.user.findFirst({ + where: { stripeSubscriptionId: subscriptionId }, + }); + if (!user) { + // Fallback: metadata on subscription via Stripe retrieve is optional; try customer + const customerId = + typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; + if (!customerId) return; + const byCustomer = await prisma.user.findFirst({ + where: { stripeCustomerId: customerId }, + }); + if (!byCustomer) return; + await applyMonthlyCreditRenewal(byCustomer.id, { + plan: "PREMIUM", + newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), + quotaResetAt: getNextMonthlyQuotaReset(), + }); + await prisma.user.update({ + where: { id: byCustomer.id }, + data: { + stripeSubscriptionId: subscriptionId, + subscribedAt: byCustomer.subscribedAt ?? new Date(), + }, + }); + return; + } + + // Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30) + await applyMonthlyCreditRenewal(user.id, { + plan: "PREMIUM", + newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), + quotaResetAt: getNextMonthlyQuotaReset(), + }); +} + +async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + const user = await prisma.user.findFirst({ + where: { stripeSubscriptionId: subscription.id }, + }); + if (!user) return; + await downgradeToFreePlan(user.id); +} + +/** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */ +async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]); + if (!terminal.has(subscription.status)) return; + + const user = await prisma.user.findFirst({ + where: { stripeSubscriptionId: subscription.id }, + }); + if (!user || user.plan !== "PREMIUM") return; + await downgradeToFreePlan(user.id); +} + +async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { + const subscriptionId = subscriptionIdFromInvoice(invoice); + if (!subscriptionId) return; + + const user = await prisma.user.findFirst({ + where: { + OR: [ + { stripeSubscriptionId: subscriptionId }, + ...(typeof invoice.customer === "string" + ? [{ stripeCustomerId: invoice.customer }] + : invoice.customer?.id + ? [{ stripeCustomerId: invoice.customer.id }] + : []), + ], + }, + }); + if (!user) return; + + // Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure. + console.warn( + "[stripe] invoice.payment_failed", + invoice.id, + "user", + user.id, + "attempt", + invoice.attempt_count, + ); +} + +export async function POST(req: NextRequest) { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + return NextResponse.json({ error: "Webhook not configured" }, { status: 503 }); + } + + const signature = req.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }); + } + + const rawBody = await req.text(); + let event: Stripe.Event; + + try { + const stripe = getStripe(); + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); + } catch (err) { + const message = err instanceof Error ? err.message : "Invalid signature"; + return NextResponse.json({ error: message }, { status: 400 }); + } + + try { + if (event.type === "checkout.session.completed") { + const session = event.data.object as Stripe.Checkout.Session; + if (session.payment_status === "paid" || session.status === "complete") { + const type = session.metadata?.type; + if (type === "pro_subscription" || session.mode === "subscription") { + await fulfillProCheckout(session); + } else if (type === "free_top_up") { + await fulfillFreeTopUp(session); + } else if (session.metadata?.purchaseId) { + // Legacy PAYG purchases: grant metadata credits as extras + const userId = session.metadata.userId; + const credits = Number(session.metadata.credits); + const purchaseId = session.metadata.purchaseId; + if (userId && purchaseId && Number.isFinite(credits) && credits > 0) { + const purchase = await prisma.creditPurchase.findUnique({ + where: { id: purchaseId }, + }); + if (purchase && purchase.status !== "COMPLETED") { + await grantExtraCredits(userId, credits); + await prisma.creditPurchase.update({ + where: { id: purchaseId }, + data: { + status: "COMPLETED", + completedAt: new Date(), + stripeSessionId: session.id, + }, + }); + } + } + } + } + } else if (event.type === "invoice.payment_succeeded") { + await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice); + } else if (event.type === "invoice.payment_failed") { + await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); + } else if (event.type === "customer.subscription.updated") { + await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); + } else if (event.type === "customer.subscription.deleted") { + await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + } + } catch (err) { + console.error("[stripe] webhook handler error", event.type, event.id, err); + return NextResponse.json({ error: "Handler failed" }, { status: 500 }); + } + + return NextResponse.json({ received: true }); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..1abdf42 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { saveUploadedFile, type UploadFileType } 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" && type !== "logo" && type !== "font") + ) { + return NextResponse.json( + { error: "Missing file or type (image | audio | logo | font)" }, + { status: 400 }, + ); + } + + try { + const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, { + sessionKey, + }); + return NextResponse.json(result); + } catch (err) { + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } + 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..e0eb38f --- /dev/null +++ b/app/api/v1/jobs/batch/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Privacy } from "@prisma/client"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +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, + songTitle: null, + description: "", + tags: "", + privacy: "PUBLIC", + categoryId: "10", + resolution: "1920x1080", + notifySubscribers: true, + madeForKids: false, + embeddable: true, + creativeCommons: false, + includeWatermark: false, + playlistId: null, + artist: 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); + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } + const message = err instanceof Error ? err.message : "Batch upload failed"; + const status = + message.includes("Batch limit exceeded") + ? 400 + : message.includes("Quota exceeded") || + message.includes("Not enough credits") || + message.includes("Payment Required") + ? 402 + : 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..1baf3c3 --- /dev/null +++ b/app/api/v1/jobs/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +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) { + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } + const message = err instanceof Error ? err.message : "Failed to create job"; + const status = + message.includes("Quota exceeded") || + message.includes("Not enough credits") || + message.includes("Payment Required") + ? 402 + : 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..74255cc --- /dev/null +++ b/app/api/v1/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { API_DOCS_URL } from "@/lib/plans"; + +export async function GET() { + return NextResponse.json({ + name: "Songs2VID 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 image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)", + body: "multipart/form-data: file, type (image|audio|logo|font)", + }, + { + method: "POST", + path: "/api/v1/jobs", + description: + "Create a video job from uploaded file paths. Pro: layout templates, blur, watermark, per-item covers", + body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }", + }, + { + 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: API_DOCS_URL, + }); +} diff --git a/app/api/v1/upload/route.ts b/app/api/v1/upload/route.ts new file mode 100644 index 0000000..2809834 --- /dev/null +++ b/app/api/v1/upload/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requirePaidApiUser } from "@/lib/api-auth"; +import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { saveUploadedFile, type UploadFileType } 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" && type !== "logo" && type !== "font") + ) { + return NextResponse.json( + { error: "Provide multipart fields: file, type (image|audio|logo|font)" }, + { status: 400 }, + ); + } + + try { + const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan); + return NextResponse.json(result); + } catch (err) { + if (err instanceof PremiumRequiredError) { + return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); + } + 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/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..566e551 --- /dev/null +++ b/app/dashboard/settings/page.tsx @@ -0,0 +1,242 @@ +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 { CreditPurchasePanel } from "@/components/CreditPurchasePanel"; +import { PlanBillingActions } from "@/components/PlanBillingActions"; +import { DashboardShell } from "@/components/DashboardShell"; +import { UpgradeProButton } from "@/components/UpgradeProButton"; +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"; +import { isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; +import { CreditPurchaseStatus } from "@prisma/client"; + +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 recentCreditPurchases = await prisma.creditPurchase.findMany({ + where: { userId: user.id, status: CreditPurchaseStatus.COMPLETED }, + orderBy: { completedAt: "desc" }, + take: 5, + }); + 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 + + . +

+
+
+ +
+

Plan & billing

+ +
+
+
+

+ Video quota remaining +

+

+ {quota.totalAvailable} + + {" "} + of {quota.limit + quota.extraCredits} videos + +

+
+

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

+
+
+
+
+

+ {`Includes monthly + purchased extras · monthly resets on ${quota.resetsIn}`} +

+
+ +
+ + + + {user.plan === "PREMIUM" && user.subscribedAt && ( + + )} + {user.plan === "PREMIUM" && ( + + )} + {extensionUsage && ( + + )} + + +
+ + {user.plan === "FREE" ? ( + <> +

+ for 50 videos/month, 1080p, API access, and lossless audio. +

+ {!selfHosted && ( + ({ + id: p.id, + credits: p.credits, + amountCents: p.amountCents, + completedAt: p.completedAt?.toISOString() ?? null, + }))} + /> + )} + + ) : ( + <> + {extensionUsage && } +

+ Pro uses your monthly allocation first, then any never-expiring extra credits. +

+ + )} +
+ + {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..dcce869 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,319 @@ +@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; + } + + .badge-marquee { + @apply relative w-full overflow-hidden; + mask-image: linear-gradient( + to right, + transparent, + black 8%, + black 92%, + transparent + ); + } + + .badge-marquee-track { + @apply flex w-max items-center gap-10; + animation: badge-marquee-scroll var(--badge-marquee-duration, 28s) linear infinite; + } + + .badge-marquee:hover .badge-marquee-track { + animation-play-state: paused; + } + + @media (prefers-reduced-motion: reduce) { + .badge-marquee-track { + animation: none; + flex-wrap: wrap; + justify-content: center; + width: 100%; + } + + .badge-marquee-item[aria-hidden="true"] { + display: none; + } + } + + .badge-marquee-item { + @apply flex shrink-0 items-center justify-center; + } + + .badge-marquee-slot { + @apply flex h-14 max-h-14 items-center justify-center; + } + + .badge-marquee-slot a, + .badge-marquee-link { + @apply m-0 inline-flex items-center p-0 leading-none no-underline; + } + + .badge-marquee-slot img, + .badge-marquee-slot iframe, + .badge-marquee-slot svg, + .badge-marquee-slot object { + display: block !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + max-height: 3.5rem !important; + width: auto !important; + height: auto !important; + max-width: min(280px, 70vw) !important; + object-fit: contain; + vertical-align: middle; + } +} + +@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); + } +} + +@keyframes badge-marquee-scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..e77737f 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..0ddae53 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,93 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { Providers } from "./providers"; +import { JsonLd } from "@/components/JsonLd"; +import { Matomo } from "@/components/Matomo"; + +const inter = Inter({ subsets: ["latin"] }); + +const SITE_URL = "https://songs2vid.com"; +const SITE_NAME = "Songs2VID"; +const DEFAULT_TITLE = + "Songs2VID - Fast Audio to Video Converter for YouTube | TunesToTube Alternative"; +const DEFAULT_DESCRIPTION = + "Convert MP3, WAV, and audio files into YouTube videos instantly. The fastest, privacy-focused online tool to upload music, beats, and podcasts directly to YouTube."; +const OG_TITLE = "Songs2VID - Fast Audio to Video Converter for YouTube"; +const OG_DESCRIPTION = + "Convert MP3 & audio files into YouTube videos instantly without heavy editing."; +const TWITTER_DESCRIPTION = "Convert MP3 & audio files into YouTube videos instantly."; + +export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), + title: { + default: DEFAULT_TITLE, + template: `%s | ${SITE_NAME}`, + }, + description: DEFAULT_DESCRIPTION, + keywords: [ + "mp3 to youtube", + "audio to video converter", + "upload audio to youtube", + "tunestotube alternative", + "song to video", + "podcast to youtube", + "convert mp3 to mp4", + ], + authors: [{ name: SITE_NAME, url: SITE_URL }], + creator: SITE_NAME, + publisher: SITE_NAME, + applicationName: SITE_NAME, + alternates: { + canonical: SITE_URL, + }, + openGraph: { + title: OG_TITLE, + description: OG_DESCRIPTION, + url: SITE_URL, + siteName: SITE_NAME, + type: "website", + locale: "en_US", + images: [ + { + url: "/logo.png", + width: 512, + height: 512, + alt: SITE_NAME, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: OG_TITLE, + description: TWITTER_DESCRIPTION, + images: ["/logo.png"], + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + }, + }, + icons: { + icon: "/favicon.png", + }, +}; + +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..712a8e1 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,94 @@ +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..4ce67dd --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,315 @@ +import type { Metadata } from "next"; +import { LegalPageLayout } from "@/components/LegalPageLayout"; +import { LEGAL_OPERATOR } from "@/lib/legal/constants"; + +export const metadata: Metadata = { + title: "Privacy Policy", + description: "How Songs2VID collects, uses, and protects your personal data.", + alternates: { canonical: "https://songs2vid.com/privacy" }, +}; + +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, create or list + playlists, and manage related YouTube actions you request +
  • +
+ +

3.2 Uploaded content and job data

+

When you use the service, we temporarily process:

+
    +
  • Image and audio files you upload (including optional per-track cover images)
  • +
  • Optional custom watermark assets (text, PNG logo, or font files)
  • +
  • Generated video files prior to or during YouTube upload
  • +
  • + Per-video metadata you provide (title, artist, description, tags, privacy, category, + resolution, layout, watermark settings, Made for Kids / embedding / license flags, etc.) +
  • +
  • + Embedded audio tag metadata (for example ID3 title/artist/album) when we read it from + uploaded MP3 files to help prefill fields +
  • +
  • Playlist titles and IDs when you create or attach YouTube playlists through the Service
  • +
+ +

3.3 Usage, API, and technical data

+
    +
  • + Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset + dates, and job processing status +
  • +
  • + Quota reset / extension and API rate-limit extension request history (Pro) when you submit + a request +
  • +
  • + API key material for Pro users: we store a cryptographic hash and a short non-secret + prefix; the full key is shown once at creation and is not stored in plaintext +
  • +
  • IP address, browser type, device information, and request logs
  • +
  • Error reports and operational diagnostics
  • +
  • + Website and product analytics events collected via Matomo (self-hosted at + analytics.atakanozban.com) to understand traffic and improve the Service — see section + 10 +
  • +
+ +

3.4 Payment data

+

+ If you purchase a Pro subscription or Free-plan extra credits, payment processing is handled + by Stripe. We do not store full payment card details on our servers. We may receive and store + billing status, Stripe customer and subscription identifiers, Checkout session or payment + references, purchase amounts, and credit pack size for fulfilled top-ups. +

+ +

4. Why we process your data

+
    +
  • + Contract performance: to provide video encoding, metadata handling, + playlist actions, API access, 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 Songs2VID, including:

+
    +
  • + Google / YouTube: authentication and video uploads via Google OAuth and + the YouTube Data API. Your use of Google and YouTube is also subject to{" "} + + Google's Privacy Policy + + ,{" "} + + YouTube Terms of Service + + , and related Google API terms +
  • +
  • + Hosting and infrastructure providers: servers, databases, queues, and + storage +
  • +
  • + Stripe: Pro subscriptions, Free-plan credit top-ups, and related billing + webhooks ( + + Stripe Privacy Policy + + ) +
  • +
  • + Matomo (self-hosted analytics): privacy-friendly analytics we operate at + analytics.atakanozban.com to measure visits and improve Songs2VID. Analytics data stays on + infrastructure we control; we do not sell it to advertising networks. +
  • +
+

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

+ +

6. Google / YouTube user data (Limited Use)

+

+ Songs2VID's use and transfer to any other app of information received from Google APIs + will adhere to the{" "} + + Google API Services User Data Policy + + , including the Limited Use requirements. +

+

+ We request Google OAuth access (including the YouTube Data API scope needed to upload videos + and manage playlists on your connected channel) solely to provide prominent, user-facing + features of Songs2VID: signing you in, connecting your channel, encoding your media, uploading + videos you create, and creating or listing playlists you request. We do not use Google user + data for advertising, credit scoring, or unrelated profiling. +

+

+ We do not sell, share, transfer, or disclose Google user data obtained via Google OAuth / + YouTube APIs to third parties, except as needed to operate the Service infrastructure under + our control or when required by law. Google / YouTube themselves process data when we call + their APIs on your behalf to perform actions you initiate. +

+

+ You can revoke Songs2VID's access to your Google account at any time in{" "} + + Google Account → Security → Third-party access + + . After revocation (or when tokens can no longer be refreshed), we will stop using those + credentials and delete or invalidate stored YouTube OAuth tokens and related connection data + associated with that consent, subject to short-term backup or security logs and any legal + retention duties. +

+ +

7. Data retention and account deletion

+
    +
  • + Uploaded source files and generated outputs are retained only as long as needed to complete + your jobs (typically removed after successful processing/upload or when no longer required) +
  • +
  • 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
  • +
  • + API key hashes are removed when you revoke the key, downgrade from Pro (where applicable), + or delete your account +
  • +
+

+ You may delete your account from Dashboard → Settings (account deletion + control) or by emailing{" "} + {LEGAL_OPERATOR.email}. Deletion removes + account and connection data from active systems subject to legal retention obligations + (for example certain billing records). Cancelling a subscription does not by itself delete + your account. +

+ +

8. Self-hosted deployments

+

+ If you deploy Songs2VID 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. +

+ +

9. 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 (in Hungary, the Nemzeti Adatvédelmi és + Információszabadság Hatóság — NAIH) +
  • +
+

+ To exercise these rights, contact{" "} + {LEGAL_OPERATOR.email}, or use in-product + account deletion where available. You may also revoke Google access as described in section + 6. +

+ +

10. Cookies, analytics, and local storage

+

+ We use essential cookies and similar technologies for authentication, session management, + and security. +

+

+ We also use Matomo, a self-hosted analytics tool on{" "} + analytics.atakanozban.com, on our website (including the landing page and + dashboard) and documentation site. Matomo helps us understand how people use Songs2VID so we + can improve functionality, reliability, and content. Typical data includes pages viewed, + approximate location derived from IP, device/browser type, referring site, and interaction + events. We configure Matomo for our domains under *.songs2vid.com. +

+

+ We collect this analytics data to improve the Service, not to sell personal profiles to + advertisers. The legal basis is our legitimate interest in operating and improving + Songs2VID (and consent where required by local law). You can block analytics with browser + settings, extensions, or Do Not Track / equivalent controls where supported. For rights + requests related to analytics data, contact{" "} + {LEGAL_OPERATOR.email}. +

+ +

11. Security

+

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

+ +

12. 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. Google, Stripe, + and infrastructure providers may process data in other countries as described in their + policies. +

+ +

13. Children

+

+ Songs2VID 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. +

+ +

14. 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. If we change how we use Google user data, we will + update this policy and, where required, notify you or obtain renewed consent. +

+ +

15. Contact

+

+ Questions about this Privacy Policy or our privacy practices:{" "} + {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..8b15c4a --- /dev/null +++ b/app/refund/page.tsx @@ -0,0 +1,119 @@ +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", + description: "Refund and cancellation policy for Songs2VID paid plans.", + alternates: { canonical: "https://songs2vid.com/refund" }, +}; + +export default function RefundPage() { + return ( + +

1. Overview

+

+ This Refund Policy explains how refunds and cancellations work for paid Songs2VID plans and + credit purchases. The free monthly Bedroom Producer allocation does not involve payment and + is not subject to refunds; optional Free-plan extra credit purchases are covered in section + 2. +

+ +

2. Free plan and extra credits

+

+ The Bedroom Producer (Free) monthly allocation is provided at no charge. Free users may + optionally purchase extra video credits (currently €0.25 each, 1–15 per checkout, Free extras + balance capped at 15). Once extra credits are granted to your account, those one-time + purchases are generally non-refundable, except where mandatory consumer law + requires otherwise. Credits trimmed under the account accumulation cap (see our{" "} + Terms of Service) are not refundable. +

+ +

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 (choose{" "} + cancel immediately or at the end of the billing period) or + via the Stripe customer portal / by contacting{" "} + {LEGAL_OPERATOR.email}. Cancelling at period + end keeps Pro access until the paid period finishes and stops future renewals. Cancelling + immediately ends Pro access and moves you to the Free plan right away (including loss of Pro + API access). Cancellation does not automatically delete your account; use{" "} + Dashboard → Settings or contact us if you want permanent account deletion. +

+ +

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
  • +
  • Purchased Free-plan extra credits have already been granted to your account
  • +
  • Credits expired or were trimmed under the 30-credit accumulation cap
  • +
  • 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/robots.ts b/app/robots.ts new file mode 100644 index 0000000..fd6a184 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/admin", "/api/admin"], + }, + sitemap: "https://songs2vid.com/sitemap.xml", + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..796b3f3 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from "next"; + +const SITE_URL = "https://songs2vid.com"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: SITE_URL, + lastModified: new Date(), + changeFrequency: "daily", + priority: 1.0, + }, + { + url: `${SITE_URL}/privacy`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.3, + }, + ]; +} diff --git a/app/terms/page.tsx b/app/terms/page.tsx new file mode 100644 index 0000000..281b4f0 --- /dev/null +++ b/app/terms/page.tsx @@ -0,0 +1,262 @@ +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", + description: "Terms and conditions for using the Songs2VID hosted service.", + alternates: { canonical: "https://songs2vid.com/terms" }, +}; + +export default function TermsPage() { + return ( + +

1. Agreement

+

+ These Terms of Service ("Terms") govern your access to and use of the Songs2VID + 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

+

+ Songs2VID 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): 10 video credits per calendar month, 720p, MP3, + optional Songs2VID watermark (bottom-right), one static cover image per batch; may purchase + limited extra credits as described in Clause 8.2 +
  • +
  • + Independent Artist (Pro): 50 video credits per month, higher resolution and + batch limits, API access, custom watermark/logo/typography, blurred art-track layouts with + position controls, unique image per + track in bulk uploads, and other features as described on our pricing page (€5/month + unless otherwise stated at checkout) +
  • +
  • + Enterprise (Record Label / Studio): 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 Data API to sign you in, connect + your channel, upload videos you create, and create or list playlists you request. Your use + of those services is also subject to{" "} + + Google Terms of Service + + ,{" "} + + YouTube Terms of Service + + ,{" "} + + YouTube API Services Terms + + , and{" "} + + Google's Privacy Policy + + . We are not responsible for changes, outages, quota limits, or enforcement actions taken by + Google or YouTube. How we handle Google user data is described in our{" "} + Privacy Policy. +

+ +

7. Open-source software

+

+ Portions of Songs2VID 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. Subscription, Billing & Credits

+

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

+

+ Credit usage order: when you create video jobs, we deduct from your current + monthly allocation first, then from any purchased extra credits. +

+ +

8.1 Pro plan quota and rate-limit extensions

+

+ Independent Artist (Pro) subscribers receive 50 video credits per + subscription month. Unused credits from prior cycles may roll over into the next cycle + subject to the accumulation cap in Clause 8.3. Renewal and rollover occur on successful + Stripe subscription renewal (typically aligned with your billing period). +

+

+ Pro subscribers may request a manual video-quota reset or temporary extension, and/or an API + rate-limit extension, via account settings or by emailing{" "} + + {LEGAL_OPERATOR.quotaRequestEmail} + + . Each Pro account is entitled to up to{" "} + five (5) video-quota reset or extension requests per calendar year and up + to five (5) API rate-limit extension requests 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 requests do not increase those annual limits. +

+ +

8.2 Free plan extra credits

+

+ Bedroom Producer (Free) accounts receive 10 video credits per calendar + month. Free users may purchase additional never-expiring extra credits at the price shown at + checkout (currently €0.25 per credit), in packs of{" "} + 1 to 15 credits per purchase. On the Free plan, the extras balance may not + exceed 15 credits. After monthly and extra credits are exhausted, further + uploads require upgrading to Pro (or waiting for the next monthly reset). Extra credits are + available only on the Free plan purchase flow; Pro includes its monthly allocation and does + not sell the same Free top-up packs. +

+ +

8.3 Credit rollover & accumulation cap

+

+ Unused credits from previous cycles may roll over, but the total accumulated balance on any + account (remaining monthly allocation + extra credits) shall strictly not exceed{" "} + 30 credits at any given time. Upon renewal (or when applying a new monthly + allocation), any excess above this 30-credit threshold is automatically trimmed and expires + permanently, without entitlement to refund or compensation. +

+ +

8.4 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. If your subscription ends or you are + moved to the Free plan, API keys and Pro-only API access are revoked or disabled. +

+ +

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 and account deletion

+

+ You may stop using the Service at any time. You may cancel a paid subscription (see our{" "} + Refund Policy) without deleting your account. To permanently + delete your account and associated active data, use the account deletion control in{" "} + Dashboard → Settings or contact{" "} + {LEGAL_OPERATOR.email}. We may suspend or + terminate your access if you breach these Terms, create risk or legal exposure, or where + required by law. Upon termination or deletion, your right to use the hosted Service ends. + Provisions that by nature should survive will survive. Deletion is subject to legal + retention obligations described in our Privacy Policy. +

+ +

14. Governing law

+

+ These Terms are governed by the laws of Hungary, excluding conflict + of law rules. Courts in Hungary 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/13110e2d20334a36ba39abf8253ec759.png b/assets/13110e2d20334a36ba39abf8253ec759.png new file mode 100644 index 0000000..8caa488 Binary files /dev/null and b/assets/13110e2d20334a36ba39abf8253ec759.png differ diff --git a/assets/956264178b044ac8a9bb1ac8a144bf5f.png b/assets/956264178b044ac8a9bb1ac8a144bf5f.png new file mode 100644 index 0000000..a59301b Binary files /dev/null and b/assets/956264178b044ac8a9bb1ac8a144bf5f.png differ 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..23eb367 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..a686fee 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..3b37b07 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,7 @@ +# Watermark image + +Place `watermark.png` here for the default bottom-right video overlay. + +Curated Pro typography fonts live in `fonts/` (run `node scripts/fetch-watermark-fonts.mjs`). +The image should include the full attribution: "Uploaded through Songs2VID.com". +If missing, FFmpeg falls back to bottom-right drawtext with the same message. diff --git a/assets/a1497741fe7846319fdd24005421c76f.png b/assets/a1497741fe7846319fdd24005421c76f.png new file mode 100644 index 0000000..2f7d2c9 Binary files /dev/null and b/assets/a1497741fe7846319fdd24005421c76f.png differ diff --git a/assets/bg-video - Repaired.mp4 b/assets/bg-video - Repaired.mp4 new file mode 100644 index 0000000..4d39ad2 Binary files /dev/null and b/assets/bg-video - Repaired.mp4 differ diff --git a/assets/bg-video.mp4 b/assets/bg-video.mp4 new file mode 100644 index 0000000..4d39ad2 Binary files /dev/null and b/assets/bg-video.mp4 differ diff --git a/assets/d11572060d6340b7af547476a4a66159.png b/assets/d11572060d6340b7af547476a4a66159.png new file mode 100644 index 0000000..af0822e Binary files /dev/null and b/assets/d11572060d6340b7af547476a4a66159.png 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/f59f2b3c290042ceb8d12fe9395b0dda.png b/assets/f59f2b3c290042ceb8d12fe9395b0dda.png new file mode 100644 index 0000000..aa9436c Binary files /dev/null and b/assets/f59f2b3c290042ceb8d12fe9395b0dda.png differ diff --git a/assets/favicon-s2vid.png b/assets/favicon-s2vid.png new file mode 100644 index 0000000..e77737f Binary files /dev/null and b/assets/favicon-s2vid.png differ diff --git a/assets/favicon-s2yt.png b/assets/favicon-s2yt.png new file mode 100644 index 0000000..e77737f Binary files /dev/null and b/assets/favicon-s2yt.png differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Montserrat-Regular.ttf b/assets/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..c97aca1 Binary files /dev/null and b/assets/fonts/Montserrat-Regular.ttf differ diff --git a/assets/fonts/Oswald-Regular.ttf b/assets/fonts/Oswald-Regular.ttf new file mode 100644 index 0000000..d1a3b9c Binary files /dev/null and b/assets/fonts/Oswald-Regular.ttf differ diff --git a/assets/fonts/PlayfairDisplay-Regular.ttf b/assets/fonts/PlayfairDisplay-Regular.ttf new file mode 100644 index 0000000..7a09eb7 Binary files /dev/null and b/assets/fonts/PlayfairDisplay-Regular.ttf differ diff --git a/assets/fonts/README.md b/assets/fonts/README.md new file mode 100644 index 0000000..5aa544a --- /dev/null +++ b/assets/fonts/README.md @@ -0,0 +1,4 @@ +# Watermark fonts + +Curated TTF assets for FFmpeg `drawtext` (OFL via Google Fonts). +Refresh with `node scripts/fetch-watermark-fonts.mjs`. diff --git a/assets/fonts/Roboto-Regular.ttf b/assets/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..5522a36 Binary files /dev/null and b/assets/fonts/Roboto-Regular.ttf differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..09e67cb 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..3fc2584 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..d618f6b --- /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 Songs2VID 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/AdminBadgePanel.tsx b/components/AdminBadgePanel.tsx new file mode 100644 index 0000000..4cfbbda --- /dev/null +++ b/components/AdminBadgePanel.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +const STORAGE_KEY = "s2vid_admin_api_key"; + +export type AdminBadge = { + id: string; + name: string; + embedHtml: string | null; + imageUrl: string | null; + linkUrl: string | null; + isActive: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +}; + +function authHeaders(key: string): HeadersInit { + return { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }; +} + +export function AdminBadgePanel() { + const [apiKey, setApiKey] = useState(""); + const [unlocked, setUnlocked] = useState(false); + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [name, setName] = useState(""); + const [embedHtml, setEmbedHtml] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + setApiKey(stored); + setUnlocked(true); + } + }, []); + + const loadBadges = useCallback(async (key: string) => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/admin/badges", { + headers: authHeaders(key), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load badges"); + setBadges(data.badges); + setUnlocked(true); + sessionStorage.setItem(STORAGE_KEY, key); + } catch (err) { + setUnlocked(false); + setBadges([]); + setError(err instanceof Error ? err.message : "Failed to load badges"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (unlocked && apiKey) { + void loadBadges(apiKey); + } + }, [unlocked, apiKey, loadBadges]); + + async function unlock(e: FormEvent) { + e.preventDefault(); + const key = apiKey.trim(); + if (!key) { + setError("Enter ADMIN_API_KEY"); + return; + } + await loadBadges(key); + } + + function lock() { + sessionStorage.removeItem(STORAGE_KEY); + setApiKey(""); + setUnlocked(false); + setBadges([]); + setSuccess(null); + setError(null); + } + + async function createBadge(e: FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + setSuccess(null); + try { + const res = await fetch("/api/admin/badges", { + method: "POST", + headers: authHeaders(apiKey), + body: JSON.stringify({ + name: name.trim(), + embedHtml: embedHtml.trim() || null, + imageUrl: imageUrl.trim() || null, + linkUrl: linkUrl.trim() || null, + isActive: true, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to create badge"); + setName(""); + setEmbedHtml(""); + setImageUrl(""); + setLinkUrl(""); + setSuccess(`Added “${data.badge.name}”`); + await loadBadges(apiKey); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create badge"); + } finally { + setSaving(false); + } + } + + async function toggleActive(badge: AdminBadge) { + setError(null); + setSuccess(null); + try { + const res = await fetch(`/api/admin/badges/${badge.id}`, { + method: "PATCH", + headers: authHeaders(apiKey), + body: JSON.stringify({ isActive: !badge.isActive }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to update badge"); + setSuccess( + data.badge.isActive + ? `“${data.badge.name}” is now active` + : `“${data.badge.name}” is now inactive`, + ); + await loadBadges(apiKey); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update badge"); + } + } + + async function removeBadge(badge: AdminBadge) { + if (!confirm(`Delete badge “${badge.name}”?`)) return; + setError(null); + setSuccess(null); + try { + const res = await fetch(`/api/admin/badges/${badge.id}`, { + method: "DELETE", + headers: authHeaders(apiKey), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to delete badge"); + setSuccess(`Deleted “${badge.name}”`); + await loadBadges(apiKey); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete badge"); + } + } + + if (!unlocked) { + return ( +
+

Admin unlock

+

+ Enter ADMIN_API_KEY to manage footer badges. + The key stays in this browser tab only (sessionStorage). +

+
+ setApiKey(e.target.value)} + placeholder="ADMIN_API_KEY" + className="input-field flex-1" + autoComplete="off" + /> + +
+ {error ?

{error}

: null} +
+ ); + } + + return ( +
+
+

+ Authenticated with admin API key. Changes apply to the live footer marquee. +

+ +
+ + {error ?

{error}

: null} + {success ?

{success}

: null} + +
+

Add badge

+

+ Paste a Product Hunt / LaunchBuff embed (HTML), or set image + link URLs. +

+
+
+ + setName(e.target.value)} + className="input-field" + placeholder="Product Hunt" + required + /> +
+
+ +