Sync layouts, typography, and watermark studio for self-hosted OSS.

Unlock features without credits/Stripe, point docs to docs.songs2vid.com, and drop leftover billing admin surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Songs2YT
2026-07-25 03:50:06 +02:00
co-authored by Cursor
parent bcca0a6e6f
commit d951ecb489
53 changed files with 3258 additions and 1977 deletions
@@ -1,51 +0,0 @@
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 });
}
}
@@ -1,30 +0,0 @@
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 });
}
@@ -1,36 +0,0 @@
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 });
}
}
@@ -1,53 +0,0 @@
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 });
}
}
+17 -4
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { saveUploadedFile } from "@/lib/jobs/create-job";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session";
export const maxDuration = 120;
@@ -15,14 +16,26 @@ export async function POST(req: NextRequest) {
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 });
if (
!file ||
!type ||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
) {
return NextResponse.json(
{ error: "Missing file or type (image | audio | logo | font)" },
{ status: 400 },
);
}
try {
const result = await saveUploadedFile(user.id, file, type, user.plan, { sessionKey });
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
sessionKey,
});
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
+6 -5
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { API_DOCS_URL } from "@/lib/plans";
export async function GET() {
return NextResponse.json({
name: "Songs2YT API",
name: "Songs2VID 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)",
requirements: ["Self-hosted edition", "YouTube account connected"],
rateLimit: "Generous self-hosted limits (see docs)",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
@@ -36,7 +37,7 @@ export async function GET() {
{
method: "GET",
path: "/api/v1/playlists",
description: "List YouTube playlists for the authenticated Pro account",
description: "List YouTube playlists for the authenticated account",
},
{
method: "POST",
@@ -56,6 +57,6 @@ export async function GET() {
description: "Get job status and item details",
},
],
docs: "/dashboard/api-docs",
docs: API_DOCS_URL,
});
}
+12 -4
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { saveUploadedFile } from "@/lib/jobs/create-job";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
export const maxDuration = 120;
@@ -12,17 +13,24 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as File | null;
const type = formData.get("type") as string | null;
if (!file || !type || (type !== "image" && type !== "audio")) {
if (
!file ||
!type ||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
) {
return NextResponse.json(
{ error: "Provide multipart fields: file, type (image|audio)" },
{ error: "Provide multipart fields: file, type (image|audio|logo|font)" },
{ status: 400 },
);
}
try {
const result = await saveUploadedFile(user.id, file, type, user.plan);
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
-219
View File
@@ -1,219 +0,0 @@
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>
);
}
+16 -117
View File
@@ -4,21 +4,12 @@ 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">
@@ -39,16 +30,8 @@ export default async function SettingsPage() {
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 apiKeyStatus = await getUserApiKeyStatus(user.id);
const apiRateLimit = await getApiRateLimitStatus(user.id);
const youtube = user.youtubeConnection;
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
@@ -100,115 +83,31 @@ export default async function SettingsPage() {
</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>
<h2 className="mb-4 text-lg font-semibold text-white">Usage</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.
<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">{quota.used}</p>
</div>
<p className="mt-2 text-xs text-gray-500">
Self-hosted edition: no video credits or 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="Edition" value="Self-hosted" />
<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-4 text-lg font-semibold text-white">API access</h2>
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
</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 &amp; data</h2>
+24 -10
View File
@@ -5,10 +5,10 @@ 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";
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
export default async function HomePage() {
const session = await getServerSession(authOptions);
@@ -34,14 +34,13 @@ export default async function HomePage() {
<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
Songs2VID
</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.
Self-hosted automation that turns your audio and cover art into YouTube videos
including art-track layouts, typography, and watermark studio. No quotas or paywalls.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
@@ -74,19 +73,34 @@ export default async function HomePage() {
</p>
</>
)}
<p className="mt-4 text-sm text-gray-400">
<a
href={`${DOCS_URL}/docs/intro`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Documentation
</a>
{" · "}
<a
href={WEBSITE_URL}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Hosted product
</a>
</p>
</div>
</div>
</section>
<StepsSection />
<BenefitsSection />
<DownloadSection />
<PricingSection />
<SupportSection />
<Footer />
</main>
);
}
}