Initial open-source self-hosted Songs2YT edition
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
website
|
||||
uploads
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
deploy-*.tgz
|
||||
scripts
|
||||
bg-video
|
||||
@@ -0,0 +1,11 @@
|
||||
DATABASE_URL="postgresql://s2yt:s2yt@localhost:5432/s2yt"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
||||
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
UPLOAD_DIR="./uploads"
|
||||
S2YT_EDITION="selfhosted"
|
||||
NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2YT/songs2yt/issues"
|
||||
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2YT/songs2yt"
|
||||
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2yt"
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
uploads/
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
website/node_modules/
|
||||
website/build/
|
||||
website/.docusaurus/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
basibozuk_cover.jpg
|
||||
bg-video/
|
||||
scripts/
|
||||
_restore/
|
||||
@@ -0,0 +1,50 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npx prisma generate && npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV S2YT_EDITION=selfhosted
|
||||
ENV UPLOAD_DIR=/app/uploads
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/package-lock.json ./package-lock.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/worker ./worker
|
||||
COPY --from=builder /app/lib ./lib
|
||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN mkdir -p /app/uploads \
|
||||
&& chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Songs2YT
|
||||
|
||||
Open-source, self-hosted Songs2YT. Set `S2YT_EDITION=selfhosted` for unlimited quotas (default).
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
npm install
|
||||
npm run db:push
|
||||
npm run dev
|
||||
npm run worker
|
||||
```
|
||||
|
||||
https://git.atakanozban.com/Songs2YT/songs2yt
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getInitialQuotaResetAt } from "@/lib/quota";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.plan !== "PREMIUM") {
|
||||
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
plan: "FREE",
|
||||
cardLast4: null,
|
||||
subscribedAt: null,
|
||||
apiKeyHash: null,
|
||||
apiKeyPrefix: null,
|
||||
apiRateLimitBonus: 0,
|
||||
quotaResetAt: getInitialQuotaResetAt(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { audioFilename: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
privacy: true,
|
||||
categoryId: true,
|
||||
resolution: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload;
|
||||
|
||||
try {
|
||||
const job = await createVideoJob(user, body);
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
return NextResponse.json(quota);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
|
||||
|
||||
if (!file || !type || (type !== "image" && type !== "audio")) {
|
||||
return NextResponse.json({ error: "Missing file or type" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { mapWithConcurrency } from "@/lib/fs-utils";
|
||||
import { getPlanLimits } from "@/lib/plans";
|
||||
import { checkQuota } from "@/lib/quota";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
type BatchItemInput = Partial<ItemMetadata> & {
|
||||
filename?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function defaultMetadata(title: string): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC",
|
||||
categoryId: "10",
|
||||
resolution: "1920x1080",
|
||||
notifySubscribers: true,
|
||||
madeForKids: false,
|
||||
embeddable: true,
|
||||
creativeCommons: false,
|
||||
includeWatermark: false,
|
||||
playlistId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const image = formData.get("image") as File | null;
|
||||
const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File);
|
||||
const metadataRaw = formData.get("metadata");
|
||||
|
||||
if (!image || audioFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: image (file), audio (one or more files)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
if (audioFiles.length > limits.maxBatchSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const quotaCheck = await checkQuota(user.id, audioFiles.length);
|
||||
if (!quotaCheck.ok) {
|
||||
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
let itemMeta: BatchItemInput[] = [];
|
||||
let defaults: Partial<ItemMetadata> = {};
|
||||
let createPlaylist: CreatePlaylistRequest | null = null;
|
||||
|
||||
if (metadataRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(metadataRaw)) as {
|
||||
items?: BatchItemInput[];
|
||||
defaults?: Partial<ItemMetadata>;
|
||||
createPlaylist?: unknown;
|
||||
};
|
||||
itemMeta = parsed.items ?? [];
|
||||
defaults = parsed.defaults ?? {};
|
||||
createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist);
|
||||
if (parsed.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = Date.now().toString();
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
|
||||
|
||||
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
||||
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
|
||||
);
|
||||
|
||||
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
||||
const audio = audioFiles[i];
|
||||
const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {};
|
||||
const base = defaultMetadata(
|
||||
metaInput.title?.trim() || filenameWithoutExtension(audio.name),
|
||||
);
|
||||
const metadata: ItemMetadata = {
|
||||
...base,
|
||||
...defaults,
|
||||
...metaInput,
|
||||
title: (metaInput.title ?? defaults.title ?? base.title).trim(),
|
||||
privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy,
|
||||
};
|
||||
|
||||
return {
|
||||
audioPath: upload.path,
|
||||
audioFilename: upload.filename,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: imageUpload.path,
|
||||
items,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Batch upload failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Batch upload failed";
|
||||
const status = message.includes("Quota exceeded") || message.includes("Batch limit exceeded")
|
||||
? 403
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||
|
||||
try {
|
||||
const createPlaylist = parseCreatePlaylistInput(body.createPlaylist);
|
||||
if (body.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(
|
||||
user,
|
||||
body.items,
|
||||
createPlaylist,
|
||||
);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: body.imagePath,
|
||||
items,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status = message.includes("Quota exceeded") ? 403 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
name: "Songs2YT API",
|
||||
version: "1.0",
|
||||
authentication: "Authorization: Bearer <api_key>",
|
||||
requirements: ["Pro subscription", "YouTube account connected"],
|
||||
rateLimit: "60 requests per minute per account (plus any approved bonus)",
|
||||
guidance: {
|
||||
recommended:
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||
batch:
|
||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/upload",
|
||||
description: "Upload a single image or audio file (use for two-step flow)",
|
||||
body: "multipart/form-data: file, type (image|audio)",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs",
|
||||
description: "Create a video job from uploaded file paths (recommended for large batches)",
|
||||
body: "application/json: { imagePath, items[] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs/batch",
|
||||
description:
|
||||
"One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs",
|
||||
body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/playlists",
|
||||
description: "List YouTube playlists for the authenticated Pro account",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/playlists",
|
||||
description: "Create a YouTube playlist",
|
||||
body: "application/json: { title, description?, privacy? (public|unlisted|private) }",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs",
|
||||
description: "List recent jobs",
|
||||
query: "limit (default 20, max 100)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs/:id",
|
||||
description: "Get job status and item details",
|
||||
},
|
||||
],
|
||||
docs: "/dashboard/api-docs",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
|
||||
if (!file || !type || (type !== "image" && type !== "audio")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: file, type (image|audio)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type, user.plan);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { UpgradeProLink } from "@/components/UpgradeProLink";
|
||||
|
||||
function CodeBlock({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg border border-gray-800 bg-black/40 p-4 text-xs leading-relaxed text-gray-300">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ApiDocsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) redirect("/");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const isPro = hasProFeatures(user.plan);
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={user.youtubeConnection?.channelTitle}>
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="text-sm text-gray-400 transition-colors hover:text-white"
|
||||
>
|
||||
← Back to settings
|
||||
</Link>
|
||||
<h1 className="mt-4 text-2xl font-bold text-white">REST API</h1>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
Programmatic uploads and batch jobs for Pro subscribers. Generate your API key in{" "}
|
||||
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
||||
Settings
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{!isPro && (
|
||||
<p className="mt-3 rounded border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
|
||||
API access requires Pro. <UpgradeProLink />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Authentication</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Send your API key on every request. Keys start with <code className="text-gray-300">s2yt_live_</code>.
|
||||
</p>
|
||||
<CodeBlock>{`Authorization: Bearer s2yt_live_your_key_here`}</CodeBlock>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Rate limits</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Default is 60 requests per minute per account. Approved rate-limit extensions increase
|
||||
that ceiling. Check live usage and request an extension in{" "}
|
||||
<Link href="/dashboard/settings" className="text-accent hover:underline">
|
||||
Settings → API access
|
||||
</Link>
|
||||
. Video quota limits from your Pro plan still apply to job creation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Choosing a flow</h2>
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 text-sm text-gray-400 space-y-3">
|
||||
<p>
|
||||
<span className="font-medium text-white">Recommended for most jobs (especially 5+ audio files):</span>{" "}
|
||||
two-step: upload each file with <code className="text-gray-300">POST /api/v1/upload</code>, then
|
||||
create the job with <code className="text-gray-300">POST /api/v1/jobs</code> (JSON).
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-white">One-shot batch</span>{" "}
|
||||
(<code className="text-gray-300">POST /api/v1/jobs/batch</code>) is for small packs only, typically
|
||||
1 cover + up to a few audio files. Large multipart bodies often fail with{" "}
|
||||
<code className="text-gray-300">failed to parse body as FormData</code>. Prefer two-step for albums
|
||||
or long tracklists.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Do not set <code className="text-gray-300">Content-Type</code> manually for multipart; the client must include the boundary.</li>
|
||||
<li>For Postman: Body → form-data, each audio row key must be exactly <code className="text-gray-300">audio</code> (type File).</li>
|
||||
<li>If a file field shows a warning triangle, re-select the file from disk.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Discovery</h2>
|
||||
<CodeBlock>{`GET /api/v1`}</CodeBlock>
|
||||
<p className="text-sm text-gray-400">Returns endpoint list and requirements (no auth).</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">1. Upload a file (two-step)</h2>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-F "file=@cover.jpg" \\
|
||||
-F "type=image"`}</CodeBlock>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/upload" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-F "file=@track1.mp3" \\
|
||||
-F "type=audio"`}</CodeBlock>
|
||||
<p className="text-sm text-gray-400">
|
||||
Response includes <code className="text-gray-300">path</code>,{" "}
|
||||
<code className="text-gray-300">filename</code>, and{" "}
|
||||
<code className="text-gray-300">size</code>. Upload the image once, then each audio file. Keep the{" "}
|
||||
<code className="text-gray-300">path</code> values for the next step.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">2. Create job from paths (recommended)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the exact <code className="text-gray-300">path</code> strings returned by upload. Add one{" "}
|
||||
<code className="text-gray-300">items[]</code> entry per track.
|
||||
</p>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/jobs" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"imagePath": "/uploads/.../cover.jpg",
|
||||
"items": [{
|
||||
"audioPath": "/uploads/.../track.mp3",
|
||||
"audioFilename": "track.mp3",
|
||||
"metadata": {
|
||||
"title": "My Track",
|
||||
"description": "",
|
||||
"tags": "electronic",
|
||||
"privacy": "PUBLIC",
|
||||
"categoryId": "10",
|
||||
"resolution": "1920x1080",
|
||||
"notifySubscribers": true,
|
||||
"madeForKids": false,
|
||||
"embeddable": true,
|
||||
"creativeCommons": false,
|
||||
"includeWatermark": false,
|
||||
"playlistId": null
|
||||
}
|
||||
}]
|
||||
}'`}</CodeBlock>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">YouTube playlists (Pro)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
List existing playlists, create a new one, or create one inline when starting a job.
|
||||
Pass <code className="text-gray-300">playlistId</code> in item metadata / batch{" "}
|
||||
<code className="text-gray-300">defaults</code>, or use{" "}
|
||||
<code className="text-gray-300">createPlaylist</code> to make a playlist and attach all
|
||||
videos to it. Privacy may be <code className="text-gray-300">public</code>,{" "}
|
||||
<code className="text-gray-300">unlisted</code>, or{" "}
|
||||
<code className="text-gray-300">private</code>. If playlist permission was just added,
|
||||
sign out and sign in again for <code className="text-gray-300">youtube.force-ssl</code>.
|
||||
</p>
|
||||
<CodeBlock>{`# List playlists
|
||||
curl "$BASE_URL/api/v1/playlists" \\
|
||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
||||
<CodeBlock>{`# Create a playlist
|
||||
curl -X POST "$BASE_URL/api/v1/playlists" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"title":"My Album","description":"From Songs2YT","privacy":"unlisted"}'`}</CodeBlock>
|
||||
<CodeBlock>{`# Use an existing playlist ID in metadata / defaults:
|
||||
{ "playlistId": "PLxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
|
||||
|
||||
# Or create one inline with a job / batch request:
|
||||
{
|
||||
"createPlaylist": {
|
||||
"title": "My Album",
|
||||
"description": "Uploaded via Songs2YT",
|
||||
"privacy": "private"
|
||||
},
|
||||
"defaults": { "privacy": "PUBLIC" },
|
||||
"items": [{ "title": "Track One" }]
|
||||
}`}</CodeBlock>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">One-shot batch (small packs only)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Upload one cover image and a few audio files in a single multipart request. Not recommended for
|
||||
large batches: use two-step instead if you see FormData parse errors.
|
||||
</p>
|
||||
<CodeBlock>{`curl -X POST "$BASE_URL/api/v1/jobs/batch" \\
|
||||
-H "Authorization: Bearer $API_KEY" \\
|
||||
-F "image=@cover.jpg" \\
|
||||
-F "audio=@track1.mp3" \\
|
||||
-F "audio=@track2.mp3" \\
|
||||
-F 'metadata={"createPlaylist":{"title":"My Album","privacy":"unlisted"},"defaults":{"privacy":"PUBLIC"},"items":[{"title":"Track One"},{"title":"Track Two"}]}'`}</CodeBlock>
|
||||
<p className="text-sm text-gray-400">
|
||||
Optional <code className="text-gray-300">metadata</code> JSON supports{" "}
|
||||
<code className="text-gray-300">defaults</code> applied to every item and per-item overrides in{" "}
|
||||
<code className="text-gray-300">items</code>. Item order should match the order of{" "}
|
||||
<code className="text-gray-300">audio</code> files.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Poll job status</h2>
|
||||
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs/JOB_ID" \\
|
||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
||||
<CodeBlock>{`curl "$BASE_URL/api/v1/jobs?limit=10" \\
|
||||
-H "Authorization: Bearer $API_KEY"`}</CodeBlock>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">History</h1>
|
||||
<JobHistory />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Create Videos</h1>
|
||||
<RecentYouTubeLimitAlert />
|
||||
<UploadForm />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
|
||||
import { PlanBillingActions } from "@/components/PlanBillingActions";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { UpgradeProLink } from "@/components/UpgradeProLink";
|
||||
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
||||
import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition";
|
||||
import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
|
||||
const PLAN_LABELS = {
|
||||
FREE: "Bedroom Producer",
|
||||
PREMIUM: "Pro",
|
||||
} as const;
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-gray-400">{label}</dt>
|
||||
<dd className="text-right text-white">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) redirect("/");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
const selfHosted = isSelfHostedEdition();
|
||||
const proFeatures = hasProFeatures(user.plan);
|
||||
const extensionUsage =
|
||||
!selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null;
|
||||
const apiRateExtensionUsage = proFeatures
|
||||
? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT)
|
||||
: null;
|
||||
const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null;
|
||||
const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null;
|
||||
const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan];
|
||||
const youtube = user.youtubeConnection;
|
||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={youtube?.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Name" value={user.name || "Not set"} />
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 border-t border-gray-800 pt-6">
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
YouTube
|
||||
</h3>
|
||||
{youtube ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
||||
<InfoRow label="Channel ID" value={youtube.channelId} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
||||
)}
|
||||
|
||||
{channelUrl && (
|
||||
<a
|
||||
href={channelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Go to your channel
|
||||
</a>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
To refresh your YouTube permissions,{" "}
|
||||
<Link href="/" className="text-accent hover:underline">
|
||||
sign out and sign in again
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">
|
||||
{selfHosted ? "Usage" : "Plan & billing"}
|
||||
</h2>
|
||||
|
||||
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Videos processed
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">
|
||||
{selfHosted ? (
|
||||
quota.used
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining}
|
||||
<span className="text-base font-normal text-gray-400">
|
||||
{" "}
|
||||
of {quota.limit} videos
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!selfHosted && (
|
||||
<p className="text-sm text-gray-400">
|
||||
{quota.used} used
|
||||
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!selfHosted && (
|
||||
<>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
quota.remaining <= 0
|
||||
? "bg-red-500"
|
||||
: quota.remaining / Math.max(1, quota.limit) <= 0.2
|
||||
? "bg-amber-500"
|
||||
: "bg-accent"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, Math.round((quota.used / Math.max(1, quota.limit)) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{user.plan === "PREMIUM"
|
||||
? `Monthly quota resets on ${quota.resetsIn}`
|
||||
: `Quota resets in ${quota.resetsIn}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{selfHosted && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Self-hosted edition: no video or API quotas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Current plan" value={planLabel} />
|
||||
{!selfHosted && user.plan === "PREMIUM" && user.subscribedAt && (
|
||||
<InfoRow
|
||||
label="Subscribed since"
|
||||
value={user.subscribedAt.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{!selfHosted && user.plan === "PREMIUM" && (
|
||||
<InfoRow
|
||||
label="Payment method"
|
||||
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
|
||||
/>
|
||||
)}
|
||||
{extensionUsage && (
|
||||
<InfoRow
|
||||
label="Extension requests (this year)"
|
||||
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
|
||||
/>
|
||||
)}
|
||||
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
|
||||
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
|
||||
</dl>
|
||||
|
||||
{!selfHosted && user.plan === "FREE" && (
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
<UpgradeProLink /> for more videos, 1080p, and lossless audio.
|
||||
</p>
|
||||
)}
|
||||
{!selfHosted && user.plan === "PREMIUM" && extensionUsage && (
|
||||
<PlanBillingActions initialUsage={extensionUsage} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
||||
<ApiKeySettings
|
||||
initialStatus={apiKeyStatus}
|
||||
initialRateLimit={apiRateLimit}
|
||||
initialExtensionUsage={apiRateExtensionUsage}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Request a copy of your data or permanently delete your account and associated uploads.
|
||||
</p>
|
||||
<AccountPrivacyActions email={user.email} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,248 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface-dark text-gray-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input-field {
|
||||
@apply w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-accent focus:outline-none;
|
||||
}
|
||||
|
||||
.mockup-upload-box {
|
||||
animation: mockup-border-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-upload-box-image {
|
||||
animation: mockup-border-pulse 3s ease-in-out infinite,
|
||||
mockup-float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-upload-box-audio {
|
||||
animation: mockup-border-pulse 3s ease-in-out infinite 0.5s,
|
||||
mockup-float 4s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.mockup-icon-image {
|
||||
animation: mockup-icon-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-icon-audio {
|
||||
animation: mockup-icon-glow 3s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.mockup-progress-shimmer {
|
||||
animation: mockup-shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-dot-1 {
|
||||
animation: processing-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-dot-2 {
|
||||
animation: processing-dot 1.4s ease-in-out infinite 0.2s;
|
||||
}
|
||||
|
||||
.mockup-dot-3 {
|
||||
animation: processing-dot 1.4s ease-in-out infinite 0.4s;
|
||||
}
|
||||
|
||||
.benefits-flow-line {
|
||||
animation: benefits-flow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.benefits-process-icon {
|
||||
animation: benefits-process-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.benefits-flow-path {
|
||||
stroke-dasharray: 6 8;
|
||||
animation: benefits-flow-dash 2s linear infinite;
|
||||
}
|
||||
|
||||
.mobile-sidebar-panel-open {
|
||||
animation: mobile-sidebar-open 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-panel-close {
|
||||
animation: mobile-sidebar-close 0.3s cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-close-btn-open {
|
||||
animation: mobile-sidebar-close-btn-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-close-btn-close {
|
||||
animation: mobile-sidebar-close-btn-out 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link {
|
||||
@apply relative block overflow-hidden rounded-lg px-4 py-3 text-base font-medium text-gray-300 transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link::before {
|
||||
content: "";
|
||||
@apply absolute bottom-2 left-0 top-2 w-1 origin-left scale-y-0 rounded-r bg-accent transition-transform duration-300 ease-out;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link:hover {
|
||||
@apply translate-x-1 bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.12)];
|
||||
}
|
||||
|
||||
.mobile-sidebar-link:hover::before {
|
||||
@apply scale-y-100;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-active {
|
||||
@apply bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.2)];
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-active::before {
|
||||
@apply scale-y-100;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-danger:hover {
|
||||
@apply bg-red-500/10 text-red-300 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.2)];
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-danger:hover::before {
|
||||
@apply bg-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-border-pulse {
|
||||
0%,
|
||||
100% {
|
||||
border-color: rgb(75 85 99 / 0.5);
|
||||
box-shadow: 0 0 0 0 rgb(255 255 255 / 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgb(156 163 175 / 0.7);
|
||||
box-shadow: 0 0 12px 0 rgb(255 255 255 / 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-icon-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-shimmer {
|
||||
0% {
|
||||
left: -30%;
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes processing-dot {
|
||||
0%,
|
||||
20% {
|
||||
opacity: 0;
|
||||
}
|
||||
40%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes benefits-flow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes benefits-process-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 28px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes benefits-flow-dash {
|
||||
to {
|
||||
stroke-dashoffset: -28;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-open {
|
||||
from {
|
||||
opacity: 0.85;
|
||||
transform: translateX(100%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0.85;
|
||||
transform: translateX(100%) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-close-btn-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) rotate(-90deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-close-btn-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 374 KiB |
@@ -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 (
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<JobProgress jobId={id} />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Songs2YT",
|
||||
description:
|
||||
"Songs2YT is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { BenefitsSection } from "@/components/BenefitsSection";
|
||||
import { DownloadSection } from "@/components/DownloadSection";
|
||||
import { LandingNavbar } from "@/components/LandingNavbar";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { PricingSection } from "@/components/PricingSection";
|
||||
import { StepsSection } from "@/components/StepsSection";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { SupportSection } from "@/components/SupportSection";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<section className="relative min-h-dvh overflow-hidden">
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<source src="/bg-video.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<LandingNavbar />
|
||||
|
||||
{/* Purpose + brand above the fold for Google OAuth verification */}
|
||||
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white sm:text-6xl lg:text-7xl">
|
||||
Songs2YT
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
|
||||
Songs2YT is an automation tool that converts your audio and image files into
|
||||
high-quality videos and uploads them directly to YouTube.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-3">
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<SignInButton large />
|
||||
<p className="max-w-md text-xs leading-relaxed text-gray-400">
|
||||
By clicking Continue with Google, you accept our{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StepsSection />
|
||||
<BenefitsSection />
|
||||
|
||||
<DownloadSection />
|
||||
|
||||
<PricingSection />
|
||||
|
||||
<SupportSection />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy | Songs2YT",
|
||||
description: "How Songs2YT collects, uses, and protects your personal data.",
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Privacy Policy"
|
||||
description="How we handle your personal data when you use Songs2YT."
|
||||
>
|
||||
<h2>1. Overview</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
We process personal data in accordance with applicable data protection laws, including the
|
||||
General Data Protection Regulation (GDPR) where it applies.
|
||||
</p>
|
||||
|
||||
<h2>2. Data controller</h2>
|
||||
<p>
|
||||
{LEGAL_OPERATOR.legalName}
|
||||
<br />
|
||||
{LEGAL_OPERATOR.address}
|
||||
<br />
|
||||
{LEGAL_OPERATOR.city}
|
||||
<br />
|
||||
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
</p>
|
||||
|
||||
<h2>3. What data we collect</h2>
|
||||
<h3>3.1 Account and authentication data</h3>
|
||||
<p>When you sign in with Google, we receive and store:</p>
|
||||
<ul>
|
||||
<li>Your name, email address, and profile image (from Google)</li>
|
||||
<li>OAuth tokens required to authenticate your session</li>
|
||||
<li>YouTube connection data, including channel ID and title</li>
|
||||
<li>Encrypted YouTube API access and refresh tokens needed to upload videos on your behalf</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.2 Uploaded content</h3>
|
||||
<p>When you use the service, we temporarily process:</p>
|
||||
<ul>
|
||||
<li>Image and audio files you upload</li>
|
||||
<li>Generated video files</li>
|
||||
<li>Per-video metadata you provide (title, description, tags, privacy settings, etc.)</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.3 Usage and technical data</h3>
|
||||
<ul>
|
||||
<li>Plan type, quota usage, and job processing status</li>
|
||||
<li>IP address, browser type, device information, and request logs</li>
|
||||
<li>Error reports and operational diagnostics</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.4 Payment data</h3>
|
||||
<p>
|
||||
If you purchase a paid plan, payment processing is handled by our payment provider. We do
|
||||
not store full payment card details on our servers. We may receive billing status,
|
||||
subscription identifiers, and transaction references.
|
||||
</p>
|
||||
|
||||
<h2>4. Why we process your data</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Contract performance:</strong> to provide video encoding, metadata handling, and
|
||||
YouTube upload features you request
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
|
||||
reliability, and enforce our terms
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consent:</strong> where you have given explicit consent, such as optional
|
||||
marketing communications if offered
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Third-party services</h2>
|
||||
<p>We use trusted third parties to operate Songs2YT, including:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
|
||||
the YouTube Data API
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
|
||||
storage
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment processors:</strong> for Pro and Enterprise billing when available
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
These providers process data only as necessary to deliver their services and under
|
||||
appropriate contractual safeguards where required.
|
||||
</p>
|
||||
|
||||
<h2>6. Data retention</h2>
|
||||
<ul>
|
||||
<li>Uploaded source files and generated outputs are retained only as long as needed to complete your jobs</li>
|
||||
<li>Account data is kept while your account remains active</li>
|
||||
<li>Billing records may be retained as required by law</li>
|
||||
<li>Logs are retained for a limited period for security and troubleshooting</li>
|
||||
</ul>
|
||||
<p>You may request deletion of your account data subject to legal retention obligations.</p>
|
||||
|
||||
<h2>7. Self-hosted deployments</h2>
|
||||
<p>
|
||||
If you deploy Songs2YT on your own infrastructure, you are the data controller for data
|
||||
processed on your instance. This Privacy Policy applies to the hosted cloud service
|
||||
operated by us, not to independent self-hosted installations unless we provide managed
|
||||
hosting for you under contract.
|
||||
</p>
|
||||
|
||||
<h2>8. Your rights</h2>
|
||||
<p>Depending on your location, you may have the right to:</p>
|
||||
<ul>
|
||||
<li>Access the personal data we hold about you</li>
|
||||
<li>Request correction or deletion</li>
|
||||
<li>Restrict or object to certain processing</li>
|
||||
<li>Data portability</li>
|
||||
<li>Withdraw consent where processing is consent-based</li>
|
||||
<li>Lodge a complaint with a supervisory authority</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise these rights, contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
|
||||
</p>
|
||||
|
||||
<h2>9. Cookies and local storage</h2>
|
||||
<p>
|
||||
We use essential cookies and similar technologies for authentication, session management,
|
||||
and security. We do not use non-essential tracking cookies unless disclosed separately and
|
||||
enabled with your consent where required.
|
||||
</p>
|
||||
|
||||
<h2>10. Security</h2>
|
||||
<p>
|
||||
We implement appropriate technical and organizational measures to protect your data,
|
||||
including encryption in transit, access controls, and isolated processing environments.
|
||||
No method of transmission or storage is 100% secure.
|
||||
</p>
|
||||
|
||||
<h2>11. International transfers</h2>
|
||||
<p>
|
||||
If data is transferred outside your country, we ensure appropriate safeguards such as
|
||||
standard contractual clauses or equivalent mechanisms where required by law.
|
||||
</p>
|
||||
|
||||
<h2>12. Children</h2>
|
||||
<p>
|
||||
Songs2YT is not directed at children under 16. We do not knowingly collect personal data
|
||||
from children. If you believe a child has provided us data, please contact us.
|
||||
</p>
|
||||
|
||||
<h2>13. Changes to this policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. Material changes will be posted on
|
||||
this page with an updated effective date.
|
||||
</p>
|
||||
|
||||
<h2>14. Contact</h2>
|
||||
<p>
|
||||
Questions about this Privacy Policy:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Refund Policy | Songs2YT",
|
||||
description: "Refund and cancellation policy for Songs2YT paid plans.",
|
||||
};
|
||||
|
||||
export default function RefundPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Refund Policy"
|
||||
description="Our policy on refunds, cancellations, and billing for paid plans."
|
||||
>
|
||||
<h2>1. Overview</h2>
|
||||
<p>
|
||||
This Refund Policy explains how refunds and cancellations work for paid Songs2YT plans. The
|
||||
free Bedroom Producer plan does not involve payments and is not subject to refunds.
|
||||
</p>
|
||||
|
||||
<h2>2. Free plan</h2>
|
||||
<p>
|
||||
The free plan is provided at no charge. No payment information is required and no refunds
|
||||
apply.
|
||||
</p>
|
||||
|
||||
<h2>3. Pro subscriptions</h2>
|
||||
<h3>3.1 Billing cycle</h3>
|
||||
<p>
|
||||
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
|
||||
Your subscription renews automatically until cancelled.
|
||||
</p>
|
||||
|
||||
<h3>3.2 14-day refund window</h3>
|
||||
<p>
|
||||
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> 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).
|
||||
</p>
|
||||
|
||||
<h3>3.3 After the refund window</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>3.4 Cancellation</h3>
|
||||
<p>
|
||||
You can cancel your subscription through your account billing settings or by contacting{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. Cancellation stops
|
||||
future charges; it does not automatically delete your account or uploaded content history
|
||||
unless you request account deletion separately.
|
||||
</p>
|
||||
|
||||
<h2>4. Enterprise and custom agreements</h2>
|
||||
<p>
|
||||
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{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
|
||||
contract-related billing questions.
|
||||
</p>
|
||||
|
||||
<h2>5. Non-refundable situations</h2>
|
||||
<p>Refunds are generally not provided when:</p>
|
||||
<ul>
|
||||
<li>The refund request is made outside the applicable refund window</li>
|
||||
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
|
||||
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
|
||||
<li>You simply changed your mind after substantial use of paid quota or features</li>
|
||||
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Chargebacks</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>7. How to request a refund</h2>
|
||||
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
|
||||
<ul>
|
||||
<li>Your account email address</li>
|
||||
<li>Date of purchase and invoice or transaction reference if available</li>
|
||||
<li>Reason for the refund request</li>
|
||||
</ul>
|
||||
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
|
||||
|
||||
<h2>8. Consumer rights</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>9. Changes</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service | Songs2YT",
|
||||
description: "Terms and conditions for using the Songs2YT hosted service.",
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Terms of Service"
|
||||
description="Please read these terms carefully before using Songs2YT."
|
||||
>
|
||||
<h2>1. Agreement</h2>
|
||||
<p>
|
||||
These Terms of Service ("Terms") govern your access to and use of the Songs2YT
|
||||
website and hosted cloud service (the "Service") operated by{" "}
|
||||
{LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using
|
||||
the Service, you agree to these Terms.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>2. The Service</h2>
|
||||
<p>
|
||||
Songs2YT converts user-provided images and audio files into videos and can upload them to
|
||||
YouTube using your connected Google/YouTube account. Features, limits, and availability
|
||||
depend on your plan.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Bedroom Producer (Free):</strong> limited quota, 720p, MP3, optional watermark
|
||||
</li>
|
||||
<li>
|
||||
<strong>Independent Artist (Pro):</strong> expanded limits and features as described on
|
||||
our pricing page
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enterprise:</strong> custom terms as agreed in writing
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>3. Eligibility and accounts</h2>
|
||||
<ul>
|
||||
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
|
||||
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
|
||||
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
|
||||
<li>You must provide accurate information and promptly update it if it changes</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Your content and responsibilities</h2>
|
||||
<p>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.</p>
|
||||
<p>You represent and warrant that:</p>
|
||||
<ul>
|
||||
<li>You own or have all necessary rights to the content you upload</li>
|
||||
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
|
||||
<li>Your content does not infringe third-party rights or contain unlawful material</li>
|
||||
<li>You have configured metadata (including "Made for Kids" and privacy settings) accurately</li>
|
||||
</ul>
|
||||
<p>
|
||||
You are solely responsible for content published to your YouTube channel through the
|
||||
Service.
|
||||
</p>
|
||||
|
||||
<h2>5. Acceptable use</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
|
||||
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
|
||||
<li>Circumvent quotas, plan limits, or technical restrictions</li>
|
||||
<li>Resell or commercially exploit the hosted Service without authorization</li>
|
||||
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
|
||||
</ul>
|
||||
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
|
||||
|
||||
<h2>6. YouTube and third-party services</h2>
|
||||
<p>
|
||||
The Service integrates with Google OAuth and the YouTube API. Your use of those services is
|
||||
subject to Google's and YouTube's terms and policies. We are not responsible for
|
||||
changes, outages, quota limits, or enforcement actions taken by YouTube.
|
||||
</p>
|
||||
|
||||
<h2>7. Open-source software</h2>
|
||||
<p>
|
||||
Portions of Songs2YT are available as open-source software. Self-hosting is permitted under
|
||||
the applicable open-source license. The hosted Service, enterprise features, managed
|
||||
infrastructure, and certain premium capabilities may require a separate commercial license
|
||||
or subscription.
|
||||
</p>
|
||||
|
||||
<h2>8. Fees and billing</h2>
|
||||
<p>
|
||||
Paid plans are billed according to the pricing displayed at the time of purchase. Taxes may
|
||||
apply. Subscriptions renew automatically unless cancelled in accordance with our{" "}
|
||||
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
|
||||
suspension.
|
||||
</p>
|
||||
|
||||
<h3>8.1 Pro plan quota</h3>
|
||||
<p>
|
||||
Independent Artist (Pro) subscribers receive a monthly video quota that resets on the{" "}
|
||||
<strong>1st day of each calendar month</strong> (UTC). Unused quota does not roll over to
|
||||
the next month unless we expressly grant an extension in writing.
|
||||
</p>
|
||||
<p>
|
||||
Pro subscribers may contact{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> to request a manual
|
||||
quota reset or temporary extension. Each Pro account is entitled to up to{" "}
|
||||
<strong>five (5) manual monthly quota resets per calendar year</strong>. We review requests
|
||||
in good faith and may decline requests that are abusive, repetitive without cause, or
|
||||
inconsistent with fair use. Approved resets do not increase the annual limit of five
|
||||
requests.
|
||||
</p>
|
||||
|
||||
<h3>8.2 Pro API access</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>9. Availability and support</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>10. Disclaimer of warranties</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>11. Limitation of liability</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Some jurisdictions do not allow certain limitations, so some of the above may not apply to
|
||||
you.
|
||||
</p>
|
||||
|
||||
<h2>12. Indemnification</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>13. Termination</h2>
|
||||
<p>
|
||||
You may stop using the Service at any time. We may suspend or terminate your access if you
|
||||
breach these Terms, create risk or legal exposure, or where required by law. Upon
|
||||
termination, your right to use the hosted Service ends. Provisions that by nature should
|
||||
survive will survive.
|
||||
</p>
|
||||
|
||||
<h2>14. Governing law</h2>
|
||||
<p>
|
||||
These Terms are governed by the laws of [Your jurisdiction / country], excluding conflict
|
||||
of law rules. Courts in [Your jurisdiction] shall have exclusive jurisdiction unless
|
||||
mandatory consumer protection laws in your country provide otherwise.
|
||||
</p>
|
||||
|
||||
<h2>15. Changes</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>16. Contact</h2>
|
||||
<p>
|
||||
Questions about these Terms:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,5 @@
|
||||
# Watermark image
|
||||
|
||||
Place `watermark.png` here for the bottom-right video overlay.
|
||||
The image should include the full attribution: "Uploaded through Songs2YT.com".
|
||||
If missing, FFmpeg falls back to bottom-right drawtext with the same message.
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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<string | null>(null);
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (
|
||||
!confirm(
|
||||
"Delete your account permanently? This removes your jobs, uploads, and YouTube connection. This cannot be undone.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/delete", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to delete account");
|
||||
await signOut({ callbackUrl: "/" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete account");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dataRequestSubject = encodeURIComponent("Data export request");
|
||||
const dataRequestBody = encodeURIComponent(
|
||||
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}?subject=${dataRequestSubject}&body=${dataRequestBody}`}
|
||||
className="inline-flex items-center justify-center rounded border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-surface"
|
||||
>
|
||||
Request my data
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Data requests are handled within the timelines described in our{" "}
|
||||
<a href="/privacy" className="text-gray-400 underline hover:text-white">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ApiKeyStatus = {
|
||||
configured: boolean;
|
||||
prefix: string | null;
|
||||
};
|
||||
|
||||
type RateLimitStatus = {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
windowSeconds: number;
|
||||
resetsInSeconds: number;
|
||||
bonus?: number;
|
||||
};
|
||||
|
||||
type ExtensionRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
adminNote: string | null;
|
||||
};
|
||||
|
||||
type ExtensionUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
requests: ExtensionRequest[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialStatus: ApiKeyStatus;
|
||||
initialRateLimit: RateLimitStatus;
|
||||
initialExtensionUsage: ExtensionUsage;
|
||||
};
|
||||
|
||||
export function ApiKeySettings({
|
||||
initialStatus,
|
||||
initialRateLimit,
|
||||
initialExtensionUsage,
|
||||
}: Props) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
||||
const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
fetch("/api/account/api-rate-limit")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (active) setRateLimit(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refresh();
|
||||
const id = setInterval(refresh, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function generateKey() {
|
||||
if (
|
||||
status.configured &&
|
||||
!confirm("This will replace your existing API key. Continue?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNewKey(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
|
||||
|
||||
setNewKey(data.apiKey);
|
||||
setStatus({ configured: true, prefix: data.prefix });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKey() {
|
||||
if (!confirm("Revoke your API key? External integrations will stop working.")) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
|
||||
|
||||
setStatus({ configured: false, prefix: null });
|
||||
setNewKey(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to revoke API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRateLimitExtension() {
|
||||
if (extensionUsage.remaining <= 0) return;
|
||||
|
||||
const reason = prompt(
|
||||
"Optional: tell us why you need a higher API rate limit (leave blank to skip).",
|
||||
);
|
||||
if (reason === null) return;
|
||||
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-rate-limit-extension-request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to submit request");
|
||||
|
||||
setExtensionUsage({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
remaining: data.remaining,
|
||||
requests: data.requests ?? extensionUsage.requests,
|
||||
});
|
||||
setSuccess(
|
||||
`Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING");
|
||||
const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the REST API to upload files and create batch video jobs programmatically. Pro plan
|
||||
only.
|
||||
</p>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-white">API rate limit</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Resets in {rateLimit.resetsInSeconds}s
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
{rateLimit.used} / {rateLimit.limit} requests used this minute
|
||||
{rateLimit.bonus ? (
|
||||
<span className="text-gray-500"> (includes +{rateLimit.bonus} bonus)</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded transition-all ${
|
||||
usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${usedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-gray-400">
|
||||
Extension requests this year: {extensionUsage.used} / {extensionUsage.limit}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestRateLimitExtension}
|
||||
disabled={requesting || extensionUsage.remaining <= 0 || hasPending}
|
||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{requesting
|
||||
? "Submitting…"
|
||||
: hasPending
|
||||
? "Pending request"
|
||||
: extensionUsage.remaining <= 0
|
||||
? "No extensions left"
|
||||
: "Request rate limit extension"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{extensionUsage.requests.length > 0 && (
|
||||
<ul className="space-y-1 border-t border-gray-800 pt-3 text-xs text-gray-500">
|
||||
{extensionUsage.requests.slice(0, 5).map((req) => (
|
||||
<li key={req.id}>
|
||||
{new Date(req.requestedAt).toLocaleDateString()} · {req.status}
|
||||
{req.adminNote ? ` · ${req.adminNote}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.configured && status.prefix && (
|
||||
<p className="text-sm text-gray-300">
|
||||
Active key: <span className="font-mono text-white">{status.prefix}…</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{newKey && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
|
||||
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
|
||||
{newKey}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
|
||||
</button>
|
||||
{status.configured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={revokeKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
Revoke API key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
|
||||
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
|
||||
<ul className="space-y-2 font-mono">
|
||||
<li>POST /api/v1/upload: upload image or audio file</li>
|
||||
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
|
||||
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
|
||||
<li>GET /api/v1/playlists: list YouTube playlists</li>
|
||||
<li>POST /api/v1/playlists: create a YouTube playlist</li>
|
||||
<li>GET /api/v1/jobs: list jobs</li>
|
||||
<li>GET /api/v1/jobs/:id: job status</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
|
||||
request. For many audio files, upload each file then call{" "}
|
||||
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
|
||||
<a href="/dashboard/api-docs" className="text-accent hover:underline">
|
||||
Full API docs
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
|
||||
function FilmStripIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
|
||||
<path d="m15 9 3 3-3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
|
||||
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CoinsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="8" cy="6" rx="5" ry="2" />
|
||||
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
|
||||
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
<ellipse cx="16" cy="14" rx="5" ry="2" />
|
||||
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
|
||||
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WaveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GearsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
title: "No editing required",
|
||||
desc: "Skip complex video editors. Just upload an image and audio, and Songs2YT handles the rest.",
|
||||
Icon: FilmStripIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube-ready output",
|
||||
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
|
||||
Icon: YouTubeIcon,
|
||||
},
|
||||
{
|
||||
title: "Free tier included",
|
||||
desc: "Start creating with 14 videos every 12 hours at up to 720p. No credit card needed.",
|
||||
Icon: CoinsIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube playlists",
|
||||
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
|
||||
Icon: PlaylistIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STOCK_THUMBS = [
|
||||
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
|
||||
] as const;
|
||||
|
||||
function TypewriterText({ text }: { text: string }) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0;
|
||||
const interval = setInterval(() => {
|
||||
index += 1;
|
||||
setDisplayed(text.slice(0, index));
|
||||
if (index >= text.length) {
|
||||
clearInterval(interval);
|
||||
setDone(true);
|
||||
}
|
||||
}, 45);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{displayed}
|
||||
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitCard({
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof FilmStripIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
|
||||
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
|
||||
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MockupProgressRow({
|
||||
initialWidth,
|
||||
midWidth,
|
||||
active,
|
||||
phaseOneMs = 3000,
|
||||
phaseTwoMs = 2000,
|
||||
}: {
|
||||
initialWidth: string;
|
||||
midWidth: string;
|
||||
active: boolean;
|
||||
phaseOneMs?: number;
|
||||
phaseTwoMs?: number;
|
||||
}) {
|
||||
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-white/80 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardMockup() {
|
||||
const { ref, inView } = useInView();
|
||||
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
|
||||
<div className="h-2 w-2 rounded-full bg-gray-500" />
|
||||
{sidebarIcons.map((Icon, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<TypewriterText text="Multi-step configuration" />
|
||||
<div className="h-4 w-8 rounded-full bg-red-500/80" />
|
||||
</div>
|
||||
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
|
||||
Title
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
|
||||
</div>
|
||||
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
|
||||
Category <span className="text-gray-400">Music ▾</span>
|
||||
</div>
|
||||
<div className="space-y-2 pt-1">
|
||||
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessFlow() {
|
||||
return (
|
||||
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 420 240"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
d="M 58 52 C 120 52, 140 58, 168 72"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
/>
|
||||
<path
|
||||
d="M 58 118 C 120 108, 140 88, 168 78"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.3s" }}
|
||||
/>
|
||||
<path
|
||||
d="M 198 75 C 250 75, 285 68, 318 68"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.6s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FlowInput label="Batch Audio Files" icon="audio" />
|
||||
<FlowInput label="Single Cover Image" icon="image" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center px-2 pt-6">
|
||||
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
|
||||
<Image
|
||||
src="/database.png"
|
||||
alt="Process"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STOCK_THUMBS.map((src) => (
|
||||
<div
|
||||
key={src}
|
||||
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="scale-110 object-cover blur-[2px]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="my-2 h-10 w-6 overflow-visible"
|
||||
viewBox="0 0 6 40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="3"
|
||||
y1="2"
|
||||
x2="3"
|
||||
y2="38"
|
||||
stroke="rgb(239 68 68 / 0.55)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.9s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<YouTubeIcon className="h-9 w-9 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
|
||||
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
|
||||
{icon === "audio" ? (
|
||||
<NoteIcon className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsVisual() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
|
||||
<DashboardMockup />
|
||||
<ProcessFlow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
|
||||
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
|
||||
}
|
||||
|
||||
export function BenefitsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="benefits"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<BenefitsScrollTitle sectionRef={sectionRef} />
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
{BENEFITS.map((benefit, index) => (
|
||||
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
|
||||
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={200} direction="right">
|
||||
<BenefitsVisual />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { YOUTUBE_CATEGORIES } from "@/lib/constants";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CategorySelect({ value, onChange }: Props) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none"
|
||||
>
|
||||
{YOUTUBE_CATEGORIES.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { SignOutButton } from "@/components/SignOutButton";
|
||||
import { MobileMenuButton, MobileSidebar, dashboardSidebarLinkClass } from "@/components/MobileSidebar";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "/dashboard", label: "Create", match: (path: string) => path === "/dashboard" },
|
||||
{
|
||||
href: "/dashboard/history",
|
||||
label: "History",
|
||||
match: (path: string) => path.startsWith("/dashboard/history"),
|
||||
},
|
||||
{
|
||||
href: "/dashboard/settings",
|
||||
label: "Settings",
|
||||
match: (path: string) => path.startsWith("/dashboard/settings"),
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
channelTitle?: string | null;
|
||||
};
|
||||
|
||||
export function DashboardNav({ channelTitle }: Props) {
|
||||
const pathname = usePathname();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<Logo href="/dashboard" size="sm" />
|
||||
{channelTitle && (
|
||||
<p className="truncate text-xs text-gray-500">Channel: {channelTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<nav className="flex items-center gap-4">
|
||||
{NAV_LINKS.map(({ href, label, match }) => {
|
||||
const active = match(pathname);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-medium transition-colors duration-200 ${
|
||||
active ? "text-white" : "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
|
||||
<MobileMenuButton
|
||||
open={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Dashboard">
|
||||
{NAV_LINKS.map(({ href, label, match }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={dashboardSidebarLinkClass(match(pathname))}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
signOut({ callbackUrl: "/" });
|
||||
}}
|
||||
className={`${dashboardSidebarLinkClass(false, true)} w-full text-left`}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</MobileSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { DashboardNav } from "@/components/DashboardNav";
|
||||
import { LegalFooter } from "@/components/LegalFooter";
|
||||
|
||||
type Props = {
|
||||
channelTitle?: string | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardShell({ channelTitle, children }: Props) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col">
|
||||
<DashboardNav channelTitle={channelTitle} />
|
||||
<div className="flex-1">{children}</div>
|
||||
<LegalFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
|
||||
<path
|
||||
fill="#2496ED"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
|
||||
<path
|
||||
fill="#609926"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DEPLOY_CARDS = [
|
||||
{
|
||||
name: "Docker Hub",
|
||||
desc: "Pull the image and run Songs2YT on your own server with Docker Compose.",
|
||||
Icon: DockerIcon,
|
||||
cta: "View on Docker Hub",
|
||||
href: DOCKER_HUB_URL,
|
||||
accent: "text-[#2496ED]",
|
||||
hover:
|
||||
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
|
||||
titleHover: "group-hover:text-[#2496ED]",
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
|
||||
Icon: GiteaIcon,
|
||||
cta: "View on Gitea",
|
||||
href: GITEA_URL,
|
||||
accent: "text-[#609926]",
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function DeployCard({
|
||||
name,
|
||||
desc,
|
||||
Icon,
|
||||
cta,
|
||||
href,
|
||||
accent,
|
||||
hover,
|
||||
titleHover,
|
||||
}: (typeof DEPLOY_CARDS)[number]) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
|
||||
Open Source
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
|
||||
{cta}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="download"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
|
||||
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
|
||||
Songs2YT is open source. Self-host on your own server with Docker or deploy from source.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
|
||||
<ScrollReveal direction="left">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
|
||||
<p className="mt-3 leading-relaxed text-gray-400">
|
||||
Host Songs2YT on your infrastructure with full control over data, queues, and
|
||||
storage. Ideal for teams and creators who want a private deployment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Docker image published to Docker Hub
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Source code available on Gitea
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Gitea Issues community support
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
PostgreSQL, Redis, FFmpeg, and worker included
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
|
||||
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
|
||||
<code>{`docker pull atakanozban/songs2yt:latest\ndocker compose up -d`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{DEPLOY_CARDS.map((card, index) => (
|
||||
<ScrollReveal key={card.name} delay={index * 120} direction="right">
|
||||
<DeployCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import {
|
||||
DOCKER_HUB_URL,
|
||||
GITEA_ISSUES_URL,
|
||||
GITEA_URL,
|
||||
SUPPORT_EMAIL,
|
||||
} from "@/lib/plans";
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-white";
|
||||
|
||||
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-gray-300";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_URL = "https://status.atakanozban.com/status/2";
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
|
||||
<div className="flex flex-col gap-4 md:col-span-5">
|
||||
<Logo size="sm" />
|
||||
<p className="max-w-sm text-gray-500">
|
||||
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
|
||||
fully open-source.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-gray-500">
|
||||
<a
|
||||
href={GITEA_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Gitea"
|
||||
className="transition-colors duration-300 hover:text-[#609926]"
|
||||
>
|
||||
<GiteaIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href={DOCKER_HUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Docker Hub"
|
||||
className="transition-colors duration-300 hover:text-[#2496ED]"
|
||||
>
|
||||
<DockerIcon className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Product
|
||||
</span>
|
||||
<FooterLink href="#pricing">Pricing</FooterLink>
|
||||
<FooterLink href="#download">Download</FooterLink>
|
||||
<FooterLink href="#benefits">Benefits</FooterLink>
|
||||
<FooterLink href="/privacy">Privacy Policy</FooterLink>
|
||||
<FooterLink href="/terms">Terms of Service</FooterLink>
|
||||
<FooterLink href="/refund">Refund Policy</FooterLink>
|
||||
<FooterLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Open Source
|
||||
</span>
|
||||
<FooterLink href={GITEA_URL} external>
|
||||
Gitea Instance
|
||||
</FooterLink>
|
||||
<FooterLink href={DOCKER_HUB_URL} external>
|
||||
Docker Image
|
||||
</FooterLink>
|
||||
<FooterLink href={GITEA_ISSUES_URL} external>
|
||||
Report a Bug
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Contact
|
||||
</span>
|
||||
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
|
||||
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
|
||||
<span>© {year} Songs2YT. All rights reserved.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/privacy">Privacy Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</LegalLink>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
||||
import type { JobResponse } from "@/lib/types";
|
||||
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Queued",
|
||||
ENCODING: "Encoding",
|
||||
UPLOADING: "Uploading",
|
||||
COMPLETED: "Completed",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
function sameDay(a: Date, b: Date) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatDayLabel(date: Date) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (sameDay(date, today)) return "Today";
|
||||
if (sameDay(date, yesterday)) return "Yesterday";
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function groupJobsByDay(jobs: JobResponse[]) {
|
||||
const groups = new Map<string, { label: string; jobs: JobResponse[] }>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const date = new Date(job.createdAt);
|
||||
const key = date.toLocaleDateString("en-CA");
|
||||
const existing = groups.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.jobs.push(job);
|
||||
} else {
|
||||
groups.set(key, { label: formatDayLabel(date), jobs: [job] });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([, group]) => group);
|
||||
}
|
||||
|
||||
function statusClass(status: string) {
|
||||
if (status === "COMPLETED") return "bg-green-500/20 text-green-400";
|
||||
if (status === "FAILED") return "bg-red-500/20 text-red-400";
|
||||
return "bg-yellow-500/20 text-yellow-400";
|
||||
}
|
||||
|
||||
export function JobHistory() {
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/jobs?limit=100");
|
||||
if (!res.ok) throw new Error("Failed to load history");
|
||||
const data = await res.json();
|
||||
if (active) setJobs(data.jobs);
|
||||
} catch (err) {
|
||||
if (active) setError(err instanceof Error ? err.message : "Error loading history");
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dayGroups = useMemo(() => groupJobsByDay(jobs), [jobs]);
|
||||
const youtubeLimitHit = useMemo(
|
||||
() =>
|
||||
jobs.some((job) =>
|
||||
job.items.some(
|
||||
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
|
||||
),
|
||||
),
|
||||
[jobs],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-400">Loading history…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dayGroups.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-700 bg-surface-light p-8 text-center">
|
||||
<p className="text-gray-400">No videos created yet.</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-4 inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Create your first video
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{youtubeLimitHit && <YouTubeLimitBanner />}
|
||||
|
||||
{dayGroups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">{group.label}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{group.jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface-light p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(job.createdAt).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{" · "}
|
||||
{job.items.length} video{job.items.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Status: <span className="text-gray-300">{job.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="text-sm text-accent transition-colors hover:text-accent-hover"
|
||||
>
|
||||
View job
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{job.items.map((item) => {
|
||||
const itemError =
|
||||
item.status === "FAILED" ? displayJobItemError(item.error) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded border border-gray-800 bg-surface px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-white">{item.title}</p>
|
||||
<p className="truncate text-xs text-gray-500">{item.audioFilename}</p>
|
||||
{item.youtubeVideoId && (
|
||||
<a
|
||||
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-xs text-accent hover:underline"
|
||||
>
|
||||
View on YouTube
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-1 text-xs font-medium ${statusClass(item.status)}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
{itemError && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-red-400">{itemError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
||||
import type { JobResponse } from "@/lib/types";
|
||||
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
||||
|
||||
type Props = {
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Queued",
|
||||
ENCODING: "Encoding video…",
|
||||
UPLOADING: "Uploading to YouTube…",
|
||||
COMPLETED: "Completed",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
export function JobProgress({ jobId }: Props) {
|
||||
const [job, setJob] = useState<JobResponse | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
plan: string;
|
||||
resetsIn: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const [jobRes, quotaRes] = await Promise.all([
|
||||
fetch(`/api/jobs/${jobId}`),
|
||||
fetch("/api/quota"),
|
||||
]);
|
||||
|
||||
if (!jobRes.ok) throw new Error("Failed to load job");
|
||||
const jobData = await jobRes.json();
|
||||
if (active) setJob(jobData);
|
||||
|
||||
if (quotaRes.ok) {
|
||||
const quotaData = await quotaRes.json();
|
||||
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit, plan: quotaData.plan, resetsIn: quotaData.resetsIn });
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return <div className="text-gray-400">Loading job status…</div>;
|
||||
}
|
||||
|
||||
const allDone = job.items.every((i) => i.status === "COMPLETED" || i.status === "FAILED");
|
||||
const youtubeLimitHit = job.items.some(
|
||||
(i) => i.status === "FAILED" && i.error && isYouTubeUploadLimitError(i.error),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Job Status</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Overall: <span className="text-white">{job.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
{quota && (
|
||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||
{quota.remaining} / {quota.limit} videos remaining ·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{youtubeLimitHit && <YouTubeLimitBanner />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{job.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface-light p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-white">{item.title}</p>
|
||||
<p className="text-xs text-gray-500">{item.audioFilename}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
item.status === "COMPLETED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: item.status === "FAILED"
|
||||
? "bg-red-500/20 text-red-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.youtubeVideoId && (
|
||||
<a
|
||||
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block text-sm text-accent hover:underline"
|
||||
>
|
||||
View on YouTube
|
||||
</a>
|
||||
)}
|
||||
|
||||
{item.status === "FAILED" && displayJobItemError(item.error) && (
|
||||
<p className="mt-2 text-sm text-red-400">{displayJobItemError(item.error)}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allDone && (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Create another video
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "#benefits", label: "Benefits" },
|
||||
{ href: "#download", label: "Download" },
|
||||
{ href: "#pricing", label: "Pricing" },
|
||||
{ href: "#support", label: "Support" },
|
||||
] as const;
|
||||
|
||||
function NavAnchor({
|
||||
href,
|
||||
label,
|
||||
onNavigate,
|
||||
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
const id = href.replace("#", "");
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.pushState(null, "", href);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} className={className}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingNavbar() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<Logo size="md" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="hidden items-center gap-10 sm:flex">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<NavAnchor key={href} href={href} label={label} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<MobileMenuButton
|
||||
open={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<NavAnchor
|
||||
key={href}
|
||||
href={href}
|
||||
label={label}
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
className={sidebarLinkClass()}
|
||||
/>
|
||||
))}
|
||||
</MobileSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-gray-300";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalFooter() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
||||
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
|
||||
<span>© {year} Songs2YT. All rights reserved.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/privacy">Privacy Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="https://status.atakanozban.com/status/2" external>
|
||||
Service Status
|
||||
</LegalLink>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function LegalPageLayout({ title, description, children }: Props) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-dark">
|
||||
<header className="border-b border-gray-800 bg-black/30">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-6 py-6">
|
||||
<Logo />
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-gray-400 transition-colors duration-300 hover:text-white"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">
|
||||
Last updated: {LEGAL_LAST_UPDATED}
|
||||
</p>
|
||||
<h1 className="mb-3 text-3xl font-bold text-white">{title}</h1>
|
||||
{description && <p className="mb-10 text-gray-400">{description}</p>}
|
||||
|
||||
<article className="prose prose-invert prose-gray max-w-none prose-headings:font-semibold prose-headings:text-white prose-p:text-gray-300 prose-li:text-gray-300 prose-strong:text-gray-200 prose-a:text-accent prose-a:no-underline hover:prose-a:underline">
|
||||
{children}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-gray-800 py-8 text-center text-xs text-gray-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Link href="/privacy" className="hover:text-gray-300">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-gray-300">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/refund" className="hover:text-gray-300">
|
||||
Refund
|
||||
</Link>
|
||||
<a
|
||||
href="https://status.atakanozban.com/status/2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-gray-300"
|
||||
>
|
||||
Service Status
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
href?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function Logo({ href = "/", size = "md" }: Props) {
|
||||
const textSize = size === "sm" ? "text-xl" : "text-2xl";
|
||||
|
||||
const content = (
|
||||
<span className={`inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
||||
<span className="text-white">Songs</span>
|
||||
<span className="text-red-400">2YT</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="inline-flex items-center" aria-label="Songs2YT">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center" aria-label="Songs2YT">
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PANEL_MS = 420;
|
||||
const ITEM_BASE_MS = 90;
|
||||
|
||||
function CloseIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar({ open, onClose, title = "Menu", children }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const frame = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
const timer = window.setTimeout(() => setMounted(false), PANEL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [mounted, onClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
style={{ animationDelay: visible ? "120ms" : "0ms" }}
|
||||
className={`fixed right-4 top-4 z-[60] flex h-10 w-10 items-center justify-center rounded-full border border-gray-700 bg-surface text-gray-300 shadow-lg transition-shadow hover:bg-surface-light hover:text-white ${
|
||||
visible ? "mobile-sidebar-close-btn-open" : "mobile-sidebar-close-btn-close"
|
||||
}`}
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<aside
|
||||
className={`absolute right-0 top-0 flex h-full w-72 max-w-[85vw] flex-col border-l border-gray-800 bg-surface shadow-2xl ${
|
||||
visible ? "mobile-sidebar-panel-open" : "mobile-sidebar-panel-close"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`border-b border-gray-800 px-5 py-5 transition-all duration-300 ease-out ${
|
||||
visible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
|
||||
}`}
|
||||
style={{ transitionDelay: visible ? "100ms" : "0ms" }}
|
||||
>
|
||||
<span className="text-sm font-semibold uppercase tracking-wider text-gray-400">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||
{Children.map(children, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`transition-all duration-300 ease-out ${
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"
|
||||
}`}
|
||||
style={{
|
||||
transitionDelay: visible ? `${ITEM_BASE_MS + index * 50}ms` : "0ms",
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileMenuButton({
|
||||
onClick,
|
||||
open = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
open?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
className="relative rounded p-2 text-gray-300 transition-all duration-300 hover:scale-105 hover:bg-white/10 hover:text-white active:scale-95 sm:hidden"
|
||||
>
|
||||
<span className="relative block h-6 w-6">
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "top-[11px] rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[11px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "scale-x-0 opacity-0" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[17px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "top-[11px] -rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarLinkClass(active = false) {
|
||||
return `block rounded-lg px-4 py-3 text-base font-medium transition-all duration-200 hover:translate-x-0.5 ${
|
||||
active ? "bg-surface-light text-white" : "text-gray-300 hover:bg-surface-light hover:text-white"
|
||||
}`;
|
||||
}
|
||||
|
||||
export function dashboardSidebarLinkClass(active = false, danger = false) {
|
||||
const classes = ["mobile-sidebar-link"];
|
||||
if (active) classes.push("mobile-sidebar-link-active");
|
||||
if (danger) classes.push("mobile-sidebar-link-danger");
|
||||
return classes.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
type ExtensionRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
adminNote: string | null;
|
||||
};
|
||||
|
||||
type ExtensionUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
requests: ExtensionRequest[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialUsage: ExtensionUsage;
|
||||
};
|
||||
|
||||
export function PlanBillingActions({ initialUsage }: Props) {
|
||||
const router = useRouter();
|
||||
const [usage, setUsage] = useState(initialUsage);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
async function handleCancelPlan() {
|
||||
if (
|
||||
!confirm(
|
||||
"Cancel your Pro plan? You will be moved to the free plan immediately and lose Pro benefits.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCancelling(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/cancel-subscription", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaRequest() {
|
||||
if (usage.remaining <= 0) return;
|
||||
|
||||
const reason = prompt(
|
||||
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
|
||||
);
|
||||
if (reason === null) return;
|
||||
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/quota-extension-request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to submit request");
|
||||
|
||||
setUsage({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
remaining: data.remaining,
|
||||
requests: data.requests ?? usage.requests,
|
||||
});
|
||||
setSuccess(
|
||||
`Request submitted. ${data.used} of ${data.limit} extension requests used this year. Support will process it shortly.`,
|
||||
);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasPending = usage.requests.some((r) => r.status === "PENDING");
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
|
||||
<p className="text-gray-300">
|
||||
Extension requests this year:{" "}
|
||||
<span className="font-medium text-white">
|
||||
{usage.used} / {usage.limit}
|
||||
</span>
|
||||
{usage.remaining > 0 ? (
|
||||
<span className="text-gray-500"> · {usage.remaining} remaining</span>
|
||||
) : (
|
||||
<span className="text-yellow-400"> · limit reached</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelPlan}
|
||||
disabled={cancelling}
|
||||
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? "Cancelling…" : "Cancel your plan"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuotaRequest}
|
||||
disabled={requesting || usage.remaining <= 0 || hasPending}
|
||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{requesting
|
||||
? "Submitting…"
|
||||
: hasPending
|
||||
? "Request pending…"
|
||||
: "Request quota reset & extension"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{usage.requests.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Request history
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{usage.requests.slice(0, 5).map((req) => (
|
||||
<li
|
||||
key={req.id}
|
||||
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-300">
|
||||
{new Date(req.requestedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
req.status === "APPROVED"
|
||||
? "text-green-400"
|
||||
: req.status === "REJECTED"
|
||||
? "text-red-400"
|
||||
: "text-yellow-400"
|
||||
}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
|
||||
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "}
|
||||
<a href="/terms" className="text-gray-400 underline hover:text-white">
|
||||
Terms of Service
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { UpgradeProLink } from "./UpgradeProLink";
|
||||
|
||||
type Playlist = {
|
||||
id: string;
|
||||
title: string;
|
||||
itemCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (playlistId: string) => void;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [privacy, setPrivacy] = useState<"public" | "unlisted" | "private">("private");
|
||||
|
||||
const loadPlaylists = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/youtube/playlists");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load playlists");
|
||||
setPlaylists(data.playlists ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load playlists");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void loadPlaylists();
|
||||
}, [enabled, loadPlaylists]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!title.trim()) {
|
||||
setError("Playlist title is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/youtube/playlists", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
privacy,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to create playlist");
|
||||
|
||||
const playlist = data.playlist as Playlist;
|
||||
setPlaylists((prev) => [playlist, ...prev]);
|
||||
onChange(playlist.id);
|
||||
setShowCreate(false);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPrivacy("private");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create playlist");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<p className="text-sm text-gray-400">
|
||||
Add uploaded videos to a YouTube playlist with{" "}
|
||||
<UpgradeProLink className="text-accent hover:underline" />.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-gray-400">Add to YouTube playlist (optional)</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={loading || creating}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="">None (don’t add to a playlist)</option>
|
||||
{playlists.map((playlist) => (
|
||||
<option key={playlist.id} value={playlist.id}>
|
||||
{playlist.title} ({playlist.itemCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate((prev) => !prev)}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
{showCreate ? "Cancel" : "Create new playlist"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadPlaylists()}
|
||||
disabled={loading}
|
||||
className="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Refresh list
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="space-y-3 rounded border border-gray-700 bg-surface-dark/60 p-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">Playlist title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
className="input-field w-full"
|
||||
placeholder="My Songs2YT uploads"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
className="input-field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">Privacy</label>
|
||||
<select
|
||||
value={privacy}
|
||||
onChange={(e) => setPrivacy(e.target.value as typeof privacy)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="private">Private</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !title.trim()}
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{creating ? "Creating…" : "Create & select"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-xs text-gray-500">Loading playlists…</p>}
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{!loading && !error && playlists.length === 0 && !showCreate && (
|
||||
<p className="text-xs text-gray-500">
|
||||
No playlists yet. Create one above. If playlist access was just added, sign out and sign
|
||||
in again to grant the new Google permission.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { SALES_EMAIL } from "@/lib/plans";
|
||||
|
||||
const FEATURE_LABELS = [
|
||||
"Infrastructure",
|
||||
"Limit",
|
||||
"Batch mode",
|
||||
"File type",
|
||||
"Playlists",
|
||||
"API support",
|
||||
"ID3 tags",
|
||||
"Support",
|
||||
"Watermark",
|
||||
] as const;
|
||||
|
||||
type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>;
|
||||
|
||||
type InfrastructureChip = "cloud" | "self-hosted";
|
||||
|
||||
type PlanConfig = {
|
||||
name: string;
|
||||
badge: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
features: PlanFeatures;
|
||||
infrastructureChips: InfrastructureChip[];
|
||||
watermarkNote?: string;
|
||||
cta:
|
||||
| { type: "signin" }
|
||||
| { type: "link"; label: string; href: string }
|
||||
| { type: "disabled"; label: string }
|
||||
| { type: "mailto"; label: string; email: string; subject: string };
|
||||
highlighted: boolean;
|
||||
hover: string;
|
||||
accent: string;
|
||||
badgeClass: string;
|
||||
ctaClass?: string;
|
||||
};
|
||||
|
||||
const PLANS: PlanConfig[] = [
|
||||
{
|
||||
name: "Bedroom Producer",
|
||||
badge: "Free",
|
||||
price: "€0",
|
||||
priceNote: "/ forever",
|
||||
features: {
|
||||
Infrastructure: "Cloud",
|
||||
Limit: "14 videos / 12 hours · 720p",
|
||||
"Batch mode": "Limited batch · up to 3 files",
|
||||
"File type": "MP3",
|
||||
Playlists: "Not included",
|
||||
"API support": "Not included",
|
||||
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
|
||||
Support: "Community",
|
||||
Watermark: "Optional · support badge",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
watermarkNote:
|
||||
"Show \"Uploaded through Songs2YT.com\" to support open-source development - opt out anytime for a clean video.",
|
||||
cta: { type: "signin" },
|
||||
highlighted: false,
|
||||
hover:
|
||||
"hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15",
|
||||
accent: "text-red-400",
|
||||
badgeClass: "bg-gray-800 text-gray-400",
|
||||
},
|
||||
{
|
||||
name: "Independent Artist",
|
||||
badge: "Pro",
|
||||
price: "€5",
|
||||
priceNote: "/ mo",
|
||||
features: {
|
||||
Infrastructure: "Cloud",
|
||||
Limit: "50 videos* / month · 1080p",
|
||||
"Batch mode": "Full batch · up to 5 files",
|
||||
"File type": "MP3 / WAV / FLAC",
|
||||
Playlists: "Create & add uploads to YouTube playlists",
|
||||
"API support": "REST API · upload, batch & playlists",
|
||||
"ID3 tags": "Extended metadata support",
|
||||
Support: "E-Mail",
|
||||
Watermark: "Fully customizable · remove completely",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
cta: { type: "disabled", label: "Coming soon" },
|
||||
highlighted: true,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
accent: "text-accent",
|
||||
badgeClass: "bg-accent/15 text-accent",
|
||||
},
|
||||
{
|
||||
name: "Record Label / Studio",
|
||||
badge: "Enterprise",
|
||||
price: "Custom",
|
||||
priceNote: "/ contact sales",
|
||||
features: {
|
||||
Infrastructure: "Cloud or self-hosted",
|
||||
Limit: "Unlimited 4K · zero limit",
|
||||
"Batch mode": "Unlimited synchronized batch processing",
|
||||
"File type": "WAV / FLAC / lossless",
|
||||
Playlists: "Org-wide playlist workflows",
|
||||
"API support": "Full API access · custom integrations & SLAs",
|
||||
"ID3 tags": "Full metadata · custom mapping",
|
||||
Support: "Top-priority**",
|
||||
Watermark: "Fully customizable · remove completely",
|
||||
},
|
||||
infrastructureChips: ["cloud", "self-hosted"],
|
||||
cta: {
|
||||
type: "mailto",
|
||||
label: "Contact Sales",
|
||||
email: SALES_EMAIL,
|
||||
subject: "Songs2YT Enterprise Inquiry",
|
||||
},
|
||||
highlighted: false,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
accent: "text-[#609926]",
|
||||
badgeClass: "bg-[#609926]/15 text-[#609926]",
|
||||
ctaClass:
|
||||
"border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20",
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureValue({ value }: { value: string }) {
|
||||
return <span className="text-sm text-gray-200">{value}</span>;
|
||||
}
|
||||
|
||||
function CloudIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="5" width="16" height="6" rx="2" />
|
||||
<rect x="4" y="13" width="16" height="6" rx="2" />
|
||||
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureChips({
|
||||
chips,
|
||||
}: {
|
||||
chips: InfrastructureChip[];
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{chips.includes("cloud") && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
||||
<CloudIcon className="h-4 w-4 text-gray-300" />
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
{chips.includes("self-hosted") && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
||||
<ServerIcon className="h-4 w-4 text-gray-300" />
|
||||
Self-hosted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingCard({ plan }: { plan: PlanConfig }) {
|
||||
const {
|
||||
name,
|
||||
badge,
|
||||
price,
|
||||
priceNote,
|
||||
features,
|
||||
watermarkNote,
|
||||
infrastructureChips,
|
||||
cta,
|
||||
highlighted,
|
||||
hover,
|
||||
accent,
|
||||
badgeClass,
|
||||
ctaClass,
|
||||
} = plan;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
|
||||
highlighted
|
||||
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
|
||||
: "border-gray-700/50"
|
||||
} ${hover}`}
|
||||
>
|
||||
{highlighted && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{name}</h3>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-1">
|
||||
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
|
||||
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
|
||||
{FEATURE_LABELS.map((label) => (
|
||||
<li key={label}>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
||||
{label === "Infrastructure" ? (
|
||||
<InfrastructureChips chips={infrastructureChips} />
|
||||
) : (
|
||||
<FeatureValue value={features[label]} />
|
||||
)}
|
||||
{label === "Watermark" && watermarkNote && (
|
||||
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
|
||||
{watermarkNote}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
{cta.type === "signin" && <SignInButton fullWidth />}
|
||||
{cta.type === "link" && (
|
||||
<Link
|
||||
href={cta.href}
|
||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
|
||||
>
|
||||
{cta.label}
|
||||
</Link>
|
||||
)}
|
||||
{cta.type === "disabled" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
{cta.type === "mailto" && (
|
||||
<a
|
||||
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
|
||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
|
||||
>
|
||||
{cta.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="pricing"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Free for getting started, Pro for creators, or Enterprise for teams.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{PLANS.map((plan, index) => (
|
||||
<ScrollReveal key={plan.name} delay={index * 100}>
|
||||
<PricingCard plan={plan} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={360}>
|
||||
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
|
||||
*Pro is a monthly subscription with included quota (extensions via support). Self-hosted
|
||||
open-source deployments have no quotas.
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
|
||||
Technical support is strictly reserved for Managed Cloud and paid Professional Setup
|
||||
agreements; independent self-hosted deployments are community-supported.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Privacy } from "@prisma/client";
|
||||
|
||||
type Props = {
|
||||
value: Privacy;
|
||||
onChange: (value: Privacy) => void;
|
||||
};
|
||||
|
||||
const OPTIONS: { value: Privacy; label: string }[] = [
|
||||
{ value: "PUBLIC", label: "Public" },
|
||||
{ value: "PRIVATE", label: "Private" },
|
||||
{ value: "UNLISTED", label: "Unlisted" },
|
||||
];
|
||||
|
||||
export function PrivacyToggle({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex rounded border border-gray-600 overflow-hidden">
|
||||
{OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`flex-1 px-3 py-2 text-sm transition-colors ${
|
||||
value === opt.value
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-light text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
||||
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
||||
|
||||
/** Shows a dashboard alert when a recent job hit YouTube's upload limit. */
|
||||
export function RecentYouTubeLimitAlert() {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
fetch("/api/jobs?limit=10")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const hit = (data.jobs ?? []).some((job: { items?: Array<{ status: string; error?: string | null }> }) =>
|
||||
(job.items ?? []).some(
|
||||
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
|
||||
),
|
||||
);
|
||||
if (active) setShow(hit);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!show) return null;
|
||||
return <YouTubeLimitBanner className="mb-6" />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolutionsForPlan } from "@/lib/plans";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
plan?: Plan;
|
||||
};
|
||||
|
||||
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
|
||||
const resolutions = getResolutionsForPlan(plan);
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{resolutions.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Direction = "up" | "left" | "right";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
direction?: Direction;
|
||||
};
|
||||
|
||||
const OFFSET: Record<Direction, string> = {
|
||||
up: "translate-y-10",
|
||||
left: "-translate-x-10",
|
||||
right: "translate-x-10",
|
||||
};
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
direction = "up",
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${className} ${
|
||||
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
|
||||
}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
type Props = {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
title: string;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) return;
|
||||
|
||||
const update = () => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const progress = Math.min(
|
||||
1,
|
||||
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
|
||||
);
|
||||
setOffsetX(progress * offset);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [sectionRef, offset]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
|
||||
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
large?: boolean;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export function SignInButton({ large, fullWidth }: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
|
||||
className={`inline-flex items-center justify-center gap-3 rounded border border-gray-300 bg-white font-medium text-gray-800 transition-all duration-300 hover:scale-[1.02] hover:bg-gray-50 hover:shadow-lg hover:shadow-white/10 ${
|
||||
large ? "px-8 py-3 text-base" : "px-4 py-2 text-sm"
|
||||
} ${fullWidth ? "w-full" : ""}`}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:bg-surface-light"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
|
||||
function LayersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SlidersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
|
||||
<circle cx="4" cy="14" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="20" cy="16" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudUploadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
|
||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Single/Batch creation",
|
||||
desc: "One image, many audios, each becomes a separate video.",
|
||||
Icon: LayersIcon,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Per-video settings",
|
||||
desc: "Title, tags, privacy, and category for every upload.",
|
||||
Icon: SlidersIcon,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Direct upload",
|
||||
desc: "Connect YouTube once and publish automatically.",
|
||||
Icon: CloudUploadIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppMockup() {
|
||||
const phaseOneMs = 3000;
|
||||
const phaseTwoMs = 2000;
|
||||
const initialWidth = "20%";
|
||||
const midWidth = "45%";
|
||||
const { ref, inView } = useInView();
|
||||
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
|
||||
</div>
|
||||
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-400">
|
||||
{processed ? (
|
||||
"Processed"
|
||||
) : (
|
||||
<>
|
||||
Processing
|
||||
<span className="mockup-dot-1">.</span>
|
||||
<span className="mockup-dot-2">.</span>
|
||||
<span className="mockup-dot-3">.</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
className="relative h-full rounded-full bg-white/90 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
>
|
||||
{!processed && (
|
||||
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
number,
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof LayersIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
|
||||
<span
|
||||
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepsSection() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<ScrollReveal direction="left">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
|
||||
Upload your content in 3 simple steps:
|
||||
</h2>
|
||||
<p className="mt-6 text-base leading-relaxed text-gray-400">
|
||||
From a single track to a full batch, Songs2YT walks you through creating and
|
||||
publishing videos to YouTube without touching a video editor.
|
||||
</p>
|
||||
<AppMockup />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="flex flex-col gap-7">
|
||||
{STEPS.map((step, index) => (
|
||||
<ScrollReveal key={step.number} delay={index * 120} direction="right">
|
||||
<StepCard
|
||||
number={step.number}
|
||||
title={step.title}
|
||||
desc={step.desc}
|
||||
Icon={step.Icon}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
|
||||
|
||||
type SupportCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
external?: boolean;
|
||||
hover: string;
|
||||
titleHover: string;
|
||||
linkClass: string;
|
||||
linkHover: string;
|
||||
};
|
||||
|
||||
function SupportCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
cta,
|
||||
external,
|
||||
hover,
|
||||
titleHover,
|
||||
linkClass,
|
||||
linkHover,
|
||||
}: SupportCardProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
|
||||
>
|
||||
{cta}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const SUPPORT_CARDS: SupportCardProps[] = [
|
||||
{
|
||||
title: "🛠️ Community & Self-Hosting",
|
||||
description:
|
||||
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
|
||||
href: GITEA_ISSUES_URL,
|
||||
cta: "Open Gitea Issues",
|
||||
external: true,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
linkClass: "text-[#609926]",
|
||||
linkHover: "group-hover:text-[#7ab33a]",
|
||||
},
|
||||
{
|
||||
title: "📩 Billing & Premium Support",
|
||||
description:
|
||||
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
|
||||
href: `mailto:${SUPPORT_EMAIL}`,
|
||||
cta: SUPPORT_EMAIL,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
titleHover: "group-hover:text-accent",
|
||||
linkClass: "text-accent",
|
||||
linkHover: "group-hover:text-accent-hover",
|
||||
},
|
||||
];
|
||||
|
||||
export function SupportSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="support"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Community help for self-hosters, or reach us directly for billing and premium support.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
|
||||
{SUPPORT_CARDS.map((card, index) => (
|
||||
<ScrollReveal
|
||||
key={card.title}
|
||||
direction={index === 0 ? "left" : "right"}
|
||||
delay={index * 120}
|
||||
>
|
||||
<SupportCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { UPGRADE_URL } from "@/lib/plans";
|
||||
|
||||
const PRO_UPGRADE_SUFFIX = " Upload up to 50 videos with Pro!";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) {
|
||||
return (
|
||||
<Link href={UPGRADE_URL} className={className}>
|
||||
{children ?? "Upgrade to Pro"}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaErrorMessage({ message }: { message: string }) {
|
||||
if (message.endsWith(PRO_UPGRADE_SUFFIX)) {
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "}
|
||||
<UpgradeProLink className="font-medium text-red-200 underline hover:text-white">
|
||||
Upload up to 50 videos with Pro!
|
||||
</UpgradeProLink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{message}</>;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||
import type { ItemMetadata } from "@/lib/types";
|
||||
import { CategorySelect } from "./CategorySelect";
|
||||
import { PlaylistSelect } from "./PlaylistSelect";
|
||||
import { PrivacyToggle } from "./PrivacyToggle";
|
||||
import { ResolutionSelect } from "./ResolutionSelect";
|
||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
||||
|
||||
const UPLOAD_CONCURRENCY = 4;
|
||||
|
||||
type AudioItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
path: string | null;
|
||||
uploading: boolean;
|
||||
metadata: ItemMetadata;
|
||||
};
|
||||
|
||||
function defaultMetadata(title = ""): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC" as Privacy,
|
||||
categoryId: "10",
|
||||
resolution: "1280x720",
|
||||
notifySubscribers: true,
|
||||
madeForKids: false,
|
||||
embeddable: true,
|
||||
creativeCommons: false,
|
||||
includeWatermark: true,
|
||||
playlistId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function UploadForm() {
|
||||
const router = useRouter();
|
||||
const uploadSessionRef = useRef<string>(crypto.randomUUID());
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playlistId, setPlaylistId] = useState("");
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
plan: Plan;
|
||||
maxBatchSize: number;
|
||||
resetsIn: string;
|
||||
videoCredits: number;
|
||||
totalAvailable: number;
|
||||
used: number;
|
||||
selfHosted?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
const res = await fetch("/api/quota");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuota({
|
||||
remaining: data.remaining,
|
||||
limit: data.limit,
|
||||
plan: data.plan,
|
||||
maxBatchSize: data.maxBatchSize,
|
||||
resetsIn: data.resetsIn,
|
||||
videoCredits: data.videoCredits ?? 0,
|
||||
totalAvailable: data.totalAvailable ?? data.remaining,
|
||||
used: data.used ?? 0,
|
||||
selfHosted: Boolean(data.selfHosted),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuota();
|
||||
}, [loadQuota]);
|
||||
|
||||
async function uploadFile(
|
||||
file: File,
|
||||
type: "image" | "audio",
|
||||
): Promise<{ path: string; audioTags?: Parameters<typeof audioTagsToMetadata>[0] | null }> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
formData.append("session", uploadSessionRef.current);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
return { path: data.path, audioTags: data.audioTags };
|
||||
}
|
||||
|
||||
async function uploadFilesWithConcurrency(files: File[]) {
|
||||
const pending = files.map((file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const autoTitle = filenameWithoutExtension(file.name);
|
||||
|
||||
setAudioItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
file,
|
||||
path: null,
|
||||
uploading: true,
|
||||
metadata: {
|
||||
...defaultMetadata(autoTitle),
|
||||
playlistId: playlistId || null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return { id, file, autoTitle };
|
||||
});
|
||||
|
||||
for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) {
|
||||
const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(async ({ id, file, autoTitle }) => {
|
||||
const upload = await uploadFile(file, "audio");
|
||||
const tagMetadata = upload.audioTags
|
||||
? audioTagsToMetadata(upload.audioTags, autoTitle)
|
||||
: null;
|
||||
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
path: upload.path,
|
||||
uploading: false,
|
||||
metadata: tagMetadata
|
||||
? { ...item.metadata, ...tagMetadata }
|
||||
: item.metadata,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
if (results[j].status === "rejected") {
|
||||
const { id } = chunk[j];
|
||||
const reason = results[j] as PromiseRejectedResult;
|
||||
setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed");
|
||||
setAudioItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setImageFile(file);
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const path = await uploadFile(file, "image");
|
||||
setImagePath(path.path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Image upload failed");
|
||||
setImageFile(null);
|
||||
setImagePath(null);
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAudioChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setError(null);
|
||||
|
||||
const maxBatch = quota?.maxBatchSize ?? 3;
|
||||
if (audioItems.length + files.length > maxBatch) {
|
||||
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFilesWithConcurrency(files);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function updateItemMetadata(id: string, updates: Partial<ItemMetadata>) {
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, metadata: { ...item.metadata, ...updates } }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePlaylistChange(nextPlaylistId: string) {
|
||||
setPlaylistId(nextPlaylistId);
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
playlistId: nextPlaylistId || null,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function removeAudioItem(id: string) {
|
||||
setAudioItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
const readyAudios = audioItems.filter((a) => a.path && !a.uploading);
|
||||
const canSubmit =
|
||||
imagePath &&
|
||||
!imageUploading &&
|
||||
readyAudios.length > 0 &&
|
||||
readyAudios.every((a) => a.metadata.title.trim()) &&
|
||||
!submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imagePath,
|
||||
items: readyAudios.map((item) => ({
|
||||
audioPath: item.path!,
|
||||
audioFilename: item.file.name,
|
||||
metadata: item.metadata,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to create job");
|
||||
|
||||
router.push(`/jobs/${data.jobId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Submission failed");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
{quota.selfHosted ? (
|
||||
<>
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
||||
batch
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining} of {quota.limit} plan videos remaining
|
||||
{quota.plan === "FREE" && quota.videoCredits > 0
|
||||
? ` · ${quota.videoCredits} pay-as-you-go credits`
|
||||
: ""}{" "}
|
||||
·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}{" "}
|
||||
· up to {quota.maxBatchSize} files per batch
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
<QuotaErrorMessage message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Files</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Image</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<StatusDot ok={!!imagePath && !imageUploading} />
|
||||
<span className="text-gray-400">
|
||||
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||||
<input
|
||||
type="file"
|
||||
accept={
|
||||
quota?.selfHosted || quota?.plan === "PREMIUM"
|
||||
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||
: "audio/mpeg,.mp3"
|
||||
}
|
||||
multiple
|
||||
onChange={handleAudioChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<StatusDot ok={readyAudios.length > 0} />
|
||||
<span className="text-gray-400">
|
||||
{audioItems.length === 0
|
||||
? "No audio files selected"
|
||||
: `${readyAudios.length} of ${audioItems.length} ready`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{audioItems.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Each audio file gets its own metadata. Title is auto-filled from the filename.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<PlaylistSelect
|
||||
value={playlistId}
|
||||
onChange={handlePlaylistChange}
|
||||
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{audioItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-white">
|
||||
Video {index + 1}: {item.file.name}
|
||||
</h3>
|
||||
{item.uploading && (
|
||||
<p className="text-xs text-yellow-400">Uploading…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAudioItem(item.id)}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Title">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.title}
|
||||
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
onChange={(v) => updateItemMetadata(item.id, { categoryId: v })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<textarea
|
||||
value={item.metadata.description}
|
||||
onChange={(e) => updateItemMetadata(item.id, { description: e.target.value })}
|
||||
rows={3}
|
||||
className="input-field resize-y"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tags">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.tags}
|
||||
onChange={(e) => updateItemMetadata(item.id, { tags: e.target.value })}
|
||||
placeholder='Separate with spaces or commas. Use "quoted phrases" for multi-word tags.'
|
||||
className="input-field"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Privacy">
|
||||
<PrivacyToggle
|
||||
value={item.metadata.privacy}
|
||||
onChange={(v) => updateItemMetadata(item.id, { privacy: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Video size">
|
||||
<ResolutionSelect
|
||||
value={item.metadata.resolution}
|
||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||
plan={quota?.plan}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Checkbox
|
||||
label="Notify subscribers about this upload"
|
||||
checked={item.metadata.notifySubscribers}
|
||||
onChange={(v) => updateItemMetadata(item.id, { notifySubscribers: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Made For Kids?"
|
||||
checked={item.metadata.madeForKids}
|
||||
onChange={(v) => updateItemMetadata(item.id, { madeForKids: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Embeddable?"
|
||||
checked={item.metadata.embeddable}
|
||||
onChange={(v) => updateItemMetadata(item.id, { embeddable: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Creative Commons?"
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show 'Uploaded through Songs2YT.com' watermark (support open-source)"
|
||||
checked={item.metadata.includeWatermark}
|
||||
onChange={(v) => updateItemMetadata(item.id, { includeWatermark: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Watermark is optional on the free plan - turn it off anytime for a clean video.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
<UpgradeProLink className="text-accent hover:underline" /> for 50 videos/month, 1080p,
|
||||
lossless audio, and full watermark control.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded bg-accent px-6 py-3 font-medium text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? "Creating videos…"
|
||||
: `Create ${readyAudios.length || ""} Video${readyAudios.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ ok }: { ok: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={`flex items-center gap-2 text-sm text-gray-300 ${disabled ? "opacity-60" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="rounded border-gray-600 bg-surface-light text-accent focus:ring-accent"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE } from "@/lib/youtube/errors";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function YouTubeLimitBanner({ className = "" }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100 ${className}`}
|
||||
>
|
||||
<p className="font-medium text-amber-200">YouTube upload limit reached</p>
|
||||
<p className="mt-1 text-amber-100/90">{YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Local infra only (Postgres + Redis). Use root docker-compose.yml for full stack.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,74 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: s2yt
|
||||
POSTGRES_PASSWORD: s2yt
|
||||
POSTGRES_DB: s2yt
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${S2YT_PORT:-3000}:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
S2YT_EDITION: selfhosted
|
||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "npx prisma db push && node server.js"
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
S2YT_EDITION: selfhosted
|
||||
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: ["npx", "tsx", "worker/index.ts"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
uploads_data:
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Options = {
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
export function useInView<T extends HTMLElement = HTMLDivElement>({
|
||||
threshold = 0.15,
|
||||
rootMargin = "0px 0px -40px 0px",
|
||||
once = true,
|
||||
}: Options = {}) {
|
||||
const ref = useRef<T>(null);
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true);
|
||||
if (once) observer.unobserve(el);
|
||||
} else if (!once) {
|
||||
setInView(false);
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold, rootMargin, once]);
|
||||
|
||||
return { ref, inView };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMockupProgress(
|
||||
active: boolean,
|
||||
phaseOneMs = 3000,
|
||||
phaseTwoMs = 2000,
|
||||
) {
|
||||
const [phase, setPhase] = useState<0 | 1 | 2>(0);
|
||||
const [processed, setProcessed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const startFrame = requestAnimationFrame(() => setPhase(1));
|
||||
const midTimer = setTimeout(() => setPhase(2), phaseOneMs);
|
||||
const doneTimer = setTimeout(() => setProcessed(true), phaseOneMs + phaseTwoMs);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(startFrame);
|
||||
clearTimeout(midTimer);
|
||||
clearTimeout(doneTimer);
|
||||
};
|
||||
}, [active, phaseOneMs, phaseTwoMs]);
|
||||
|
||||
const transitionMs = phase === 1 ? phaseOneMs : phase === 2 ? phaseTwoMs : 0;
|
||||
|
||||
return { phase, processed, transitionMs };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { findUserByApiKey } from "./api-keys";
|
||||
import { checkApiRateLimit } from "./api-rate-limit";
|
||||
import { hasProFeatures } from "./edition";
|
||||
import { getSessionUser } from "./session";
|
||||
|
||||
function extractBearerToken(req: NextRequest) {
|
||||
const auth = req.headers.get("authorization");
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
|
||||
export async function requirePaidApiUser(req: NextRequest) {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "Missing API key. Use Authorization: Bearer <your_api_key>" },
|
||||
{ status: 401 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await findUserByApiKey(token);
|
||||
if (!user) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Invalid API key" }, { status: 401 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access requires an active Pro subscription" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "YouTube account not connected. Sign in via the dashboard first." },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
const rateLimit = await checkApiRateLimit(user.id);
|
||||
if (!rateLimit.ok) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{
|
||||
error: "API rate limit exceeded. Try again shortly.",
|
||||
retryAfterSeconds: rateLimit.retryAfterSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
|
||||
},
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
|
||||
const apiResult = await requirePaidApiUser(req);
|
||||
if (apiResult.user) return apiResult;
|
||||
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
return apiResult.error
|
||||
? apiResult
|
||||
: {
|
||||
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasProFeatures(sessionUser.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API access requires an active Pro subscription" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!sessionUser.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: null, user: sessionUser };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { prisma } from "./db";
|
||||
|
||||
const API_KEY_PREFIX = "s2yt_live_";
|
||||
|
||||
export function hashApiKey(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function generateApiKeyMaterial() {
|
||||
const secret = randomBytes(24).toString("base64url");
|
||||
const token = `${API_KEY_PREFIX}${secret}`;
|
||||
return {
|
||||
token,
|
||||
hash: hashApiKey(token),
|
||||
prefix: token.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUserApiKey(userId: string) {
|
||||
const { token, hash, prefix } = generateApiKeyMaterial();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
apiKeyHash: hash,
|
||||
apiKeyPrefix: prefix,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, prefix };
|
||||
}
|
||||
|
||||
export async function revokeUserApiKey(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
apiKeyHash: null,
|
||||
apiKeyPrefix: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserApiKeyStatus(userId: string) {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { apiKeyPrefix: true, apiKeyHash: true },
|
||||
});
|
||||
|
||||
return {
|
||||
configured: Boolean(user.apiKeyHash),
|
||||
prefix: user.apiKeyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findUserByApiKey(token: string) {
|
||||
if (!token.startsWith(API_KEY_PREFIX)) return null;
|
||||
|
||||
const hash = hashApiKey(token);
|
||||
return prisma.user.findFirst({
|
||||
where: { apiKeyHash: hash },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import IORedis from "ioredis";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export const DEFAULT_API_RATE_LIMIT = 60;
|
||||
export const API_RATE_WINDOW_SECONDS = 60;
|
||||
export const MAX_ADMIN_API_RATE_BONUS = 120;
|
||||
const SELFHOSTED_API_RATE_LIMIT = 100_000;
|
||||
|
||||
type MemoryBucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const memoryBuckets = new Map<string, MemoryBucket>();
|
||||
let redis: IORedis | null | undefined;
|
||||
|
||||
function getRedis() {
|
||||
if (redis !== undefined) return redis;
|
||||
try {
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
redis = new IORedis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
return redis;
|
||||
} catch {
|
||||
redis = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rateKey(userId: string) {
|
||||
return `s2yt:api-rate:${userId}`;
|
||||
}
|
||||
|
||||
export async function getEffectiveApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { apiRateLimitBonus: true, plan: true },
|
||||
});
|
||||
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
|
||||
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
|
||||
}
|
||||
|
||||
function memoryStatus(userId: string, limit: number) {
|
||||
const now = Date.now();
|
||||
const bucket = memoryBuckets.get(userId);
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
return {
|
||||
limit,
|
||||
used: 0,
|
||||
remaining: limit,
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: API_RATE_WINDOW_SECONDS,
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
}
|
||||
const used = Math.min(bucket.count, limit);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApiRateLimitStatus(userId: string) {
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return memoryStatus(userId, limit);
|
||||
|
||||
try {
|
||||
if (client.status !== "ready") {
|
||||
await client.connect().catch(() => {});
|
||||
}
|
||||
const key = rateKey(userId);
|
||||
const [countRaw, ttl] = await Promise.all([client.get(key), client.ttl(key)]);
|
||||
const used = Math.min(Number(countRaw || 0), limit);
|
||||
return {
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(0, limit - used),
|
||||
windowSeconds: API_RATE_WINDOW_SECONDS,
|
||||
resetsInSeconds: ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS,
|
||||
bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT),
|
||||
};
|
||||
} catch {
|
||||
return { ...memoryStatus(userId, limit), bonus: Math.max(0, limit - DEFAULT_API_RATE_LIMIT) };
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemoryRateLimit(userId: string, limit: number) {
|
||||
const now = Date.now();
|
||||
const bucket = memoryBuckets.get(userId);
|
||||
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
memoryBuckets.set(userId, {
|
||||
count: 1,
|
||||
resetAt: now + API_RATE_WINDOW_SECONDS * 1000,
|
||||
});
|
||||
return { ok: true as const, limit, remaining: limit - 1 };
|
||||
}
|
||||
|
||||
if (bucket.count >= limit) {
|
||||
return {
|
||||
ok: false as const,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
limit,
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
bucket.count += 1;
|
||||
return { ok: true as const, limit, remaining: Math.max(0, limit - bucket.count) };
|
||||
}
|
||||
|
||||
export async function checkApiRateLimit(userId: string) {
|
||||
if (isSelfHostedEdition()) {
|
||||
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
|
||||
}
|
||||
const limit = await getEffectiveApiRateLimit(userId);
|
||||
const client = getRedis();
|
||||
if (!client) return checkMemoryRateLimit(userId, limit);
|
||||
|
||||
try {
|
||||
if (client.status !== "ready") {
|
||||
await client.connect().catch(() => {});
|
||||
}
|
||||
|
||||
const key = rateKey(userId);
|
||||
const count = await client.incr(key);
|
||||
if (count === 1) {
|
||||
await client.expire(key, API_RATE_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
if (count > limit) {
|
||||
const ttl = await client.ttl(key);
|
||||
return {
|
||||
ok: false as const,
|
||||
retryAfterSeconds: Math.max(1, ttl > 0 ? ttl : API_RATE_WINDOW_SECONDS),
|
||||
limit,
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, limit, remaining: Math.max(0, limit - count) };
|
||||
} catch {
|
||||
return checkMemoryRateLimit(userId, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { parseFile } from "music-metadata";
|
||||
import type { ItemMetadata } from "./types";
|
||||
|
||||
export type AudioTagMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
genre?: string;
|
||||
year?: string;
|
||||
};
|
||||
|
||||
export async function readAudioTags(filePath: string): Promise<AudioTagMetadata | null> {
|
||||
try {
|
||||
const { common } = await parseFile(filePath);
|
||||
if (!common.title && !common.artist && !common.album && !common.genre?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: common.title,
|
||||
artist: common.artist,
|
||||
album: common.album,
|
||||
genre: common.genre?.join(", "),
|
||||
year: common.year?.toString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function audioTagsToMetadata(
|
||||
tags: AudioTagMetadata,
|
||||
fallbackTitle: string,
|
||||
): Pick<ItemMetadata, "title" | "description" | "tags"> {
|
||||
const title =
|
||||
tags.title ||
|
||||
(tags.artist ? `${tags.artist}${tags.album ? ` - ${tags.album}` : ""}` : fallbackTitle);
|
||||
|
||||
const description = [tags.artist, tags.album, tags.year].filter(Boolean).join(" · ");
|
||||
const tagParts = [tags.genre, tags.artist].filter(Boolean);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
tags: tagParts.join(", "),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { encryptSecret } from "./crypto/secrets";
|
||||
import { prisma } from "./db";
|
||||
import { getInitialQuotaResetAt } from "./quota";
|
||||
import { fetchYouTubeChannel } from "./youtube/upload";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
authorization: {
|
||||
params: {
|
||||
prompt: "consent",
|
||||
access_type: "offline",
|
||||
response_type: "code",
|
||||
scope: [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/youtube.upload",
|
||||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
// Required to add uploaded videos to playlists
|
||||
"https://www.googleapis.com/auth/youtube.force-ssl",
|
||||
].join(" "),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
if (!account?.access_token || !user.email) return false;
|
||||
|
||||
const dbUser = await prisma.user.upsert({
|
||||
where: { email: user.email },
|
||||
create: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
quotaResetAt: getInitialQuotaResetAt(),
|
||||
},
|
||||
update: {
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
},
|
||||
});
|
||||
|
||||
if (account.refresh_token) {
|
||||
const channel = await fetchYouTubeChannel(
|
||||
account.access_token,
|
||||
account.refresh_token,
|
||||
);
|
||||
|
||||
const accessToken = encryptSecret(account.access_token);
|
||||
const refreshToken = encryptSecret(account.refresh_token);
|
||||
|
||||
await prisma.youTubeConnection.upsert({
|
||||
where: { userId: dbUser.id },
|
||||
create: {
|
||||
userId: dbUser.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt: account.expires_at
|
||||
? new Date(account.expires_at * 1000)
|
||||
: new Date(Date.now() + 3600 * 1000),
|
||||
channelId: channel.channelId,
|
||||
channelTitle: channel.channelTitle,
|
||||
},
|
||||
update: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt: account.expires_at
|
||||
? new Date(account.expires_at * 1000)
|
||||
: new Date(Date.now() + 3600 * 1000),
|
||||
channelId: channel.channelId,
|
||||
channelTitle: channel.channelTitle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
async session({ session }) {
|
||||
if (session.user?.email) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
session.user.id = dbUser.id;
|
||||
session.user.plan = dbUser.plan;
|
||||
session.user.youtubeConnected = !!dbUser.youtubeConnection;
|
||||
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const BRAND_NAME = "Songs2YT";
|
||||
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
|
||||
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
|
||||
|
||||
export function getVideoAttributionText() {
|
||||
return `${VIDEO_ATTRIBUTION_PREFIX} ${BRAND_DOMAIN}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const FREE_PLAN = {
|
||||
monthlyQuota: 14,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
maxBatchSize: 3,
|
||||
watermarkOptional: true,
|
||||
} as const;
|
||||
|
||||
export const RESOLUTIONS = [
|
||||
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
|
||||
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
|
||||
{ value: "854x480", label: "854x480 (16:9)", width: 854, height: 480 },
|
||||
{ value: "720x720", label: "720x720 (1:1)", width: 720, height: 720 },
|
||||
{ value: "640x360", label: "640x360 (16:9)", width: 640, height: 360 },
|
||||
{ value: "426x240", label: "426x240 (16:9)", width: 426, height: 240 },
|
||||
] as const;
|
||||
|
||||
export const YOUTUBE_CATEGORIES = [
|
||||
{ id: "10", name: "Music" },
|
||||
{ id: "1", name: "Film & Animation" },
|
||||
{ id: "2", name: "Autos & Vehicles" },
|
||||
{ id: "15", name: "Pets & Animals" },
|
||||
{ id: "17", name: "Sports" },
|
||||
{ id: "19", name: "Travel & Events" },
|
||||
{ id: "20", name: "Gaming" },
|
||||
{ id: "22", name: "People & Blogs" },
|
||||
{ id: "23", name: "Comedy" },
|
||||
{ id: "24", name: "Entertainment" },
|
||||
{ id: "25", name: "News & Politics" },
|
||||
{ id: "26", name: "Howto & Style" },
|
||||
{ id: "27", name: "Education" },
|
||||
{ id: "28", name: "Science & Technology" },
|
||||
{ id: "29", name: "Nonprofits & Activism" },
|
||||
] as const;
|
||||
|
||||
export const QUEUE_NAME = "video-jobs";
|
||||
|
||||
export function getResolution(value: string) {
|
||||
return RESOLUTIONS.find((r) => r.value === value);
|
||||
}
|
||||
|
||||
export function isAllowedResolution(value: string): boolean {
|
||||
return RESOLUTIONS.some((r) => r.value === value);
|
||||
}
|
||||
|
||||
export function filenameWithoutExtension(filename: string): string {
|
||||
const lastDot = filename.lastIndexOf(".");
|
||||
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
|
||||
}
|
||||
|
||||
export function parseTags(input: string): string[] {
|
||||
const tags: string[] = [];
|
||||
const regex = /"([^"]+)"|([^,\s]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
const tag = (match[1] || match[2]).trim();
|
||||
if (tag) tags.push(tag);
|
||||
}
|
||||
return tags.slice(0, 500);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
|
||||
function getEncryptionKey() {
|
||||
const secret = process.env.TOKEN_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("TOKEN_ENCRYPTION_KEY or NEXTAUTH_SECRET must be set to encrypt secrets");
|
||||
}
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
/** Encrypt a secret for DB storage (AES-256-GCM). Idempotent if already encrypted. */
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
if (!plaintext) return plaintext;
|
||||
if (plaintext.startsWith(ENC_PREFIX)) return plaintext;
|
||||
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
ENC_PREFIX,
|
||||
iv.toString("base64url"),
|
||||
".",
|
||||
tag.toString("base64url"),
|
||||
".",
|
||||
encrypted.toString("base64url"),
|
||||
].join("");
|
||||
}
|
||||
|
||||
/** Decrypt a stored secret. Legacy plaintext values are returned as-is. */
|
||||
export function decryptSecret(value: string): string {
|
||||
if (!value) return value;
|
||||
if (!value.startsWith(ENC_PREFIX)) return value;
|
||||
|
||||
const payload = value.slice(ENC_PREFIX.length);
|
||||
const [ivB64, tagB64, dataB64] = payload.split(".");
|
||||
if (!ivB64 || !tagB64 || !dataB64) {
|
||||
throw new Error("Invalid encrypted secret format");
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
getEncryptionKey(),
|
||||
Buffer.from(ivB64, "base64url"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(dataB64, "base64url")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
|
||||
export function isSelfHostedEdition(): boolean {
|
||||
return process.env.S2YT_EDITION === "selfhosted";
|
||||
}
|
||||
|
||||
export function hasProFeatures(plan: Plan): boolean {
|
||||
return isSelfHostedEdition() || plan === "PREMIUM";
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import ffmpegStatic from "ffmpeg-static";
|
||||
import { getVideoAttributionText } from "../branding";
|
||||
import { getResolution } from "../constants";
|
||||
import { getWatermarkPath } from "../storage";
|
||||
|
||||
function getFfmpegPath(): string {
|
||||
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
||||
if (ffmpegStatic) return ffmpegStatic;
|
||||
return "ffmpeg";
|
||||
}
|
||||
|
||||
export { getFfmpegPath };
|
||||
|
||||
export async function encodeVideo(options: {
|
||||
imagePath: string;
|
||||
audioPath: string;
|
||||
outputPath: string;
|
||||
resolution: string;
|
||||
includeWatermark: boolean;
|
||||
}): Promise<void> {
|
||||
const res = getResolution(options.resolution);
|
||||
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
||||
|
||||
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
||||
|
||||
const scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`;
|
||||
|
||||
const watermarkPath = getWatermarkPath();
|
||||
let watermarkExists = false;
|
||||
try {
|
||||
await fs.access(watermarkPath);
|
||||
watermarkExists = true;
|
||||
} catch {
|
||||
watermarkExists = false;
|
||||
}
|
||||
|
||||
const useWatermark = options.includeWatermark && watermarkExists;
|
||||
const attributionText = getVideoAttributionText().replace(/:/g, "\\:").replace(/'/g, "\\'");
|
||||
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
||||
|
||||
// Base template: ffmpeg -loop 1 -r 1 -i image -i audio -c:v libx264 -tune stillimage -c:a copy -shortest -pix_fmt yuv420p output.mp4
|
||||
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||
|
||||
if (useWatermark) {
|
||||
args.push("-i", watermarkPath);
|
||||
args.push(
|
||||
"-filter_complex",
|
||||
`[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"1:a",
|
||||
);
|
||||
} else if (options.includeWatermark && !watermarkExists) {
|
||||
args.push(
|
||||
"-filter_complex",
|
||||
`[0:v]${scaleFilter},drawtext=text='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-20:y=h-th-20[vout]`,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"1:a",
|
||||
);
|
||||
} else {
|
||||
args.push("-vf", scaleFilter);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-tune",
|
||||
"stillimage",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
options.outputPath,
|
||||
);
|
||||
|
||||
await runFfmpeg(args);
|
||||
}
|
||||
|
||||
function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
reject(new Error(`FFmpeg not found or failed to start: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupFiles(paths: string[]) {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
await fs.unlink(p);
|
||||
} catch {
|
||||
// ignore missing files
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createWriteStream } from "fs";
|
||||
import fs from "fs/promises";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
|
||||
export async function writeUploadedFile(file: File, destPath: string) {
|
||||
const webStream = file.stream();
|
||||
const nodeStream = Readable.fromWeb(webStream as Parameters<typeof Readable.fromWeb>[0]);
|
||||
await pipeline(nodeStream, createWriteStream(destPath));
|
||||
}
|
||||
|
||||
export async function moveFile(src: string, dest: string) {
|
||||
try {
|
||||
await fs.rename(src, dest);
|
||||
} catch (err) {
|
||||
const code = err && typeof err === "object" && "code" in err ? err.code : null;
|
||||
if (code === "EXDEV") {
|
||||
await fs.copyFile(src, dest);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
) {
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await fn(items[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { readAudioTags } from "../audio-tags";
|
||||
import { isAllowedResolution } from "../constants";
|
||||
import { prisma } from "../db";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import { moveFile, writeUploadedFile } from "../fs-utils";
|
||||
import { enqueueVideoJob } from "../queue/client";
|
||||
import {
|
||||
getPlanLimits,
|
||||
isAudioExtensionAllowed,
|
||||
isResolutionAllowedForPlan,
|
||||
} from "../plans";
|
||||
import { releaseReservationSplit, reserveQuota } from "../quota";
|
||||
import { getJobDir } from "../storage";
|
||||
import {
|
||||
assertPathInUserUploads,
|
||||
getUserStagingDir,
|
||||
sanitizeUploadSessionKey,
|
||||
} from "../upload-paths";
|
||||
import type { CreateJobPayload } from "../types";
|
||||
|
||||
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
|
||||
if (!metadata.title?.trim()) return "Each video must have a title";
|
||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||
return "Invalid privacy setting";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) {
|
||||
if (!body.imagePath || !body.items?.length) {
|
||||
return "Image and at least one audio file required";
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
const metaError = validateItemMetadata(item.metadata);
|
||||
if (metaError) return metaError;
|
||||
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) {
|
||||
return `Resolution ${item.metadata.resolution} is not available on your plan`;
|
||||
}
|
||||
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
|
||||
return "Adding videos to a YouTube playlist requires the Pro plan";
|
||||
}
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
|
||||
try {
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
await fs.access(imagePath);
|
||||
const imageStat = await fs.stat(imagePath);
|
||||
if (imageStat.size > limits.maxFileSizeBytes) {
|
||||
return "Image exceeds size limit";
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) {
|
||||
return `Audio file ${item.audioFilename} is not supported on your plan`;
|
||||
}
|
||||
const audioPath = assertPathInUserUploads(user.id, item.audioPath);
|
||||
await fs.access(audioPath);
|
||||
const stat = await fs.stat(audioPath);
|
||||
if (stat.size > limits.maxFileSizeBytes) {
|
||||
return `Audio file ${item.audioFilename} exceeds size limit`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Invalid upload path") {
|
||||
return "Invalid upload path";
|
||||
}
|
||||
return "One or more uploaded files not found";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createVideoJob(
|
||||
user: { id: string; plan: Plan },
|
||||
body: CreateJobPayload,
|
||||
) {
|
||||
const validationError = await validateJobPayload(user, body);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
|
||||
const items = body.items.map((item) => ({
|
||||
...item,
|
||||
audioPath: assertPathInUserUploads(user.id, item.audioPath),
|
||||
}));
|
||||
|
||||
const reservation = await reserveQuota(user.id, items.length);
|
||||
|
||||
try {
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
imagePath,
|
||||
items: {
|
||||
create: items.map((item, index) => ({
|
||||
audioPath: item.audioPath,
|
||||
audioFilename: item.audioFilename,
|
||||
title: item.metadata.title.trim(),
|
||||
description: item.metadata.description || "",
|
||||
tags: item.metadata.tags || "",
|
||||
privacy: item.metadata.privacy as Privacy,
|
||||
categoryId: item.metadata.categoryId || "10",
|
||||
resolution: item.metadata.resolution,
|
||||
notifySubscribers: item.metadata.notifySubscribers,
|
||||
madeForKids: item.metadata.madeForKids,
|
||||
embeddable: item.metadata.embeddable,
|
||||
creativeCommons: item.metadata.creativeCommons,
|
||||
includeWatermark: item.metadata.includeWatermark,
|
||||
playlistId: item.metadata.playlistId?.trim() || null,
|
||||
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
const jobDir = getJobDir(user.id, job.id);
|
||||
await fs.mkdir(jobDir, { recursive: true });
|
||||
|
||||
const imageExt = path.extname(imagePath);
|
||||
const newImagePath = path.join(jobDir, `image${imageExt}`);
|
||||
await moveFile(imagePath, newImagePath);
|
||||
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
|
||||
|
||||
await Promise.all(
|
||||
job.items.map(async (item) => {
|
||||
const audioExt = path.extname(item.audioPath);
|
||||
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
|
||||
await moveFile(item.audioPath, newAudioPath);
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: { audioPath: newAudioPath },
|
||||
});
|
||||
|
||||
await enqueueVideoJob({
|
||||
jobItemId: item.id,
|
||||
userId: user.id,
|
||||
jobId: job.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return job;
|
||||
} catch (err) {
|
||||
await releaseReservationSplit(user.id, reservation).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUploadedFile(
|
||||
userId: string,
|
||||
file: File,
|
||||
type: "image" | "audio",
|
||||
plan: Plan,
|
||||
options?: { sessionKey?: string },
|
||||
) {
|
||||
const limits = getPlanLimits(plan);
|
||||
|
||||
if (file.size > limits.maxFileSizeBytes) {
|
||||
throw new Error(`File exceeds ${limits.maxFileSizeBytes / (1024 * 1024)} MB limit`);
|
||||
}
|
||||
|
||||
const allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
const allowedAudioTypes = [
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
];
|
||||
const allowedTypes = type === "image" ? allowedImageTypes : allowedAudioTypes;
|
||||
|
||||
if (
|
||||
!allowedTypes.includes(file.type) &&
|
||||
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)
|
||||
) {
|
||||
throw new Error("Invalid file type");
|
||||
}
|
||||
|
||||
if (type === "audio" && !isAudioExtensionAllowed(file.name, plan)) {
|
||||
const allowed = limits.allowedAudioExtensions.join(", ");
|
||||
throw new Error(`Your plan supports ${allowed} audio files only`);
|
||||
}
|
||||
|
||||
const sessionKey = sanitizeUploadSessionKey(options?.sessionKey);
|
||||
const sessionDir = getUserStagingDir(userId, sessionKey);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const uniqueName = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
|
||||
const filePath = path.join(sessionDir, uniqueName);
|
||||
assertPathInUserUploads(userId, filePath);
|
||||
await writeUploadedFile(file, filePath);
|
||||
|
||||
let audioTags = null;
|
||||
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
|
||||
audioTags = await readAudioTags(filePath);
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
audioTags,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Plan } from "@prisma/client";
|
||||
import { hasProFeatures } from "../edition";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
|
||||
import { createYouTubePlaylist } from "../youtube/upload";
|
||||
|
||||
export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const title = typeof value.title === "string" ? value.title.trim() : "";
|
||||
if (!title) return null;
|
||||
|
||||
const privacy =
|
||||
value.privacy === "public" || value.privacy === "unlisted" || value.privacy === "private"
|
||||
? value.privacy
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: typeof value.description === "string" ? value.description : undefined,
|
||||
privacy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyCreatePlaylistToItems(
|
||||
user: { id: string; plan: Plan },
|
||||
items: CreateJobPayload["items"],
|
||||
createPlaylist: CreatePlaylistRequest | null | undefined,
|
||||
) {
|
||||
if (!createPlaylist) {
|
||||
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
|
||||
}
|
||||
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
throw new Error("Creating a YouTube playlist requires the Pro plan");
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, createPlaylist);
|
||||
const nextItems = items.map((item) => ({
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
playlistId: item.metadata.playlistId || playlist.id,
|
||||
} satisfies ItemMetadata,
|
||||
}));
|
||||
|
||||
return { items: nextItems, playlist };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SUPPORT_EMAIL, SALES_EMAIL } from "@/lib/plans";
|
||||
|
||||
export const LEGAL_LAST_UPDATED = "July 13, 2026";
|
||||
|
||||
export const LEGAL_OPERATOR = {
|
||||
name: "Songs2YT",
|
||||
legalName: "Atakan Doğan Özban",
|
||||
address: "Universitas u. 2/A",
|
||||
city: "7622 Pécs, Hungary",
|
||||
email: SUPPORT_EMAIL,
|
||||
salesEmail: SALES_EMAIL,
|
||||
website: "https://www.songs2yt.com",
|
||||
} as const;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolution, RESOLUTIONS } from "./constants";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
|
||||
export type PlanLimits = {
|
||||
monthlyQuota: number;
|
||||
maxBatchSize: number;
|
||||
maxResolutionHeight: number;
|
||||
maxFileSizeBytes: number;
|
||||
watermarkOptional: boolean;
|
||||
allowedAudioExtensions: readonly string[];
|
||||
id3TagSupport: boolean;
|
||||
};
|
||||
|
||||
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
FREE: {
|
||||
monthlyQuota: 14,
|
||||
maxBatchSize: 3,
|
||||
maxResolutionHeight: 720,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
allowedAudioExtensions: [".mp3"],
|
||||
id3TagSupport: true,
|
||||
},
|
||||
PREMIUM: {
|
||||
monthlyQuota: 50,
|
||||
maxBatchSize: 5,
|
||||
maxResolutionHeight: 1080,
|
||||
maxFileSizeBytes: 30 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
||||
id3TagSupport: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SELFHOSTED_LIMITS: PlanLimits = {
|
||||
monthlyQuota: 1_000_000,
|
||||
maxBatchSize: 100,
|
||||
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
|
||||
maxFileSizeBytes: 500 * 1024 * 1024,
|
||||
watermarkOptional: true,
|
||||
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
|
||||
id3TagSupport: true,
|
||||
};
|
||||
|
||||
export const SALES_EMAIL = "songs2yt@atakanozban.com";
|
||||
export const SUPPORT_EMAIL = "songs2yt@atakanozban.com";
|
||||
export const UPGRADE_URL = "/#pricing";
|
||||
export const GITEA_ISSUES_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2YT/issues";
|
||||
export const GITEA_URL =
|
||||
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2YT";
|
||||
export const DOCKER_HUB_URL =
|
||||
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2yt";
|
||||
|
||||
export function getPlanLimits(plan: Plan): PlanLimits {
|
||||
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS;
|
||||
return PLAN_LIMITS[plan];
|
||||
}
|
||||
|
||||
export function getResolutionsForPlan(plan: Plan) {
|
||||
const maxHeight = getPlanLimits(plan).maxResolutionHeight;
|
||||
return RESOLUTIONS.filter((r) => r.height <= maxHeight);
|
||||
}
|
||||
|
||||
export function isResolutionAllowedForPlan(resolution: string, plan: Plan): boolean {
|
||||
const res = getResolution(resolution);
|
||||
if (!res) return false;
|
||||
return res.height <= getPlanLimits(plan).maxResolutionHeight;
|
||||
}
|
||||
|
||||
export function isAudioExtensionAllowed(filename: string, plan: Plan): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
return getPlanLimits(plan).allowedAudioExtensions.includes(ext);
|
||||
}
|
||||
|
||||
export function getNextMonthlyQuotaReset(from: Date = new Date()): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { QUEUE_NAME } from "../constants";
|
||||
import type { VideoJobData } from "../types";
|
||||
|
||||
function getConnectionOptions() {
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: Number(parsed.port) || 6379,
|
||||
username: parsed.username || undefined,
|
||||
password: parsed.password || undefined,
|
||||
maxRetriesPerRequest: null as null,
|
||||
};
|
||||
}
|
||||
|
||||
let queue: Queue | null = null;
|
||||
|
||||
export function getRedisConnection() {
|
||||
return getConnectionOptions();
|
||||
}
|
||||
|
||||
export function getVideoQueue(): Queue {
|
||||
if (!queue) {
|
||||
queue = new Queue(QUEUE_NAME, {
|
||||
connection: getConnectionOptions(),
|
||||
});
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
export async function enqueueVideoJob(data: VideoJobData) {
|
||||
const q = getVideoQueue();
|
||||
await q.add("process-video", data, {
|
||||
attempts: 2,
|
||||
backoff: { type: "exponential", delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 200,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { QuotaExtensionRequestStatus } from "@prisma/client";
|
||||
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
|
||||
import { prisma } from "./db";
|
||||
import { getPlanLimits } from "./plans";
|
||||
|
||||
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
|
||||
/** Max bonus videos an admin may grant per approval. */
|
||||
export const MAX_ADMIN_BONUS_QUOTA = 50;
|
||||
|
||||
export const EXTENSION_KIND = {
|
||||
VIDEO_QUOTA: "VIDEO_QUOTA",
|
||||
API_RATE_LIMIT: "API_RATE_LIMIT",
|
||||
} as const;
|
||||
|
||||
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
|
||||
|
||||
function getCalendarYearBounds(year = new Date().getFullYear()) {
|
||||
return {
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year + 1, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getQuotaExtensionUsage(
|
||||
userId: string,
|
||||
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
||||
) {
|
||||
const { start, end } = getCalendarYearBounds();
|
||||
|
||||
const requests = await prisma.quotaExtensionRequest.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind,
|
||||
requestedAt: { gte: start, lt: end },
|
||||
},
|
||||
orderBy: { requestedAt: "desc" },
|
||||
});
|
||||
|
||||
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
|
||||
|
||||
return {
|
||||
used,
|
||||
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
|
||||
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
|
||||
kind,
|
||||
requests: requests.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
message: r.message,
|
||||
requestedAt: r.requestedAt.toISOString(),
|
||||
processedAt: r.processedAt?.toISOString() ?? null,
|
||||
adminNote: r.adminNote,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createQuotaExtensionRequest(
|
||||
userId: string,
|
||||
message = "",
|
||||
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
||||
) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
if (user.plan !== "PREMIUM") {
|
||||
throw new Error("Only Pro subscribers can request quota resets or extensions.");
|
||||
}
|
||||
|
||||
const usage = await getQuotaExtensionUsage(userId, kind);
|
||||
if (usage.remaining <= 0) {
|
||||
throw new Error(
|
||||
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
|
||||
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
|
||||
} extension requests for this year.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pending = await prisma.quotaExtensionRequest.findFirst({
|
||||
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
|
||||
});
|
||||
if (pending) {
|
||||
throw new Error("You already have a pending request. Please wait for it to be processed.");
|
||||
}
|
||||
|
||||
const request = await prisma.quotaExtensionRequest.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
message: message.trim().slice(0, 1000),
|
||||
status: QuotaExtensionRequestStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
|
||||
|
||||
return {
|
||||
requestId: request.id,
|
||||
...updatedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Call when approving a request in the database (admin / support). */
|
||||
export async function approveQuotaExtensionRequest(
|
||||
requestId: string,
|
||||
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
|
||||
) {
|
||||
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
||||
where: { id: requestId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
||||
throw new Error("Request is not pending.");
|
||||
}
|
||||
|
||||
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
|
||||
const bonus = Math.min(
|
||||
MAX_ADMIN_API_RATE_BONUS,
|
||||
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.APPROVED,
|
||||
processedAt: new Date(),
|
||||
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const bonus = Math.min(
|
||||
MAX_ADMIN_BONUS_QUOTA,
|
||||
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.APPROVED,
|
||||
processedAt: new Date(),
|
||||
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
videosUsed: 0,
|
||||
bonusQuota: request.user.bonusQuota + bonus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
|
||||
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
||||
where: { id: requestId },
|
||||
});
|
||||
|
||||
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
||||
throw new Error("Request is not pending.");
|
||||
}
|
||||
|
||||
await prisma.quotaExtensionRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: QuotaExtensionRequestStatus.REJECTED,
|
||||
processedAt: new Date(),
|
||||
adminNote: adminNote?.trim().slice(0, 500) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
|
||||
return getPlanLimits(plan).monthlyQuota + bonusQuota;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Plan } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { isSelfHostedEdition } from "./edition";
|
||||
import { getEffectiveQuotaLimit } from "./quota-extensions";
|
||||
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
|
||||
|
||||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||||
const CREDIT_BALANCE_MAX = 1000;
|
||||
|
||||
function getNextFreeQuotaReset(from: Date = new Date()): Date {
|
||||
return new Date(from.getTime() + TWELVE_HOURS_MS);
|
||||
}
|
||||
|
||||
function getNextQuotaResetForPlan(plan: Plan, from: Date = new Date()): Date {
|
||||
return plan === "FREE" ? getNextFreeQuotaReset(from) : getNextMonthlyQuotaReset(from);
|
||||
}
|
||||
|
||||
function isMonthlyResetDate(date: Date): boolean {
|
||||
return (
|
||||
date.getDate() === 1 &&
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
const ms = Math.max(0, target.getTime() - Date.now());
|
||||
const totalSec = Math.ceil(ms / 1000);
|
||||
const hours = Math.floor(totalSec / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const seconds = totalSec % 60;
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string {
|
||||
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
|
||||
if (plan === "PREMIUM") {
|
||||
return target.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
return formatQuotaResetCountdown(target);
|
||||
}
|
||||
|
||||
function getQuotaExceededMessage(plan: Plan, resetsAt: Date, videoCredits: number): string {
|
||||
if (plan === "PREMIUM") {
|
||||
const resetLabel = formatQuotaResetDisplay(plan, resetsAt);
|
||||
return `Quota exceeded. Your monthly Pro quota resets on ${resetLabel}. Request an extension in Settings if needed.`;
|
||||
}
|
||||
|
||||
const countdown = formatQuotaResetCountdown(resetsAt);
|
||||
const creditHint = videoCredits > 0 ? "" : " Credits are not available in this build.";
|
||||
return `Not enough free quota or credits. Your free quota resets in ${countdown}.${creditHint}`;
|
||||
}
|
||||
|
||||
export async function ensureQuotaReset(userId: string) {
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const now = new Date();
|
||||
|
||||
if (user.plan === "PREMIUM") {
|
||||
if (now >= user.quotaResetAt) {
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
videosUsed: 0,
|
||||
bonusQuota: 0,
|
||||
quotaResetAt: getNextMonthlyQuotaReset(now),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isMonthlyResetDate(user.quotaResetAt)) {
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
quotaResetAt: getNextMonthlyQuotaReset(now),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
if (now >= user.quotaResetAt) {
|
||||
return prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
videosUsed: 0,
|
||||
quotaResetAt: getNextQuotaResetForPlan(user.plan, now),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getQuotaInfo(userId: string) {
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limit = isSelfHostedEdition()
|
||||
? limits.monthlyQuota
|
||||
: getEffectiveQuotaLimit(user.plan, user.bonusQuota);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
const videoCredits =
|
||||
isSelfHostedEdition() || user.plan !== "FREE" ? 0 : user.videoCredits;
|
||||
return {
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
baseLimit: limits.monthlyQuota,
|
||||
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
|
||||
remaining,
|
||||
videoCredits,
|
||||
creditBalanceMax: CREDIT_BALANCE_MAX,
|
||||
totalAvailable: remaining + videoCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
|
||||
plan: user.plan,
|
||||
maxBatchSize: limits.maxBatchSize,
|
||||
watermarkOptional: limits.watermarkOptional,
|
||||
maxResolutionHeight: limits.maxResolutionHeight,
|
||||
selfHosted: isSelfHostedEdition(),
|
||||
};
|
||||
}
|
||||
|
||||
export type ReservationSplit = {
|
||||
fromQuota: number;
|
||||
fromCredits: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Free plan: use included quota first, then pay-as-you-go credits.
|
||||
* Pro plan: subscription quota only (PAYG is a separate product).
|
||||
*/
|
||||
export function planReservation(
|
||||
plan: Plan,
|
||||
remainingQuota: number,
|
||||
videoCredits: number,
|
||||
count: number,
|
||||
): ReservationSplit | null {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
|
||||
if (plan === "PREMIUM") {
|
||||
if (count > remainingQuota) return null;
|
||||
return { fromQuota: count, fromCredits: 0 };
|
||||
}
|
||||
|
||||
const fromQuota = Math.min(count, Math.max(0, remainingQuota));
|
||||
const fromCredits = count - fromQuota;
|
||||
if (fromCredits > videoCredits) return null;
|
||||
return { fromQuota, fromCredits };
|
||||
}
|
||||
|
||||
export async function checkQuota(userId: string, requestedCount: number) {
|
||||
if (isSelfHostedEdition()) {
|
||||
const info = await getQuotaInfo(userId);
|
||||
if (requestedCount > info.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
|
||||
used: info.used,
|
||||
limit: info.limit,
|
||||
remaining: info.remaining,
|
||||
videoCredits: 0,
|
||||
resetsAt: info.resetsAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
...info,
|
||||
reservation: { fromQuota: requestedCount, fromCredits: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ensureQuotaReset(userId);
|
||||
const limits = getPlanLimits(user.plan);
|
||||
const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
|
||||
if (requestedCount > limits.maxBatchSize) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
remaining,
|
||||
videoCredits: user.videoCredits,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const split = planReservation(user.plan, remaining, user.videoCredits, requestedCount);
|
||||
if (!split) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits),
|
||||
used: user.videosUsed,
|
||||
limit,
|
||||
remaining,
|
||||
videoCredits: user.plan === "FREE" ? user.videoCredits : 0,
|
||||
resetsAt: user.quotaResetAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const info = await getQuotaInfo(userId);
|
||||
return { ok: true as const, ...info, reservation: split };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reserve plan quota first, then prepaid credits.
|
||||
* Returns how many of each were taken (for per-item billingSource).
|
||||
*/
|
||||
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
|
||||
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
|
||||
|
||||
if (isSelfHostedEdition()) {
|
||||
const limits = getPlanLimits("FREE");
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
|
||||
}
|
||||
await ensureQuotaReset(userId);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { videosUsed: { increment: count } },
|
||||
});
|
||||
return { fromQuota: count, fromCredits: 0 };
|
||||
}
|
||||
|
||||
await ensureQuotaReset(userId);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
plan: Plan;
|
||||
videosUsed: number;
|
||||
bonusQuota: number;
|
||||
videoCredits: number;
|
||||
quotaResetAt: Date;
|
||||
}>
|
||||
>`SELECT id, plan, "videosUsed", "bonusQuota", "videoCredits", "quotaResetAt" FROM "User" WHERE id = ${userId} FOR UPDATE`;
|
||||
|
||||
const user = rows[0];
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
if (count > limits.maxBatchSize) {
|
||||
throw new Error(
|
||||
`Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
|
||||
);
|
||||
}
|
||||
|
||||
const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota);
|
||||
const remaining = Math.max(0, limit - user.videosUsed);
|
||||
const split = planReservation(user.plan, remaining, user.videoCredits, count);
|
||||
if (!split) {
|
||||
throw new Error(getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits));
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(split.fromQuota > 0 ? { videosUsed: { increment: split.fromQuota } } : {}),
|
||||
...(split.fromCredits > 0 ? { videoCredits: { decrement: split.fromCredits } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return split;
|
||||
});
|
||||
}
|
||||
|
||||
export async function releaseQuota(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await ensureQuotaReset(userId);
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videosUsed" = GREATEST(0, "videosUsed" - ${count})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function releaseCredits(userId: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "User"
|
||||
SET "videoCredits" = LEAST(${CREDIT_BALANCE_MAX}, "videoCredits" + ${count})
|
||||
WHERE id = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Release one reserved unit based on how the job item was billed. */
|
||||
export async function releaseReservation(
|
||||
userId: string,
|
||||
billingSource: "QUOTA" | "CREDIT",
|
||||
count = 1,
|
||||
) {
|
||||
if (billingSource === "CREDIT") {
|
||||
await releaseCredits(userId, count);
|
||||
} else {
|
||||
await releaseQuota(userId, count);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
|
||||
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
|
||||
if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits);
|
||||
}
|
||||
|
||||
/** @deprecated Prefer reserveQuota at create; kept for callers that still increment on success. */
|
||||
export async function incrementQuota(userId: string, count: number) {
|
||||
await reserveQuota(userId, count);
|
||||
}
|
||||
|
||||
export function getInitialQuotaResetAt(): Date {
|
||||
return getNextFreeQuotaReset();
|
||||
}
|
||||