Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.

Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Atakan Doğan Özban
2026-07-27 16:26:19 +02:00
co-authored by Cursor
parent 682020ff5a
commit c8015937f9
206 changed files with 37078 additions and 638 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 Songs2VID 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>
);
}
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { useEffect, useState } from "react";
import { API_DOCS_URL } from "@/lib/plans";
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={API_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
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 Songs2VID 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 10 videos every month 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>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { UpgradeProButton } from "@/components/UpgradeProButton";
import {
CREDIT_PRICE_CENTS,
FREE_EXTRA_CREDITS_MAX,
FREE_TOP_UP_MAX,
FREE_TOP_UP_MIN,
formatCreditPrice,
formatEuroFromCents,
} from "@/lib/credits";
type Purchase = {
id: string;
credits: number;
amountCents: number;
completedAt: string | null;
};
type Props = {
initialCredits: number;
stripeConfigured: boolean;
recentPurchases?: Purchase[];
};
export function CreditPurchasePanel({
initialCredits,
stripeConfigured,
recentPurchases = [],
}: Props) {
const router = useRouter();
const [creditsBalance, setCreditsBalance] = useState(initialCredits);
const [amount, setAmount] = useState(FREE_TOP_UP_MIN);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const room = Math.max(0, FREE_EXTRA_CREDITS_MAX - creditsBalance);
const maxBuyable = Math.min(FREE_TOP_UP_MAX, room);
const minBuyable = room < FREE_TOP_UP_MIN ? 0 : FREE_TOP_UP_MIN;
const atCap = room === 0;
const totalLabel = useMemo(() => formatCreditPrice(amount), [amount]);
const unitLabel = formatCreditPrice(1);
async function handleBuy() {
setLoading(true);
setError(null);
setMessage(null);
try {
const res = await fetch("/api/account/credits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credits: amount }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Checkout failed");
if (data.mocked) {
setCreditsBalance((c) => c + (data.granted ?? amount));
setMessage(
`Granted +${data.granted ?? amount} credits for ${formatEuroFromCents(data.amountCents ?? amount * CREDIT_PRICE_CENTS)} (dev mock).`,
);
setLoading(false);
router.refresh();
return;
}
if (!data.url) throw new Error("No checkout URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Checkout failed");
setLoading(false);
}
}
function clampAmount(value: number) {
if (maxBuyable < FREE_TOP_UP_MIN) return FREE_TOP_UP_MIN;
return Math.min(maxBuyable, Math.max(FREE_TOP_UP_MIN, value));
}
return (
<div className="mt-6 border-t border-gray-800 pt-6">
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-500">
Extra credits
</h3>
<p className="mb-4 text-sm text-gray-400">
Free plan: 10 monthly videos + up to {FREE_EXTRA_CREDITS_MAX} paid extras (
{unitLabel} each). After both are used, Pro is required.
</p>
<div className="mb-4 rounded border border-gray-800 bg-surface p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Extra credit balance
</p>
<p className="mt-1 text-2xl font-semibold text-white">
{Math.min(creditsBalance, FREE_EXTRA_CREDITS_MAX)}
<span className="text-base font-normal text-gray-400">
{" "}
/ {FREE_EXTRA_CREDITS_MAX} credits
</span>
</p>
{creditsBalance > FREE_EXTRA_CREDITS_MAX && (
<p className="mt-1 text-xs text-amber-400">
You have {creditsBalance} extras from earlier purchases; no further top-ups until
under {FREE_EXTRA_CREDITS_MAX}.
</p>
)}
</div>
{atCap ? (
<div className="space-y-2 rounded border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-100">
<p>
Free extras are capped at {FREE_EXTRA_CREDITS_MAX}. When your monthly 10 and these
extras are gone, continue with Pro (5/mo · 50 videos).
</p>
<UpgradeProButton className="font-medium text-accent underline hover:text-white" />
</div>
) : !stripeConfigured ? (
<p className="text-sm text-amber-400">
Card checkout is not configured yet (missing Stripe keys). In development you can also
use{" "}
<code className="text-gray-300">POST /api/dev/grant-credits</code>.
</p>
) : (
<div className="space-y-4">
<label className="block text-sm text-gray-300">
Credits to buy ({FREE_TOP_UP_MIN}{maxBuyable})
<div className="mt-2 flex items-center gap-3">
<input
type="range"
min={minBuyable || FREE_TOP_UP_MIN}
max={maxBuyable}
value={clampAmount(amount)}
onChange={(e) => setAmount(clampAmount(Number(e.target.value)))}
className="w-full accent-[var(--accent,#3b82f6)]"
/>
<input
type="number"
min={FREE_TOP_UP_MIN}
max={maxBuyable}
value={clampAmount(amount)}
onChange={(e) =>
setAmount(
clampAmount(Math.floor(Number(e.target.value)) || FREE_TOP_UP_MIN),
)
}
className="w-16 rounded border border-gray-700 bg-surface px-2 py-1 text-center text-white"
/>
</div>
</label>
<p className="text-sm text-gray-300">
Total:{" "}
<span className="font-semibold text-white">{totalLabel}</span>
<span className="text-gray-500">
{" "}
({unitLabel} × {clampAmount(amount)})
</span>
</p>
<button
type="button"
disabled={loading}
onClick={handleBuy}
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
{loading
? "Starting checkout…"
: `Buy ${clampAmount(amount)} credits (${totalLabel})`}
</button>
</div>
)}
{message && <p className="mt-3 text-sm text-emerald-400">{message}</p>}
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{recentPurchases.length > 0 && (
<div className="mt-6">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
Recent purchases
</h4>
<ul className="space-y-1 text-sm text-gray-400">
{recentPurchases.map((p) => (
<li key={p.id}>
+{p.credits} credits · {formatEuroFromCents(p.amountCents)}
{p.completedAt
? ` · ${new Date(p.completedAt).toLocaleDateString()}`
: ""}
</li>
))}
</ul>
</div>
)}
</div>
);
}
+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 Songs2VID 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">
Songs2VID 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 Songs2VID 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/songs2vid: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>
);
}
+206
View File
@@ -0,0 +1,206 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import {
DOCKER_HUB_URL,
DOCS_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";
const DOCS_INTRO_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
const DOCS_API_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/api/overview`;
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" beta />
<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={DOCS_INTRO_URL} external>
Documentation
</FooterLink>
<FooterLink href={DOCS_API_URL} external>
API Reference
</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-col items-center gap-3 border-t border-gray-900 pt-8 text-xs text-gray-500 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 sm:justify-start">
<span>© {year} Songs2VID. 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>
</div>
<span className="shrink-0 text-center sm:text-right">
Made with by{" "}
<a
href="https://www.atakanozban.com"
target="_blank"
rel="noopener noreferrer"
className="transition-colors duration-300 hover:text-gray-300"
>
atakan
</a>
.
</span>
</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>
);
}
+32 -8
View File
@@ -1,7 +1,10 @@
"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;
@@ -17,7 +20,13 @@ const STATUS_LABELS: Record<string, string> = {
export function JobProgress({ jobId }: Props) {
const [job, setJob] = useState<JobResponse | null>(null);
const [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null);
const [quota, setQuota] = useState<{
remaining: number;
limit: number;
totalAvailable: number;
plan: string;
resetsIn: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -36,7 +45,14 @@ export function JobProgress({ jobId }: Props) {
if (quotaRes.ok) {
const quotaData = await quotaRes.json();
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit });
if (active)
setQuota({
remaining: quotaData.remaining,
limit: quotaData.limit,
totalAvailable: quotaData.totalAvailable ?? quotaData.remaining,
plan: quotaData.plan,
resetsIn: quotaData.resetsIn,
});
}
} catch (err) {
if (active) setError(err instanceof Error ? err.message : "Error loading job");
@@ -60,6 +76,9 @@ export function JobProgress({ jobId }: Props) {
}
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">
@@ -72,11 +91,16 @@ export function JobProgress({ jobId }: Props) {
</div>
{quota && (
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
{quota.remaining} / {quota.limit} videos remaining this month
{quota.remaining} / {quota.limit} plan · {quota.totalAvailable} total available ·{" "}
{quota.plan === "FREE"
? `resets ${quota.resetsIn}`
: `monthly resets on ${quota.resetsIn}`}
</div>
)}
</div>
{youtubeLimitHit && <YouTubeLimitBanner />}
<div className="space-y-3">
{job.items.map((item) => (
<div
@@ -108,24 +132,24 @@ export function JobProgress({ jobId }: Props) {
rel="noopener noreferrer"
className="mt-2 inline-block text-sm text-accent hover:underline"
>
View on YouTube
View on YouTube
</a>
)}
{item.error && (
<p className="mt-2 text-sm text-red-400">{item.error}</p>
{item.status === "FAILED" && displayJobItemError(item.error) && (
<p className="mt-2 text-sm text-red-400">{displayJobItemError(item.error)}</p>
)}
</div>
))}
</div>
{allDone && (
<a
<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
</a>
</Link>
)}
</div>
);
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { Logo } from "@/components/Logo";
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
import { DOCS_URL } from "@/lib/plans";
const NAV_LINKS = [
{ href: "#benefits", label: "Benefits" },
{ href: "#download", label: "Download" },
{ href: "#pricing", label: "Pricing" },
{ href: "#support", label: "Support" },
] as const;
const DOCS_HREF = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
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>
);
}
function DocsLink({
onNavigate,
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
}: {
onNavigate?: () => void;
className?: string;
}) {
return (
<a
href={DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
className={className}
onClick={() => onNavigate?.()}
>
Docs
</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" beta />
<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} />
))}
<DocsLink />
</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()}
/>
))}
<DocsLink
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
</MobileSidebar>
</>
);
}
+866
View File
@@ -0,0 +1,866 @@
"use client";
import {
useEffect,
useMemo,
useState,
type CSSProperties,
type ChangeEvent,
} from "react";
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
import {
BLUR_AMOUNT_MAX,
BLUR_AMOUNT_MIN,
BLUR_OPACITY_DEFAULT,
BLUR_OPACITY_MAX,
BLUR_OPACITY_MIN,
DEFAULT_LAYOUT,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
TEXT_OFFSET_MAX,
TEXT_OFFSET_MIN,
TEXT_PADDING_MAX,
TEXT_PADDING_MIN,
TITLE_ARTIST_GAP_MAX,
TITLE_ARTIST_GAP_MIN,
type LayoutSettings,
type LayoutTemplate,
} from "@/lib/layout";
import {
WATERMARK_WIDTH_FRACTION,
artistFontSizeForWidth,
curatedFontApiUrl,
previewFontFamilyCss,
scaleFontToPreview,
titleFontSizeForWidth,
watermarkFontSizeForWidth,
} from "@/lib/preview-typography";
import {
DEFAULT_WATERMARK,
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
WATERMARK_POSITIONS,
WATERMARK_TEXT_MAX,
type WatermarkMode,
type WatermarkPosition,
type WatermarkSettings,
} from "@/lib/watermark";
import { UpgradeProButton } from "./UpgradeProButton";
type Props = {
locked: boolean;
previewImageUrl: string | null;
/** On-video song title (art-track layouts). */
songTitle: string;
artist: string;
/** Encode width from selected resolution (e.g. 1280). */
encodeWidth?: number;
layout: LayoutSettings;
onLayoutChange: (next: LayoutSettings) => void;
watermark: WatermarkSettings;
onWatermarkChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
logoPreviewUrl?: string | null;
};
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
"top-left": "Top left",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-right": "Bottom right",
center: "Center",
};
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
return previewFontFamilyCss(fontKey);
}
function watermarkOverlayStyle(
position: WatermarkPosition,
offsetX: number,
offsetY: number,
): CSSProperties {
const base: CSSProperties = {
position: "absolute",
maxWidth: "32%",
pointerEvents: "none",
zIndex: 5,
};
const ox = `${offsetX}px`;
const oy = `${offsetY}px`;
switch (position) {
case "top-left":
return { ...base, top: oy, left: ox };
case "top-right":
return { ...base, top: oy, right: ox };
case "bottom-left":
return { ...base, bottom: oy, left: ox };
case "center":
return {
...base,
top: "50%",
left: "50%",
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
};
case "bottom-right":
default:
return { ...base, bottom: oy, right: ox };
}
}
function MiniThumb({
template,
active,
onClick,
disabled,
}: {
template: LayoutTemplate | null;
active: boolean;
onClick: () => void;
disabled: boolean;
}) {
const isClassic = template === null;
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`overflow-hidden rounded border text-left transition-colors ${
active
? "border-accent bg-accent/10"
: "border-gray-700 bg-black/40 hover:border-gray-500"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
<div className="relative aspect-video w-full overflow-hidden bg-gradient-to-br from-gray-800 to-gray-950 p-1">
{isClassic ? (
<div className="flex h-full items-center justify-center">
<div className="h-[70%] w-[45%] rounded-sm bg-gray-600" />
</div>
) : (
<div className="relative h-full w-full overflow-hidden bg-gray-700/50">
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
{template === "COVER_LEFT_TEXT_RIGHT" && (
<>
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_RIGHT_TEXT_LEFT" && (
<>
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_TOP_TEXT_BOTTOM" && (
<>
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
<div className="absolute left-[20%] bottom-[18%] h-1 w-[60%] rounded bg-white/80" />
<div className="absolute left-[28%] bottom-[8%] h-0.5 w-[44%] rounded bg-white/50" />
</>
)}
{template === "CENTERED_COMPACT" && (
<>
<div className="absolute left-[32%] top-[14%] h-[42%] w-[36%] bg-gray-300" />
<div className="absolute left-[28%] bottom-[22%] h-1 w-[44%] rounded bg-white/80" />
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
</>
)}
</div>
)}
</div>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
</p>
</button>
);
}
function previewCoverStyle(
template: LayoutTemplate,
padPct: number,
): CSSProperties {
const p = `${padPct}%`;
switch (template) {
case "COVER_LEFT_TEXT_RIGHT":
return {
position: "absolute",
left: p,
top: "50%",
transform: "translateY(-50%)",
width: "38%",
maxHeight: "72%",
objectFit: "contain",
};
case "COVER_RIGHT_TEXT_LEFT":
return {
position: "absolute",
right: p,
top: "50%",
transform: "translateY(-50%)",
width: "38%",
maxHeight: "72%",
objectFit: "contain",
};
case "COVER_TOP_TEXT_BOTTOM":
return {
position: "absolute",
left: "50%",
top: p,
transform: "translateX(-50%)",
width: `calc(100% - ${padPct * 2}%)`,
maxHeight: "56%",
objectFit: "contain",
};
case "CENTERED_COMPACT":
return {
position: "absolute",
left: "50%",
top: "16%",
transform: "translateX(-50%)",
width: "38%",
maxHeight: "38%",
objectFit: "contain",
};
}
}
function previewTextStyle(
template: LayoutTemplate,
padPct: number,
textOffsetX: number,
textOffsetY: number,
titleArtistGap: number,
): CSSProperties {
const p = `${padPct}%`;
const shift = {
transform: undefined as string | undefined,
};
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
switch (template) {
case "COVER_LEFT_TEXT_RIGHT":
return {
...baseGap,
position: "absolute",
left: `calc(48% + ${textOffsetX}px)`,
right: p,
top: "50%",
transform: `translateY(calc(-50% + ${textOffsetY}px))`,
textAlign: "left",
};
case "COVER_RIGHT_TEXT_LEFT":
return {
...baseGap,
position: "absolute",
left: p,
right: `calc(48% - ${textOffsetX}px)`,
top: "50%",
transform: `translateY(calc(-50% + ${textOffsetY}px))`,
textAlign: "left",
};
case "COVER_TOP_TEXT_BOTTOM":
return {
...baseGap,
position: "absolute",
left: p,
right: p,
bottom: `calc(10% - ${textOffsetY}px)`,
transform: textOffsetX ? `translateX(${textOffsetX}px)` : undefined,
textAlign: "center",
alignItems: "center",
};
case "CENTERED_COMPACT":
return {
...baseGap,
position: "absolute",
left: p,
right: p,
top: `calc(58% + ${textOffsetY}px)`,
transform: textOffsetX ? `translateX(${textOffsetX}px)` : undefined,
textAlign: "center",
alignItems: "center",
};
}
void shift;
}
export function LayoutStudio({
locked,
previewImageUrl,
songTitle,
artist,
encodeWidth = 1280,
layout,
onLayoutChange,
watermark,
onWatermarkChange,
onUploadLogo,
onUploadFont,
logoPreviewUrl,
}: Props) {
const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [fontUploading, setFontUploading] = useState(false);
const [fontError, setFontError] = useState<string | null>(null);
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
// Always coalesce HMR / older session state may omit newly added fields
const L: LayoutSettings = {
...DEFAULT_LAYOUT,
...layout,
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
};
const W: WatermarkSettings = {
...DEFAULT_WATERMARK,
...watermark,
text: watermark.text ?? "",
offsetX: watermark.offsetX ?? DEFAULT_WATERMARK.offsetX,
offsetY: watermark.offsetY ?? DEFAULT_WATERMARK.offsetY,
fontKey: watermark.fontKey ?? "system",
};
const blurPx = useMemo(
() => Math.round((L.blurAmount / 100) * 28),
[L.blurAmount],
);
const padPct = useMemo(
() =>
3 +
((L.textPadding - TEXT_PADDING_MIN) / (TEXT_PADDING_MAX - TEXT_PADDING_MIN)) * 5,
[L.textPadding],
);
const wmOverlay = useMemo(
() => watermarkOverlayStyle(W.position, W.offsetX, W.offsetY),
[W.position, W.offsetX, W.offsetY],
);
const textFontStyle = useMemo(
() => ({ fontFamily: previewFontFamily(W.fontKey) }),
[W.fontKey],
);
const titlePreviewPx = useMemo(
() => scaleFontToPreview(titleFontSizeForWidth(encodeWidth), encodeWidth),
[encodeWidth],
);
const artistPreviewPx = useMemo(
() => scaleFontToPreview(artistFontSizeForWidth(encodeWidth), encodeWidth),
[encodeWidth],
);
const wmTextPreviewPx = useMemo(
() => scaleFontToPreview(watermarkFontSizeForWidth(encodeWidth), encodeWidth),
[encodeWidth],
);
useEffect(() => {
const styleId = "s2vid-preview-curated-fonts";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-display: swap;
}`,
).join("\n");
}, []);
useEffect(() => {
if (!customFontObjectUrl) return;
const styleId = "s2vid-watermark-custom-font";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = `
@font-face {
font-family: '${CUSTOM_PREVIEW_FAMILY}';
src: url('${customFontObjectUrl}');
font-display: swap;
}`;
}, [customFontObjectUrl]);
useEffect(() => {
return () => {
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
};
}, [customFontObjectUrl]);
function patchLayout(partial: Partial<LayoutSettings>) {
if (locked) return;
onLayoutChange({ ...DEFAULT_LAYOUT, ...layout, ...partial });
}
function patchWm(partial: Partial<WatermarkSettings>) {
if (locked) return;
onWatermarkChange({ ...DEFAULT_WATERMARK, ...watermark, ...partial });
}
async function handleLogo(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked) return;
setLogoError(null);
setLogoUploading(true);
try {
const path = await onUploadLogo(file);
patchWm({ mode: "logo", logoPath: path });
} catch (err) {
setLogoError(err instanceof Error ? err.message : "Logo upload failed");
} finally {
setLogoUploading(false);
}
}
async function handleFont(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked) return;
setFontError(null);
setFontUploading(true);
try {
const objectUrl = URL.createObjectURL(file);
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
setCustomFontObjectUrl(objectUrl);
const path = await onUploadFont(file);
patchWm({ fontKey: "custom", fontPath: path });
} catch (err) {
setFontError(err instanceof Error ? err.message : "Font upload failed");
} finally {
setFontUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) {
if (key === "custom") {
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
return;
}
patchWm({ fontKey: key, fontPath: null });
}
const artTrack = Boolean(L.template);
return (
<div
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
locked ? "opacity-80" : ""
}`}
>
{locked && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
Pro
</span>
<p className="max-w-sm text-sm text-gray-200">
Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features.
</p>
<UpgradeProButton
label="Unlock video layout studio"
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
/>
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Video layout</h3>
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
Pro
</span>
</div>
{/* Single live preview: art-track + watermark */}
<div
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-black"
aria-hidden={locked}
>
{previewImageUrl ? (
<>
{artTrack ? (
<>
<div className="absolute inset-0 bg-black" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
transform: "scale(1.15)",
opacity: L.blurOpacity / 100,
}}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
style={previewCoverStyle(L.template!, padPct)}
className="pointer-events-none"
/>
<div
style={previewTextStyle(
L.template!,
padPct,
L.textOffsetX,
L.textOffsetY,
L.titleArtistGap,
)}
>
<p
className="max-w-full truncate font-semibold text-white drop-shadow"
style={{
...textFontStyle,
fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15,
}}
>
{songTitle.trim() || "Track title"}
</p>
<p
className="max-w-full truncate text-white/75 drop-shadow"
style={{
...textFontStyle,
fontSize: `${artistPreviewPx}px`,
lineHeight: 1.15,
}}
>
{artist.trim() || "Artist"}
</p>
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
</div>
)}
{W.mode !== "none" && (
<div style={wmOverlay}>
{W.mode === "default" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src="/api/branding/watermark"
alt=""
className="h-auto object-contain"
style={{ width: `${WATERMARK_WIDTH_FRACTION * 100}%` }}
/>
) : W.mode === "logo" && logoPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoPreviewUrl}
alt=""
className="h-auto object-contain"
style={{ width: `${WATERMARK_WIDTH_FRACTION * 100}%` }}
/>
) : W.mode === "text" ? (
<span
className="font-medium text-white/90 drop-shadow"
style={{
...textFontStyle,
fontSize: `${wmTextPreviewPx}px`,
}}
>
{W.text?.trim()
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
: getVideoAttributionText()}
</span>
) : null}
</div>
)}
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-600">
Upload a cover image to preview
</div>
)}
</div>
{/* Art-track templates */}
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
<MiniThumb
template={null}
active={L.template === null}
disabled={locked}
onClick={() => patchLayout({ template: null })}
/>
{LAYOUT_TEMPLATES.map((t) => (
<MiniThumb
key={t}
template={t}
active={L.template === t}
disabled={locked}
onClick={() => patchLayout({ template: t })}
/>
))}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
Background blur ({L.blurAmount}%)
<input
type="range"
min={BLUR_AMOUNT_MIN}
max={BLUR_AMOUNT_MAX}
disabled={locked || !artTrack}
value={L.blurAmount}
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label className="text-sm text-gray-400">
Background opacity ({L.blurOpacity}%)
<input
type="range"
min={BLUR_OPACITY_MIN}
max={BLUR_OPACITY_MAX}
disabled={locked || !artTrack}
value={L.blurOpacity}
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label className="text-sm text-gray-400">
Cover / edge padding ({L.textPadding}px)
<input
type="range"
min={TEXT_PADDING_MIN}
max={TEXT_PADDING_MAX}
disabled={locked || !artTrack}
value={L.textPadding}
onChange={(e) => patchLayout({ textPadding: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label className="text-sm text-gray-400">
Title artist gap ({L.titleArtistGap}px)
<input
type="range"
min={TITLE_ARTIST_GAP_MIN}
max={TITLE_ARTIST_GAP_MAX}
disabled={locked || !artTrack}
value={L.titleArtistGap}
onChange={(e) =>
patchLayout({ titleArtistGap: Number(e.target.value) })
}
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label className="text-sm text-gray-400">
Text horizontal ({L.textOffsetX}px)
<input
type="range"
min={TEXT_OFFSET_MIN}
max={TEXT_OFFSET_MAX}
disabled={locked || !artTrack}
value={L.textOffsetX}
onChange={(e) => patchLayout({ textOffsetX: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
/>
</label>
<label className="text-sm text-gray-400 sm:col-span-2">
Text vertical ({L.textOffsetY}px)
<input
type="range"
min={TEXT_OFFSET_MIN}
max={TEXT_OFFSET_MAX}
disabled={locked || !artTrack}
value={L.textOffsetY}
onChange={(e) => patchLayout({ textOffsetY: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
/>
</label>
</div>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
<div className="mt-6 border-t border-gray-800 pt-5">
<p className="mb-3 text-sm font-medium text-gray-300">Typography</p>
<label className="block text-sm text-gray-400">
Font
<select
disabled={locked}
value={W.fontKey ?? "system"}
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">Arial (system default)</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
<option value="custom">Custom upload (.ttf / .otf)</option>
</select>
</label>
{W.fontKey === "custom" && (
<div className="mt-3">
<label className="mb-1 block text-sm text-gray-400">
Upload font (.ttf or .otf, max 10 MB)
</label>
<input
type="file"
accept=".ttf,.otf,font/ttf,font/otf"
disabled={locked || fontUploading}
onChange={(e) => void handleFont(e)}
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"
/>
{fontUploading && (
<p className="mt-1 text-xs text-yellow-400">Uploading font</p>
)}
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
</div>
)}
{(W.fontKey === "custom" ||
(W.fontKey &&
W.fontKey !== "system" &&
CURATED_FONTS.some((f) => f.key === (W.fontKey as CuratedFontKey)))) && (
<p
className="mt-2 text-xs text-gray-500"
style={{ ...textFontStyle, fontSize: `${scaleFontToPreview(16, encodeWidth)}px` }}
>
Preview: The quick brown fox jumps over the lazy dog
</p>
)}
</div>
{/* Watermark section */}
<div className="mt-6 border-t border-gray-800 pt-5">
<p className="mb-3 text-sm font-medium text-gray-300">Watermark</p>
<div className="flex flex-wrap gap-2">
{(
[
["none", "No watermark"],
["default", "Songs2VID badge"],
["text", "Custom text"],
["logo", "PNG logo"],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
disabled={locked}
onClick={() => patchWm({ mode: mode as WatermarkMode })}
className={`rounded border px-3 py-1.5 text-xs font-medium transition-colors ${
W.mode === mode
? "border-accent bg-accent/15 text-accent"
: "border-gray-700 text-gray-400 hover:border-gray-500"
} disabled:cursor-not-allowed`}
>
{label}
</button>
))}
</div>
{W.mode === "text" && (
<div className="mt-3 space-y-3">
<label className="block text-sm text-gray-400">
Watermark text
<input
type="text"
maxLength={WATERMARK_TEXT_MAX}
disabled={locked}
value={W.text ?? ""}
onChange={(e) => patchWm({ text: e.target.value })}
className="input-field mt-1"
placeholder="Your brand name"
/>
</label>
</div>
)}
{W.mode === "logo" && (
<div className="mt-3">
<label className="mb-1 block text-sm text-gray-400">PNG logo</label>
<input
type="file"
accept="image/png,.png"
disabled={locked || logoUploading}
onChange={(e) => void handleLogo(e)}
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"
/>
{logoUploading && (
<p className="mt-1 text-xs text-yellow-400">Uploading logo</p>
)}
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
</div>
)}
<div className="mt-4">
<p className="mb-2 text-sm text-gray-400">Watermark position</p>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{WATERMARK_POSITIONS.map((pos) => (
<button
key={pos}
type="button"
disabled={locked || W.mode === "none"}
onClick={() => patchWm({ position: pos })}
className={`rounded border px-2 py-2 text-[11px] font-medium ${
W.position === pos
? "border-accent bg-accent/15 text-accent"
: "border-gray-700 text-gray-400"
} disabled:opacity-40`}
>
{WM_POSITION_LABELS[pos]}
</button>
))}
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
Watermark offset X ({W.offsetX}px)
<input
type="range"
min={WATERMARK_OFFSET_MIN}
max={WATERMARK_OFFSET_MAX}
disabled={locked || W.mode === "none"}
value={W.offsetX}
onChange={(e) => patchWm({ offsetX: Number(e.target.value) })}
className="mt-1 w-full"
/>
</label>
<label className="text-sm text-gray-400">
Watermark offset Y ({W.offsetY}px)
<input
type="range"
min={WATERMARK_OFFSET_MIN}
max={WATERMARK_OFFSET_MAX}
disabled={locked || W.mode === "none"}
value={W.offsetY}
onChange={(e) => patchWm({ offsetY: Number(e.target.value) })}
className="mt-1 w-full"
/>
</label>
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
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} Songs2VID. All rights reserved.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<span>
Made with by{" "}
<LegalLink href="https://www.atakanozban.com" external>
atakan
</LegalLink>
</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>
);
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import { BRAND_NAME } from "@/lib/branding";
type Props = {
href?: string;
size?: "sm" | "md";
/** Small Beta mark at top-right of the wordmark (public marketing surfaces). */
beta?: boolean;
};
export function Logo({ href = "/", size = "md", beta = false }: Props) {
const textSize = size === "sm" ? "text-xl" : "text-2xl";
const betaSize = size === "sm" ? "text-[0.5rem]" : "text-[0.55rem]";
const content = (
<span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
<span className="text-white">Songs</span>
<span className="text-red-400">2VID</span>
{beta ? (
<span
className={`pointer-events-none absolute left-full top-0 ml-0.5 -translate-y-1/2 ${betaSize} font-bold uppercase tracking-wider text-red-400`}
aria-hidden="true"
>
Beta
</span>
) : null}
</span>
);
const label = beta ? `${BRAND_NAME} Beta` : BRAND_NAME;
if (href) {
return (
<Link href={href} className="inline-flex items-center" aria-label={label}>
{content}
</Link>
);
}
return (
<span className="inline-flex items-center" aria-label={label}>
{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(" ");
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
CREDIT_PRICE_CENTS,
CREDIT_PURCHASE_MAX,
CREDIT_PURCHASE_MIN,
formatCreditPrice,
} from "@/lib/credits";
export function PaygPriceCalculator() {
const [videos, setVideos] = useState(CREDIT_PURCHASE_MIN);
const total = formatCreditPrice(videos);
const unit = formatCreditPrice(1);
return (
<div className="mx-auto w-full max-w-xl rounded-2xl border border-gray-700/60 bg-surface/80 p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-400">
Pay as you go
</p>
<h3 className="mt-2 text-xl font-semibold text-white sm:text-2xl">
Choose how many videos you need
</h3>
<p className="mt-2 text-sm text-gray-400">
{unit} per video · minimum {CREDIT_PURCHASE_MIN} videos (
{formatCreditPrice(CREDIT_PURCHASE_MIN)})
</p>
<div className="mt-8">
<div className="flex items-end justify-between gap-4">
<label htmlFor="payg-videos" className="text-sm text-gray-300">
Videos
</label>
<div className="flex items-center gap-2">
<input
id="payg-videos-number"
type="number"
min={CREDIT_PURCHASE_MIN}
max={CREDIT_PURCHASE_MAX}
value={videos}
onChange={(e) => {
const n = Math.floor(Number(e.target.value));
if (!Number.isFinite(n)) return;
setVideos(
Math.min(CREDIT_PURCHASE_MAX, Math.max(CREDIT_PURCHASE_MIN, n)),
);
}}
className="w-20 rounded border border-gray-600 bg-surface-dark px-3 py-2 text-center text-lg font-semibold text-white focus:border-accent focus:outline-none"
/>
</div>
</div>
<input
id="payg-videos"
type="range"
min={CREDIT_PURCHASE_MIN}
max={CREDIT_PURCHASE_MAX}
value={videos}
onChange={(e) => setVideos(Number(e.target.value))}
className="mt-4 w-full accent-amber-400"
aria-valuemin={CREDIT_PURCHASE_MIN}
aria-valuemax={CREDIT_PURCHASE_MAX}
aria-valuenow={videos}
aria-label="Number of videos"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{CREDIT_PURCHASE_MIN}</span>
<span>{CREDIT_PURCHASE_MAX}</span>
</div>
</div>
<div className="mt-8 flex flex-wrap items-end justify-between gap-4 border-t border-gray-800 pt-6">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500">Total</p>
<p className="mt-1 text-3xl font-bold text-white sm:text-4xl">{total}</p>
<p className="mt-1 text-sm text-gray-400">
{videos} × {unit}{" "}
<span className="text-gray-500">
({(CREDIT_PRICE_CENTS / 100).toFixed(2)} each)
</span>
</p>
</div>
<Link
href="/dashboard/settings"
className="inline-flex rounded border border-amber-500/40 bg-amber-500/10 px-5 py-3 text-sm font-medium text-amber-300 transition-colors hover:border-amber-500/60 hover:bg-amber-500/20"
>
Buy credits
</Link>
</div>
</div>
);
}
+412
View File
@@ -0,0 +1,412 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { QUOTA_REQUEST_EMAIL } from "@/lib/plans";
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;
};
function openQuotaRequestMailto(reason?: string) {
const subject = encodeURIComponent("Songs2VID Quota reset / extension request");
const body = encodeURIComponent(
[
"Hi,",
"",
"I'd like to request a Pro quota reset or temporary extension.",
"",
reason?.trim() ? `Reason:\n${reason.trim()}` : "Reason: (optional add details here)",
"",
"Account email: (please keep the address you use to sign in)",
"",
"Thanks,",
].join("\n"),
);
window.location.href = `mailto:${QUOTA_REQUEST_EMAIL}?subject=${subject}&body=${body}`;
}
function formatEndDate(iso: string | null) {
if (!iso) return null;
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function PlanBillingActions({ initialUsage }: Props) {
const router = useRouter();
const [usage, setUsage] = useState(initialUsage);
const [cancelling, setCancelling] = useState(false);
const [requesting, setRequesting] = useState(false);
const [openingPortal, setOpeningPortal] = useState(false);
const [showCancelChoices, setShowCancelChoices] = useState(false);
const [cancelAtPeriodEnd, setCancelAtPeriodEnd] = useState(false);
const [endsAt, setEndsAt] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/account/cancel-subscription");
if (!res.ok) return;
const data = await res.json();
if (cancelled) return;
setCancelAtPeriodEnd(Boolean(data.cancelAtPeriodEnd));
setEndsAt(data.endsAt ?? null);
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleOpenBillingPortal() {
setOpeningPortal(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/billing-portal", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not open billing portal");
if (!data.url) throw new Error("No portal URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Could not open billing portal");
setOpeningPortal(false);
}
}
async function cancelWithMode(when: "immediate" | "period_end") {
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ when }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
setShowCancelChoices(false);
if (when === "period_end") {
setCancelAtPeriodEnd(true);
setEndsAt(data.endsAt ?? null);
const label = formatEndDate(data.endsAt ?? null);
setSuccess(
label
? `Cancellation scheduled. You keep Pro until ${label}; no further charges after that.`
: "Cancellation scheduled at the end of your current billing period. You keep Pro until then.",
);
} else {
setCancelAtPeriodEnd(false);
setEndsAt(null);
setSuccess("Subscription canceled immediately. You are now on the Free plan.");
}
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
} finally {
setCancelling(false);
}
}
async function handleResumeSubscription() {
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resume" }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to resume subscription");
setCancelAtPeriodEnd(false);
setEndsAt(data.currentPeriodEnd ?? null);
setSuccess("Subscription kept. It will renew as usual.");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resume 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 recorded (${data.used} of ${data.limit} this year). Opening your email client to contact support…`,
);
openQuotaRequestMailto(reason);
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");
const endLabel = formatEndDate(endsAt);
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>
{cancelAtPeriodEnd && (
<div className="rounded border border-yellow-600/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100">
<p>
Cancellation scheduled
{endLabel ? (
<>
{" "}
Pro stays active until <strong>{endLabel}</strong>
</>
) : (
<> at the end of your current billing period</>
)}
. You will not be charged again.
</p>
<button
type="button"
onClick={() => void handleResumeSubscription()}
disabled={cancelling}
className="mt-2 text-sm font-medium text-accent underline hover:text-white disabled:opacity-50"
>
{cancelling ? "Working…" : "Keep my Pro subscription"}
</button>
</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={() => void handleOpenBillingPortal()}
disabled={openingPortal}
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-gray-800 disabled:opacity-50"
>
{openingPortal ? "Opening…" : "Manage billing & card"}
</button>
{!cancelAtPeriodEnd && (
<button
type="button"
onClick={() => {
setShowCancelChoices(true);
setError(null);
setSuccess(null);
}}
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"
>
Cancel your plan
</button>
)}
<a
href={`mailto:${QUOTA_REQUEST_EMAIL}?subject=${encodeURIComponent("Songs2VID Quota reset / extension request")}`}
onClick={(e) => {
if (requesting || usage.remaining <= 0 || hasPending) {
e.preventDefault();
return;
}
e.preventDefault();
void handleQuotaRequest();
}}
aria-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 ${
requesting || usage.remaining <= 0 || hasPending
? "pointer-events-none cursor-not-allowed opacity-50"
: ""
}`}
>
{requesting
? "Submitting…"
: hasPending
? "Request pending…"
: "Request quota reset & extension"}
</a>
</div>
{showCancelChoices && (
<div
role="dialog"
aria-labelledby="cancel-plan-title"
className="rounded border border-yellow-600/40 bg-surface px-4 py-4"
>
<h3 id="cancel-plan-title" className="text-sm font-semibold text-white">
When should Pro end?
</h3>
<p className="mt-1 text-xs text-gray-400">
This is sent to Stripe. Choose how you want to cancel your subscription.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
disabled={cancelling}
onClick={() => void cancelWithMode("period_end")}
className="rounded border border-gray-600 bg-surface-light px-3 py-3 text-left transition-colors hover:border-accent/50 hover:bg-accent/5 disabled:opacity-50"
>
<span className="block text-sm font-medium text-white">
At end of billing period
</span>
<span className="mt-1 block text-xs text-gray-400">
Keep Pro until your paid period ends. No more renewals after that.
</span>
</button>
<button
type="button"
disabled={cancelling}
onClick={() => void cancelWithMode("immediate")}
className="rounded border border-yellow-600/50 bg-yellow-500/5 px-3 py-3 text-left transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
<span className="block text-sm font-medium text-yellow-200">
Cancel immediately
</span>
<span className="mt-1 block text-xs text-gray-400">
End Pro now and switch to Free. Access ends right away.
</span>
</button>
</div>
<button
type="button"
disabled={cancelling}
onClick={() => setShowCancelChoices(false)}
className="mt-3 text-xs text-gray-500 underline hover:text-gray-300 disabled:opacity-50"
>
{cancelling ? "Working…" : "Never mind"}
</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 by emailing{" "}
<a
href={`mailto:${QUOTA_REQUEST_EMAIL}`}
className="text-gray-400 underline hover:text-white"
>
{QUOTA_REQUEST_EMAIL}
</a>
. 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 Songs2VID 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>
);
}
+336
View File
@@ -0,0 +1,336 @@
"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 { getVideoAttributionText } from "@/lib/branding";
import { SALES_EMAIL } from "@/lib/plans";
const FEATURE_LABELS = [
"Infrastructure",
"Limit",
"Batch mode",
"Bulk image matching",
"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: "10 videos* / month · 720p · buy 115 extras (€0.25 each)",
"Batch mode": "Limited batch · up to 3 files",
"Bulk image matching": "1 static image only",
"File type": "MP3",
Playlists: "Not included",
"API support": "Not included",
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
Support: "Community",
Watermark: "Optional Songs2VID badge · bottom-right",
},
infrastructureChips: ["cloud"],
watermarkNote:
`Show "${getVideoAttributionText()}" 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",
"Bulk image matching": "Unique image per track (PRO)",
"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: "Custom text/logo/fonts · art-track layouts + blur (PRO)",
},
infrastructureChips: ["cloud"],
cta: { type: "link", label: "Upgrade to Pro", href: "/dashboard/settings" },
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",
"Bulk image matching": "Unique image per track",
"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: "Custom branding · position studio",
},
infrastructureChips: ["cloud", "self-hosted"],
cta: {
type: "mailto",
label: "Contact Sales",
email: SALES_EMAIL,
subject: "Songs2VID 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 with optional extras, Pro at 5/month, or Enterprise for studios.
</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">
*Free includes 10 videos/month; buy 115 extras at 0.25 each (max 15 extras). After both
are used, Pro (5/mo · 50 videos) is required. Total balance capped at 30. Deduction
order: monthly first, then extras.
</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>
);
}
+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" />;
}
+7 -3
View File
@@ -1,14 +1,18 @@
"use client";
import { RESOLUTIONS } from "@/lib/constants";
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 }: Props) {
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
const resolutions = getResolutionsForPlan(plan);
return (
<select
value={value}
@@ -16,7 +20,7 @@ export function ResolutionSelect({ value, onChange, disabled }: Props) {
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) => (
{resolutions.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
+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>
);
}
+29 -4
View File
@@ -2,19 +2,44 @@
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 }: Props) {
export function SignInButton({ large, fullWidth }: Props) {
return (
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className={`rounded bg-white font-medium text-gray-900 transition-colors hover:bg-gray-100 ${
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" : ""}`}
>
Sign in with Google
<GoogleIcon />
Continue with Google
</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, Songs2VID 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>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type Props = {
className?: string;
label?: string;
};
/** Starts Pro Checkout (€5/mo) or applies a dev mock upgrade. */
export function UpgradeProButton({
className = "text-accent hover:underline",
label = "Upgrade to Pro",
}: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleUpgrade() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/subscribe", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Upgrade failed");
if (data.mocked) {
router.refresh();
return;
}
if (!data.url) throw new Error("No checkout URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Upgrade failed");
setLoading(false);
}
}
return (
<span className="inline">
<button
type="button"
onClick={handleUpgrade}
disabled={loading}
className={className}
>
{loading ? "Starting…" : label}
</button>
{error && <span className="ml-2 text-sm text-red-400">{error}</span>}
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { PRO_UPGRADE_REQUIRED_SUFFIX } from "@/lib/credits";
import { UPGRADE_URL } from "@/lib/plans";
import { UpgradeProButton } from "@/components/UpgradeProButton";
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_REQUIRED_SUFFIX)) {
return (
<>
{message.slice(0, -PRO_UPGRADE_REQUIRED_SUFFIX.length)}{" "}
<UpgradeProButton
className="font-medium text-red-200 underline hover:text-white"
label="Upload up to 50 videos with Pro!"
/>
</>
);
}
return <>{message}</>;
}
+506 -60
View File
@@ -1,19 +1,34 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Privacy } from "@prisma/client";
import { Plan, Privacy } from "@prisma/client";
import { filenameWithoutExtension } from "@/lib/constants";
import { getVideoAttributionText } from "@/lib/branding";
import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout";
import type { ItemMetadata } from "@/lib/types";
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
import { CategorySelect } from "./CategorySelect";
import { LayoutStudio } from "./LayoutStudio";
import { PlaylistSelect } from "./PlaylistSelect";
import { PrivacyToggle } from "./PrivacyToggle";
import { ResolutionSelect } from "./ResolutionSelect";
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
import { UpgradeProButton } from "./UpgradeProButton";
const UPLOAD_CONCURRENCY = 4;
type AudioItem = {
id: string;
file: File;
path: string | null;
uploading: boolean;
itemImagePath: string | null;
itemImageName: string | null;
itemImagePreview: string | null;
metadata: ItemMetadata;
};
@@ -30,24 +45,69 @@ function defaultMetadata(title = ""): ItemMetadata {
embeddable: true,
creativeCommons: false,
includeWatermark: true,
playlistId: null,
imagePath: null,
artist: null,
songTitle: null,
watermark: { ...DEFAULT_WATERMARK },
layout: { ...DEFAULT_LAYOUT },
};
}
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 [imagePreviewUrl, setImagePreviewUrl] = 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 [quota, setQuota] = useState<{ remaining: number; limit: number } | null>(null);
const [playlistId, setPlaylistId] = useState("");
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{
remaining: number;
limit: number;
plan: Plan;
maxBatchSize: number;
resetsIn: string;
videoCredits: number;
extraCredits: number;
totalAvailable: number;
used: number;
selfHosted?: boolean;
customWatermark?: boolean;
perItemImages?: boolean;
artTrackLayouts?: boolean;
} | null>(null);
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
const canPerItemImages = Boolean(quota?.perItemImages || isPro);
const canCustomWatermark = Boolean(quota?.customWatermark || isPro);
const canArtTrackLayouts = Boolean(quota?.artTrackLayouts || isPro);
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 });
setQuota({
remaining: data.remaining,
limit: data.limit,
plan: data.plan,
maxBatchSize: data.maxBatchSize,
resetsIn: data.resetsIn,
videoCredits: data.videoCredits ?? data.extraCredits ?? 0,
extraCredits: data.extraCredits ?? data.videoCredits ?? 0,
totalAvailable: data.totalAvailable ?? data.remaining,
used: data.used ?? 0,
selfHosted: Boolean(data.selfHosted),
customWatermark: Boolean(data.customWatermark),
perItemImages: Boolean(data.perItemImages),
artTrackLayouts: Boolean(data.artTrackLayouts),
});
}
}, []);
@@ -55,43 +115,35 @@ export function UploadForm() {
loadQuota();
}, [loadQuota]);
async function uploadFile(file: File, type: "image" | "audio"): Promise<string> {
useEffect(() => {
return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
audioItems.forEach((a) => {
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup on unmount only
}, []);
async function uploadFile(
file: File,
type: "image" | "audio" | "logo" | "font",
): 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 });
const data = await res.json();
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Upload failed");
}
const data = await res.json();
return data.path;
return { path: data.path, audioTags: data.audioTags };
}
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);
} 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);
for (const file of files) {
async function uploadFilesWithConcurrency(files: File[]) {
const pending = files.map((file) => {
const id = crypto.randomUUID();
const autoTitle = filenameWithoutExtension(file.name);
@@ -102,23 +154,186 @@ export function UploadForm() {
file,
path: null,
uploading: true,
metadata: defaultMetadata(autoTitle),
itemImagePath: null,
itemImageName: null,
itemImagePreview: null,
metadata: {
...defaultMetadata(autoTitle),
playlistId: playlistId || null,
watermark: { ...jobWatermark },
layout: { ...jobLayout },
},
},
]);
try {
const path = await uploadFile(file, "audio");
setAudioItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, path, uploading: false } : item,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Audio upload failed");
setAudioItems((prev) => prev.filter((item) => item.id !== id));
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);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImagePreviewUrl(URL.createObjectURL(file));
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);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImagePreviewUrl(null);
} finally {
setImageUploading(false);
}
}
async function handleItemImageChange(itemId: string, file: File | null) {
if (!canPerItemImages) {
setError("Matching a unique image per audio requires Pro.");
return;
}
if (!file) return;
setError(null);
const preview = URL.createObjectURL(file);
setAudioItems((prev) =>
prev.map((item) => {
if (item.id !== itemId) return item;
if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview);
return {
...item,
itemImageName: file.name,
itemImagePreview: preview,
itemImagePath: null,
};
}),
);
try {
const uploaded = await uploadFile(file, "image");
setAudioItems((prev) =>
prev.map((item) =>
item.id === itemId
? {
...item,
itemImagePath: uploaded.path,
metadata: { ...item.metadata, imagePath: uploaded.path },
}
: item,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Per-item image upload failed");
setAudioItems((prev) =>
prev.map((item) => {
if (item.id !== itemId) return item;
if (item.itemImagePreview) URL.revokeObjectURL(item.itemImagePreview);
return {
...item,
itemImagePath: null,
itemImageName: null,
itemImagePreview: null,
metadata: { ...item.metadata, imagePath: null },
};
}),
);
}
}
async function handleLogoUpload(file: File) {
const preview = URL.createObjectURL(file);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
setLogoPreviewUrl(preview);
const uploaded = await uploadFile(file, "logo");
return uploaded.path;
}
async function handleFontUpload(file: File) {
const uploaded = await uploadFile(file, "font");
return uploaded.path;
}
function applyWatermarkToAll(next: WatermarkSettings) {
const normalized = { ...DEFAULT_WATERMARK, ...next };
setJobWatermark(normalized);
setAudioItems((prev) =>
prev.map((item) => ({
...item,
metadata: {
...item.metadata,
includeWatermark: normalized.mode !== "none",
watermark: { ...normalized },
},
})),
);
}
function applyLayoutToAll(next: LayoutSettings) {
const normalized = { ...DEFAULT_LAYOUT, ...next };
setJobLayout(normalized);
setAudioItems((prev) =>
prev.map((item) => ({
...item,
metadata: {
...item.metadata,
layout: { ...normalized },
},
})),
);
}
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 = "";
}
@@ -132,6 +347,19 @@ export function UploadForm() {
);
}
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));
}
@@ -141,7 +369,18 @@ export function UploadForm() {
imagePath &&
!imageUploading &&
readyAudios.length > 0 &&
readyAudios.every((a) => a.metadata.title.trim()) &&
readyAudios.every((a) =>
Boolean(
resolveYouTubeTitle(
{
title: a.metadata.title,
songTitle: a.metadata.songTitle,
artist: a.metadata.artist,
},
isPro ? Plan.PREMIUM : Plan.FREE,
),
),
) &&
!submitting;
async function handleSubmit(e: React.FormEvent) {
@@ -160,13 +399,31 @@ export function UploadForm() {
items: readyAudios.map((item) => ({
audioPath: item.path!,
audioFilename: item.file.name,
metadata: item.metadata,
metadata: {
...item.metadata,
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null,
includeWatermark: jobWatermark.mode !== "none",
watermark: canCustomWatermark
? jobWatermark
: {
mode: jobWatermark.mode === "none" ? "none" : "default",
position: "bottom-right",
offsetX: 20,
offsetY: 20,
},
layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT },
},
})),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create job");
if (!res.ok) {
if (data.code === "PREMIUM_REQUIRED") {
throw new Error(data.error || "This feature requires Pro.");
}
throw new Error(data.error || "Failed to create job");
}
router.push(`/jobs/${data.jobId}`);
} catch (err) {
@@ -179,13 +436,38 @@ export function UploadForm() {
<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.remaining} of {quota.limit} videos remaining this month
{quota.selfHosted ? (
<>
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
batch
</>
) : (
<>
{quota.remaining} of {quota.limit} plan videos remaining
{quota.extraCredits > 0
? ` · ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)`
: ""}{" "}
·{" "}
{quota.plan === "FREE"
? `resets in ${quota.resetsIn}`
: `monthly quota resets on ${quota.resetsIn}`}{" "}
· up to {quota.maxBatchSize} files per batch
{quota.plan === "FREE" ? (
<>
{" · "}
<a href="/dashboard/settings" className="text-accent hover:underline">
Buy 115 credits
</a>
</>
) : null}
</>
)}
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
<QuotaErrorMessage message={error} />
</div>
)}
@@ -207,13 +489,22 @@ export function UploadForm() {
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
</span>
</div>
<p className="mt-2 text-xs text-gray-500">
{canPerItemImages
? "Shared cover for all tracks. Optionally override per audio below (Pro)."
: "Free plan: one static cover for the whole batch."}
</p>
</div>
<div>
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
<input
type="file"
accept="audio/*"
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"
@@ -234,9 +525,18 @@ export function UploadForm() {
<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.
Each audio file gets its own metadata. Video title is used for YouTube; song title and
artist appear in Pro art-track layouts.
</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}
@@ -261,16 +561,50 @@ export function UploadForm() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Title">
<Field label="Video title (YouTube)">
<input
type="text"
value={item.metadata.title}
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
className="input-field"
required
placeholder="Title shown on YouTube"
/>
</Field>
<ProLockedField
label="Song title (on-video)"
locked={!isPro}
hint="Pro unlocks custom song title burned into art-track layouts."
>
<input
type="text"
value={item.metadata.songTitle ?? ""}
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
className="input-field disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isPro}
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
maxLength={SONG_TITLE_MAX}
/>
</ProLockedField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ProLockedField
label="Artist (on-video)"
locked={!isPro}
hint="Pro unlocks custom artist line in art-track layouts."
>
<input
type="text"
value={item.metadata.artist ?? ""}
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
className="input-field disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isPro}
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
maxLength={80}
/>
</ProLockedField>
<Field label="Category">
<CategorySelect
value={item.metadata.categoryId}
@@ -309,9 +643,50 @@ export function UploadForm() {
<ResolutionSelect
value={item.metadata.resolution}
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
plan={quota?.plan}
/>
</Field>
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
{!canPerItemImages && (
<div className="mb-2 flex items-center gap-2">
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent">
Pro
</span>
<span className="text-xs text-gray-500">Unique image per track</span>
</div>
)}
<Field
label={
canPerItemImages
? "Cover for this track (optional)"
: "Cover for this track (Pro)"
}
>
<input
type="file"
accept="image/*"
disabled={!canPerItemImages}
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
void handleItemImageChange(item.id, f);
e.target.value = "";
}}
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 disabled:opacity-50"
/>
</Field>
{item.itemImageName && (
<p className="mt-1 text-xs text-gray-500">
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}`}
</p>
)}
{!canPerItemImages && (
<p className="mt-2 text-xs text-gray-500">
<UpgradeProButton label="Upgrade for bulk unique image matching" />
</p>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Checkbox
label="Notify subscribers about this upload"
@@ -333,21 +708,58 @@ export function UploadForm() {
checked={item.metadata.creativeCommons}
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
/>
<Checkbox
label="Include s2yt watermark?"
checked={item.metadata.includeWatermark}
onChange={() => {}}
disabled
/>
{!canCustomWatermark && (
<Checkbox
label={`Show '${getVideoAttributionText()}' watermark (support open-source)`}
checked={jobWatermark.mode !== "none"}
onChange={(v) =>
applyWatermarkToAll({
...DEFAULT_WATERMARK,
mode: v ? "default" : "none",
})
}
/>
)}
</div>
</div>
))}
</section>
)}
<p className="text-sm text-accent">
Upgrade your account to remove watermarks, go ad-free, and unlock advanced settings.
</p>
<LayoutStudio
locked={!canArtTrackLayouts && !canCustomWatermark}
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
songTitle={
audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title ||
"Track title"
}
artist={audioItems[0]?.metadata.artist || ""}
encodeWidth={parseInt(readyAudios[0]?.metadata.resolution?.split("x")[0] || "1280", 10)}
layout={jobLayout}
onLayoutChange={applyLayoutToAll}
watermark={jobWatermark}
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
logoPreviewUrl={logoPreviewUrl}
/>
{quota && !quota.selfHosted && quota.plan === "FREE" && (
<>
<p className="text-sm text-gray-400">
Free plan: one static cover image for the batch · optional Songs2VID watermark
(bottom-right only).
</p>
<p className="text-sm text-gray-400">
<UpgradeProLink className="text-accent hover:underline" /> for custom branding, typography,
blurred art-track layouts, watermark positioning, unique image matching, 1080p, and
lossless audio.
</p>
</>
)}
<button
type="submit"
@@ -379,6 +791,40 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
);
}
function ProLockedField({
label,
locked,
hint,
children,
}: {
label: string;
locked: boolean;
hint: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="mb-1 flex items-center gap-2 text-sm text-gray-400">
{label}
{locked && (
<span
className="rounded border border-accent/50 bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent"
title={hint}
>
Pro
</span>
)}
</label>
<div className="relative">
{children}
{locked && (
<p className="mt-1 text-xs text-gray-500">{hint}</p>
)}
</div>
</div>
);
}
function Checkbox({
label,
checked,
+417
View File
@@ -0,0 +1,417 @@
"use client";
import {
useEffect,
useMemo,
useState,
type CSSProperties,
type ChangeEvent,
} from "react";
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
googleFontsStylesheetUrl,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
import {
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
WATERMARK_POSITIONS,
WATERMARK_TEXT_MAX,
type WatermarkMode,
type WatermarkPosition,
type WatermarkSettings,
} from "@/lib/watermark";
import { UpgradeProButton } from "./UpgradeProButton";
type Props = {
enabled: boolean;
locked: boolean;
previewImageUrl: string | null;
value: WatermarkSettings;
onChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
logoPreviewUrl?: string | null;
};
const POSITION_LABELS: Record<WatermarkPosition, string> = {
"top-left": "Top left",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-right": "Bottom right",
center: "Center",
};
const CUSTOM_PREVIEW_FAMILY = "S2VIDCustomWm";
function previewStyle(
position: WatermarkPosition,
offsetX: number,
offsetY: number,
): CSSProperties {
const base: CSSProperties = {
position: "absolute",
maxWidth: "32%",
pointerEvents: "none",
};
const ox = `${offsetX}px`;
const oy = `${offsetY}px`;
switch (position) {
case "top-left":
return { ...base, top: oy, left: ox };
case "top-right":
return { ...base, top: oy, right: ox };
case "bottom-left":
return { ...base, bottom: oy, left: ox };
case "center":
return {
...base,
top: "50%",
left: "50%",
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`,
};
case "bottom-right":
default:
return { ...base, bottom: oy, right: ox };
}
}
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
}
export function WatermarkPreview({
enabled,
locked,
previewImageUrl,
value,
onChange,
onUploadLogo,
onUploadFont,
logoPreviewUrl,
}: Props) {
const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [fontUploading, setFontUploading] = useState(false);
const [fontError, setFontError] = useState<string | null>(null);
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
const overlay = useMemo(
() => previewStyle(value.position, value.offsetX, value.offsetY),
[value.position, value.offsetX, value.offsetY],
);
const textFontStyle = useMemo(
() => ({ fontFamily: previewFontFamily(value.fontKey) }),
[value.fontKey],
);
// Load curated Google Fonts for live canvas preview
useEffect(() => {
const id = "s2vid-watermark-google-fonts";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = googleFontsStylesheetUrl(CURATED_FONTS.map((f) => f.key));
document.head.appendChild(link);
}, []);
// @font-face for uploaded custom font (browser preview only)
useEffect(() => {
if (!customFontObjectUrl) return;
const styleId = "s2vid-watermark-custom-font";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = `
@font-face {
font-family: '${CUSTOM_PREVIEW_FAMILY}';
src: url('${customFontObjectUrl}');
font-display: swap;
}`;
return () => {
/* keep style until next custom font replaces it */
};
}, [customFontObjectUrl]);
useEffect(() => {
return () => {
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
};
}, [customFontObjectUrl]);
function patch(partial: Partial<WatermarkSettings>) {
if (locked) return;
onChange({ ...value, ...partial });
}
async function handleLogo(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked) return;
setLogoError(null);
setLogoUploading(true);
try {
const path = await onUploadLogo(file);
patch({ mode: "logo", logoPath: path });
} catch (err) {
setLogoError(err instanceof Error ? err.message : "Logo upload failed");
} finally {
setLogoUploading(false);
}
}
async function handleFont(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked) return;
setFontError(null);
setFontUploading(true);
try {
const objectUrl = URL.createObjectURL(file);
if (customFontObjectUrl) URL.revokeObjectURL(customFontObjectUrl);
setCustomFontObjectUrl(objectUrl);
const path = await onUploadFont(file);
patch({ mode: "text", fontKey: "custom", fontPath: path });
} catch (err) {
setFontError(err instanceof Error ? err.message : "Font upload failed");
} finally {
setFontUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) {
if (key === "custom") {
patch({ fontKey: "custom", fontPath: value.fontPath ?? null });
return;
}
patch({ fontKey: key, fontPath: null });
}
return (
<div
className={`relative rounded-lg border border-gray-700 bg-surface p-6 ${
locked ? "opacity-80" : ""
}`}
>
{locked && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
Pro
</span>
<p className="max-w-sm text-sm text-gray-200">
Custom branding, typography, logo overlay, and position controls are Pro features.
</p>
<UpgradeProButton
label="Unlock watermark studio"
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
/>
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
Pro / B2B
</span>
</div>
<div
className="relative mx-auto aspect-video w-full max-w-xl overflow-hidden rounded border border-gray-800 bg-black"
aria-hidden={locked}
>
{previewImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewImageUrl} alt="" className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-600">
Upload a cover image to preview
</div>
)}
{enabled && value.mode !== "none" && (
<div style={overlay} className="rounded bg-black/40 px-2 py-1">
{value.mode === "logo" && logoPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoPreviewUrl} alt="" className="max-h-16 w-auto object-contain" />
) : (
<span
className="text-[11px] font-medium text-white/90 drop-shadow"
style={value.mode === "text" ? textFontStyle : undefined}
>
{value.mode === "text" && value.text?.trim()
? value.text.trim().slice(0, WATERMARK_TEXT_MAX)
: getVideoAttributionText()}
</span>
)}
</div>
)}
</div>
<div className="mt-4 space-y-4" aria-disabled={locked}>
<div className="flex flex-wrap gap-2">
{(
[
["none", "No watermark"],
["default", "Songs2VID badge"],
["text", "Custom text"],
["logo", "PNG logo"],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
disabled={locked}
onClick={() => patch({ mode: mode as WatermarkMode })}
className={`rounded border px-3 py-1.5 text-xs font-medium transition-colors ${
value.mode === mode
? "border-accent bg-accent/15 text-accent"
: "border-gray-700 text-gray-400 hover:border-gray-500"
} disabled:cursor-not-allowed`}
>
{label}
</button>
))}
</div>
{value.mode === "text" && (
<>
<label className="block text-sm text-gray-400">
Watermark text
<input
type="text"
maxLength={WATERMARK_TEXT_MAX}
disabled={locked}
value={value.text ?? ""}
onChange={(e) => patch({ text: e.target.value })}
className="input-field mt-1"
placeholder="Your brand name"
/>
</label>
<label className="block text-sm text-gray-400">
Font
<select
disabled={locked}
value={value.fontKey ?? "system"}
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">System default</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{f.label}
</option>
))}
<option value="custom">Custom upload (.ttf / .otf)</option>
</select>
</label>
{(value.fontKey === "custom" ||
(value.fontKey &&
value.fontKey !== "system" &&
CURATED_FONTS.some((f) => f.key === (value.fontKey as CuratedFontKey)))) && (
<p className="text-xs text-gray-500" style={textFontStyle}>
Preview: The quick brown fox jumps over the lazy dog
</p>
)}
{value.fontKey === "custom" && (
<div>
<label className="mb-1 block text-sm text-gray-400">
Upload font (.ttf or .otf, max 10 MB)
</label>
<input
type="file"
accept=".ttf,.otf,font/ttf,font/otf"
disabled={locked || fontUploading}
onChange={(e) => void handleFont(e)}
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"
/>
{fontUploading && (
<p className="mt-1 text-xs text-yellow-400">Uploading font</p>
)}
{fontError && <p className="mt-1 text-xs text-red-400">{fontError}</p>}
{value.fontPath && !fontError && (
<p className="mt-1 text-xs text-green-400">Custom font ready for render</p>
)}
</div>
)}
</>
)}
{value.mode === "logo" && (
<div>
<label className="mb-1 block text-sm text-gray-400">PNG logo</label>
<input
type="file"
accept="image/png,.png"
disabled={locked || logoUploading}
onChange={(e) => void handleLogo(e)}
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"
/>
{logoUploading && <p className="mt-1 text-xs text-yellow-400">Uploading logo</p>}
{logoError && <p className="mt-1 text-xs text-red-400">{logoError}</p>}
</div>
)}
<div>
<p className="mb-2 text-sm text-gray-400">Position</p>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{WATERMARK_POSITIONS.map((pos) => (
<button
key={pos}
type="button"
disabled={locked || value.mode === "none"}
onClick={() => patch({ position: pos })}
className={`rounded border px-2 py-2 text-[11px] font-medium ${
value.position === pos
? "border-accent bg-accent/15 text-accent"
: "border-gray-700 text-gray-400"
} disabled:opacity-40`}
>
{POSITION_LABELS[pos]}
</button>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400">
Offset X ({value.offsetX}px)
<input
type="range"
min={WATERMARK_OFFSET_MIN}
max={WATERMARK_OFFSET_MAX}
disabled={locked || value.mode === "none"}
value={value.offsetX}
onChange={(e) => patch({ offsetX: Number(e.target.value) })}
className="mt-1 w-full"
/>
</label>
<label className="text-sm text-gray-400">
Offset Y ({value.offsetY}px)
<input
type="range"
min={WATERMARK_OFFSET_MIN}
max={WATERMARK_OFFSET_MAX}
disabled={locked || value.mode === "none"}
value={value.offsetY}
onChange={(e) => patch({ offsetY: Number(e.target.value) })}
className="mt-1 w-full"
/>
</label>
</div>
</div>
</div>
);
}
+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>
);
}