Initial open-source self-hosted Songs2YT edition

This commit is contained in:
Songs2YT
2026-07-17 02:23:59 +02:00
commit 2aa8a84781
134 changed files with 17758 additions and 0 deletions
+78
View File
@@ -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>
);
}
+296
View File
@@ -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>
);
}
+436
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+95
View File
@@ -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>
</>
);
}
+18
View File
@@ -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>
);
}
+160
View File
@@ -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>
);
}
+192
View File
@@ -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>
);
}
+210
View File
@@ -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>
);
}
+148
View File
@@ -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>
);
}
+77
View File
@@ -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>
</>
);
}
+58
View File
@@ -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>
);
}
+62
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+169
View File
@@ -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(" ");
}
+199
View File
@@ -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>
);
}
+195
View File
@@ -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 (dont 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>
);
}
+330
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+34
View File
@@ -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" />;
}
+30
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+234
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}
+33
View File
@@ -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}</>;
}
+526
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}