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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Songs2YT
2026-07-25 03:50:06 +02:00
co-authored by Cursor
parent bcca0a6e6f
commit d951ecb489
53 changed files with 3258 additions and 1977 deletions
+24 -130
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { API_DOCS_URL } from "@/lib/plans";
type ApiKeyStatus = {
configured: boolean;
@@ -16,39 +17,16 @@ type RateLimitStatus = {
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) {
export function ApiKeySettings({ initialStatus, initialRateLimit }: 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);
@@ -81,146 +59,58 @@ export function ApiKeySettings({
setLoading(true);
setError(null);
setNewKey(null);
setSuccess(null);
setNewKey(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");
if (!res.ok) throw new Error(data.error || "Failed to generate key");
setNewKey(data.apiKey);
setStatus({ configured: true, prefix: data.prefix });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate API key");
setSuccess("API key generated. Copy it now — it will not be shown again.");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to generate key");
} finally {
setLoading(false);
}
}
async function revokeKey() {
if (!confirm("Revoke your API key? External integrations will stop working.")) return;
if (!confirm("Revoke the current API key?")) return;
setLoading(true);
setError(null);
setSuccess(null);
setNewKey(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");
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to revoke key");
setStatus({ configured: false, prefix: null });
setNewKey(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to revoke API key");
setSuccess("API key revoked.");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to revoke 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.
Use the REST API to upload files and create batch video jobs programmatically.
</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>
<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
Self-hosted: API rate limits are effectively unlimited.
</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 && (
@@ -284,9 +174,13 @@ export function ApiKeySettings({
</ul>
<p className="mt-3">
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
request. For many audio files, upload each file then call{" "}
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
<a href="/dashboard/api-docs" className="text-accent hover:underline">
request.{" "}
<a
href={API_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Full API docs
</a>
</p>
+4 -4
View File
@@ -77,7 +77,7 @@ function GearsIcon({ className }: { className?: string }) {
const BENEFITS = [
{
title: "No editing required",
desc: "Skip complex video editors. Just upload an image and audio, and Songs2YT handles the rest.",
desc: "Skip complex video editors. Just upload an image and audio, and Songs2VID handles the rest.",
Icon: FilmStripIcon,
},
{
@@ -86,13 +86,13 @@ const BENEFITS = [
Icon: YouTubeIcon,
},
{
title: "Free tier included",
desc: "Start creating with 14 videos every 12 hours at up to 720p. No credit card needed.",
title: "Self-hosted, unlocked",
desc: "No video quotas or paywalls. Art-track layouts, typography, watermarks, and the REST API are all available.",
Icon: CoinsIcon,
},
{
title: "YouTube playlists",
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
desc: "Create YouTube playlists and add every upload from the dashboard or API.",
Icon: PlaylistIcon,
},
] as const;
+15 -8
View File
@@ -2,10 +2,13 @@ import Link from "next/link";
import type { ReactNode } from "react";
import { Logo } from "@/components/Logo";
import {
API_DOCS_URL,
DOCKER_HUB_URL,
DOCS_URL,
GITEA_ISSUES_URL,
GITEA_URL,
SUPPORT_EMAIL,
WEBSITE_URL,
} from "@/lib/plans";
function GiteaIcon({ className }: { className?: string }) {
@@ -88,6 +91,7 @@ function LegalLink({
}
const STATUS_URL = "https://status.atakanozban.com/status/2";
const DOCS_INTRO_URL = `${DOCS_URL}/docs/intro`;
export function Footer() {
const year = new Date().getFullYear();
@@ -129,12 +133,19 @@ export function Footer() {
<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={API_DOCS_URL} external>
API docs
</FooterLink>
<FooterLink href={WEBSITE_URL} external>
Hosted Songs2VID
</FooterLink>
<FooterLink href="/privacy">Privacy Policy</FooterLink>
<FooterLink href="/terms">Terms of Service</FooterLink>
<FooterLink href="/refund">Refund Policy</FooterLink>
<FooterLink href={STATUS_URL} external>
Service Status
</FooterLink>
@@ -160,13 +171,13 @@ export function Footer() {
Contact
</span>
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
<span className="text-xs text-gray-600">Self-hosted community support</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
<span>© {year} Songs2YT. All rights reserved.</span>
<span>© {year} Songs2VID. All rights reserved.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
@@ -178,10 +189,6 @@ export function Footer() {
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/refund">Refund Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href={STATUS_URL} external>
Service Status
</LegalLink>
+4 -12
View File
@@ -20,12 +20,7 @@ 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;
plan: string;
resetsIn: string;
} | null>(null);
const [videosProcessed, setVideosProcessed] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -44,7 +39,7 @@ export function JobProgress({ jobId }: Props) {
if (quotaRes.ok) {
const quotaData = await quotaRes.json();
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit, plan: quotaData.plan, resetsIn: quotaData.resetsIn });
if (active) setVideosProcessed(quotaData.used ?? 0);
}
} catch (err) {
if (active) setError(err instanceof Error ? err.message : "Error loading job");
@@ -81,12 +76,9 @@ export function JobProgress({ jobId }: Props) {
Overall: <span className="text-white">{job.status}</span>
</p>
</div>
{quota && (
{videosProcessed !== null && (
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
{quota.remaining} / {quota.limit} videos remaining ·{" "}
{quota.plan === "FREE"
? `resets in ${quota.resetsIn}`
: `monthly quota resets on ${quota.resetsIn}`}
Self-hosted · {videosProcessed} videos processed
</div>
)}
</div>
+56 -16
View File
@@ -3,12 +3,14 @@
import { useState } from "react";
import { Logo } from "@/components/Logo";
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
import { DOCS_URL, WEBSITE_URL } from "@/lib/plans";
const NAV_LINKS = [
{ href: "#benefits", label: "Benefits" },
{ href: "#download", label: "Download" },
{ href: "#pricing", label: "Pricing" },
{ href: "#support", label: "Support" },
{ href: "#benefits", label: "Benefits", kind: "anchor" as const },
{ href: "#download", label: "Download", kind: "anchor" as const },
{ href: `${DOCS_URL}/docs/intro`, label: "Docs", kind: "external" as const },
{ href: WEBSITE_URL, label: "Hosted", kind: "external" as const },
{ href: "#support", label: "Support", kind: "anchor" as const },
] as const;
function NavAnchor({
@@ -37,6 +39,30 @@ function NavAnchor({
);
}
function ExternalLink({
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;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={() => onNavigate?.()}
className={className}
>
{label}
</a>
);
}
export function LandingNavbar() {
const [menuOpen, setMenuOpen] = useState(false);
@@ -48,9 +74,13 @@ export function LandingNavbar() {
<div className="flex items-center gap-4">
<nav className="hidden items-center gap-10 sm:flex">
{NAV_LINKS.map(({ href, label }) => (
<NavAnchor key={href} href={href} label={label} />
))}
{NAV_LINKS.map((link) =>
link.kind === "external" ? (
<ExternalLink key={link.href} href={link.href} label={link.label} />
) : (
<NavAnchor key={link.href} href={link.href} label={link.label} />
),
)}
</nav>
<MobileMenuButton
@@ -62,15 +92,25 @@ export function LandingNavbar() {
</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()}
/>
))}
{NAV_LINKS.map((link) =>
link.kind === "external" ? (
<ExternalLink
key={link.href}
href={link.href}
label={link.label}
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
) : (
<NavAnchor
key={link.href}
href={link.href}
label={link.label}
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
),
)}
</MobileSidebar>
</>
);
+792
View File
@@ -0,0 +1,792 @@
"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 {
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 {
DEFAULT_WATERMARK,
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
WATERMARK_POSITIONS,
WATERMARK_TEXT_MAX,
type WatermarkMode,
type WatermarkPosition,
type WatermarkSettings,
} from "@/lib/watermark";
type Props = {
locked?: boolean;
previewImageUrl: string | null;
title: string;
artist: string;
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 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 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";
}
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 = false,
previewImageUrl,
title,
artist,
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],
);
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);
}, []);
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({ 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") {
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">
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Video layout</h3>
</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 text-sm font-semibold text-white drop-shadow"
style={
W.mode === "text" ? textFontStyle : undefined
}
>
{title.trim() || "Track title"}
</p>
<p
className="max-w-full truncate text-xs text-white/75 drop-shadow"
style={
W.mode === "text" ? textFontStyle : undefined
}
>
{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} className="rounded bg-black/40 px-2 py-1">
{W.mode === "logo" && logoPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoPreviewUrl}
alt=""
className="max-h-14 w-auto object-contain"
/>
) : (
<span
className="text-[11px] font-medium text-white/90 drop-shadow"
style={W.mode === "text" ? textFontStyle : undefined}
>
{W.mode === "text" && W.text?.trim()
? W.text.trim().slice(0, WATERMARK_TEXT_MAX)
: getVideoAttributionText()}
</span>
)}
</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>
{/* 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>
<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">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>
<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="text-xs text-gray-500" style={textFontStyle}>
Preview: The quick brown fox jumps over the lazy dog
</p>
)}
</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>
);
}
-199
View File
@@ -1,199 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type ExtensionRequest = {
id: string;
status: string;
message: string;
requestedAt: string;
processedAt: string | null;
adminNote: string | null;
};
type ExtensionUsage = {
used: number;
limit: number;
remaining: number;
requests: ExtensionRequest[];
};
type Props = {
initialUsage: ExtensionUsage;
};
export function PlanBillingActions({ initialUsage }: Props) {
const router = useRouter();
const [usage, setUsage] = useState(initialUsage);
const [cancelling, setCancelling] = useState(false);
const [requesting, setRequesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function handleCancelPlan() {
if (
!confirm(
"Cancel your Pro plan? You will be moved to the free plan immediately and lose Pro benefits.",
)
) {
return;
}
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
} finally {
setCancelling(false);
}
}
async function handleQuotaRequest() {
if (usage.remaining <= 0) return;
const reason = prompt(
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
);
if (reason === null) return;
setRequesting(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/quota-extension-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to submit request");
setUsage({
used: data.used,
limit: data.limit,
remaining: data.remaining,
requests: data.requests ?? usage.requests,
});
setSuccess(
`Request submitted. ${data.used} of ${data.limit} extension requests used this year. Support will process it shortly.`,
);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit request");
} finally {
setRequesting(false);
}
}
const hasPending = usage.requests.some((r) => r.status === "PENDING");
return (
<div className="mt-4 space-y-4">
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
<p className="text-gray-300">
Extension requests this year:{" "}
<span className="font-medium text-white">
{usage.used} / {usage.limit}
</span>
{usage.remaining > 0 ? (
<span className="text-gray-500"> · {usage.remaining} remaining</span>
) : (
<span className="text-yellow-400"> · limit reached</span>
)}
</p>
</div>
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={handleCancelPlan}
disabled={cancelling}
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
{cancelling ? "Cancelling…" : "Cancel your plan"}
</button>
<button
type="button"
onClick={handleQuotaRequest}
disabled={requesting || usage.remaining <= 0 || hasPending}
className="inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{requesting
? "Submitting…"
: hasPending
? "Request pending…"
: "Request quota reset & extension"}
</button>
</div>
{usage.requests.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Request history
</p>
<ul className="space-y-2">
{usage.requests.slice(0, 5).map((req) => (
<li
key={req.id}
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
>
<div className="flex items-center justify-between gap-2">
<span className="text-gray-300">
{new Date(req.requestedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span
className={
req.status === "APPROVED"
? "text-green-400"
: req.status === "REJECTED"
? "text-red-400"
: "text-yellow-400"
}
>
{req.status}
</span>
</div>
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-500">
Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "}
<a href="/terms" className="text-gray-400 underline hover:text-white">
Terms of Service
</a>
.
</p>
</div>
);
}
+1 -9
View File
@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { UpgradeProLink } from "./UpgradeProLink";
type Playlist = {
id: string;
@@ -80,14 +79,7 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
}
}
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>
);
}
if (!enabled) { return null; }
return (
<div className="space-y-3">
-330
View File
@@ -1,330 +0,0 @@
"use client";
import Link from "next/link";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { SignInButton } from "@/components/SignInButton";
import { SALES_EMAIL } from "@/lib/plans";
const FEATURE_LABELS = [
"Infrastructure",
"Limit",
"Batch mode",
"File type",
"Playlists",
"API support",
"ID3 tags",
"Support",
"Watermark",
] as const;
type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>;
type InfrastructureChip = "cloud" | "self-hosted";
type PlanConfig = {
name: string;
badge: string;
price: string;
priceNote: string;
features: PlanFeatures;
infrastructureChips: InfrastructureChip[];
watermarkNote?: string;
cta:
| { type: "signin" }
| { type: "link"; label: string; href: string }
| { type: "disabled"; label: string }
| { type: "mailto"; label: string; email: string; subject: string };
highlighted: boolean;
hover: string;
accent: string;
badgeClass: string;
ctaClass?: string;
};
const PLANS: PlanConfig[] = [
{
name: "Bedroom Producer",
badge: "Free",
price: "€0",
priceNote: "/ forever",
features: {
Infrastructure: "Cloud",
Limit: "14 videos / 12 hours · 720p",
"Batch mode": "Limited batch · up to 3 files",
"File type": "MP3",
Playlists: "Not included",
"API support": "Not included",
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
Support: "Community",
Watermark: "Optional · support badge",
},
infrastructureChips: ["cloud"],
watermarkNote:
"Show \"Uploaded through Songs2YT.com\" to support open-source development - opt out anytime for a clean video.",
cta: { type: "signin" },
highlighted: false,
hover:
"hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15",
accent: "text-red-400",
badgeClass: "bg-gray-800 text-gray-400",
},
{
name: "Independent Artist",
badge: "Pro",
price: "€5",
priceNote: "/ mo",
features: {
Infrastructure: "Cloud",
Limit: "50 videos* / month · 1080p",
"Batch mode": "Full batch · up to 5 files",
"File type": "MP3 / WAV / FLAC",
Playlists: "Create & add uploads to YouTube playlists",
"API support": "REST API · upload, batch & playlists",
"ID3 tags": "Extended metadata support",
Support: "E-Mail",
Watermark: "Fully customizable · remove completely",
},
infrastructureChips: ["cloud"],
cta: { type: "disabled", label: "Coming soon" },
highlighted: true,
hover:
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
accent: "text-accent",
badgeClass: "bg-accent/15 text-accent",
},
{
name: "Record Label / Studio",
badge: "Enterprise",
price: "Custom",
priceNote: "/ contact sales",
features: {
Infrastructure: "Cloud or self-hosted",
Limit: "Unlimited 4K · zero limit",
"Batch mode": "Unlimited synchronized batch processing",
"File type": "WAV / FLAC / lossless",
Playlists: "Org-wide playlist workflows",
"API support": "Full API access · custom integrations & SLAs",
"ID3 tags": "Full metadata · custom mapping",
Support: "Top-priority**",
Watermark: "Fully customizable · remove completely",
},
infrastructureChips: ["cloud", "self-hosted"],
cta: {
type: "mailto",
label: "Contact Sales",
email: SALES_EMAIL,
subject: "Songs2YT Enterprise Inquiry",
},
highlighted: false,
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
accent: "text-[#609926]",
badgeClass: "bg-[#609926]/15 text-[#609926]",
ctaClass:
"border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20",
},
];
function FeatureValue({ value }: { value: string }) {
return <span className="text-sm text-gray-200">{value}</span>;
}
function CloudIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ServerIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="4" y="5" width="16" height="6" rx="2" />
<rect x="4" y="13" width="16" height="6" rx="2" />
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
</svg>
);
}
function InfrastructureChips({
chips,
}: {
chips: InfrastructureChip[];
}) {
return (
<div className="mt-2 flex flex-wrap items-center gap-2">
{chips.includes("cloud") && (
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
<CloudIcon className="h-4 w-4 text-gray-300" />
Cloud
</span>
)}
{chips.includes("self-hosted") && (
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
<ServerIcon className="h-4 w-4 text-gray-300" />
Self-hosted
</span>
)}
</div>
);
}
function PricingCard({ plan }: { plan: PlanConfig }) {
const {
name,
badge,
price,
priceNote,
features,
watermarkNote,
infrastructureChips,
cta,
highlighted,
hover,
accent,
badgeClass,
ctaClass,
} = plan;
return (
<div
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
highlighted
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
: "border-gray-700/50"
} ${hover}`}
>
{highlighted && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
Most popular
</span>
)}
<div className="mb-6">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{name}</h3>
<span
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
>
{badge}
</span>
</div>
<div className="flex items-end gap-1">
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
</div>
</div>
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
{FEATURE_LABELS.map((label) => (
<li key={label}>
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
{label === "Infrastructure" ? (
<InfrastructureChips chips={infrastructureChips} />
) : (
<FeatureValue value={features[label]} />
)}
{label === "Watermark" && watermarkNote && (
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
{watermarkNote}
</p>
)}
</li>
))}
</ul>
<div className="mt-auto">
{cta.type === "signin" && <SignInButton fullWidth />}
{cta.type === "link" && (
<Link
href={cta.href}
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
>
{cta.label}
</Link>
)}
{cta.type === "disabled" && (
<button
type="button"
disabled
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
>
{cta.label}
</button>
)}
{cta.type === "mailto" && (
<a
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
>
{cta.label}
</a>
)}
</div>
</div>
);
}
export function PricingSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="pricing"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
Free for getting started, Pro for creators, or Enterprise for teams.
</p>
</ScrollReveal>
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{PLANS.map((plan, index) => (
<ScrollReveal key={plan.name} delay={index * 100}>
<PricingCard plan={plan} />
</ScrollReveal>
))}
</div>
<ScrollReveal delay={360}>
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
*Pro is a monthly subscription with included quota (extensions via support). Self-hosted
open-source deployments have no quotas.
</p>
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
Technical support is strictly reserved for Managed Cloud and paid Professional Setup
agreements; independent self-hosted deployments are community-supported.
</p>
</ScrollReveal>
</div>
</section>
);
}
-33
View File
@@ -1,33 +0,0 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { UPGRADE_URL } from "@/lib/plans";
const PRO_UPGRADE_SUFFIX = " Upload up to 50 videos with Pro!";
type Props = {
className?: string;
children?: ReactNode;
};
export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) {
return (
<Link href={UPGRADE_URL} className={className}>
{children ?? "Upgrade to Pro"}
</Link>
);
}
export function QuotaErrorMessage({ message }: { message: string }) {
if (message.endsWith(PRO_UPGRADE_SUFFIX)) {
return (
<>
{message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "}
<UpgradeProLink className="font-medium text-red-200 underline hover:text-white">
Upload up to 50 videos with Pro!
</UpgradeProLink>
</>
);
}
return <>{message}</>;
}
+213 -60
View File
@@ -4,14 +4,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Plan, Privacy } from "@prisma/client";
import { filenameWithoutExtension } from "@/lib/constants";
import { getVideoAttributionText } from "@/lib/branding";
import { audioTagsToMetadata } from "@/lib/audio-tags";
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";
const UPLOAD_CONCURRENCY = 4;
type AudioItem = {
@@ -19,6 +21,9 @@ type AudioItem = {
file: File;
path: string | null;
uploading: boolean;
itemImagePath: string | null;
itemImageName: string | null;
itemImagePreview: string | null;
metadata: ItemMetadata;
};
@@ -36,6 +41,10 @@ function defaultMetadata(title = ""): ItemMetadata {
creativeCommons: false,
includeWatermark: true,
playlistId: null,
imagePath: null,
artist: null,
watermark: { ...DEFAULT_WATERMARK },
layout: { ...DEFAULT_LAYOUT },
};
}
@@ -44,21 +53,19 @@ export function UploadForm() {
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 [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;
totalAvailable: number;
used: number;
selfHosted?: boolean;
} | null>(null);
const loadQuota = useCallback(async () => {
@@ -66,15 +73,9 @@ export function UploadForm() {
if (res.ok) {
const data = await res.json();
setQuota({
remaining: data.remaining,
limit: data.limit,
plan: data.plan,
maxBatchSize: data.maxBatchSize,
resetsIn: data.resetsIn,
videoCredits: data.videoCredits ?? 0,
totalAvailable: data.totalAvailable ?? data.remaining,
used: data.used ?? 0,
selfHosted: Boolean(data.selfHosted),
});
}
}, []);
@@ -83,20 +84,30 @@ export function UploadForm() {
loadQuota();
}, [loadQuota]);
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",
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 { path: data.path, audioTags: data.audioTags };
}
@@ -112,9 +123,14 @@ export function UploadForm() {
file,
path: null,
uploading: true,
itemImagePath: null,
itemImageName: null,
itemImagePreview: null,
metadata: {
...defaultMetadata(autoTitle),
playlistId: playlistId || null,
watermark: { ...jobWatermark },
layout: { ...jobLayout },
},
},
]);
@@ -165,6 +181,8 @@ export function UploadForm() {
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);
@@ -172,11 +190,106 @@ export function UploadForm() {
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 (!true) {
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;
@@ -244,13 +357,31 @@ export function UploadForm() {
items: readyAudios.map((item) => ({
audioPath: item.path!,
audioFilename: item.file.name,
metadata: item.metadata,
metadata: {
...item.metadata,
imagePath: true ? item.itemImagePath || item.metadata.imagePath || null : null,
includeWatermark: jobWatermark.mode !== "none",
watermark: true
? jobWatermark
: {
mode: jobWatermark.mode === "none" ? "none" : "default",
position: "bottom-right",
offsetX: 20,
offsetY: 20,
},
layout: jobLayout,
},
})),
}),
});
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) {
@@ -263,30 +394,13 @@ 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.selfHosted ? (
<>
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
batch
</>
) : (
<>
{quota.remaining} of {quota.limit} plan videos remaining
{quota.plan === "FREE" && quota.videoCredits > 0
? ` · ${quota.videoCredits} pay-as-you-go credits`
: ""}{" "}
·{" "}
{quota.plan === "FREE"
? `resets in ${quota.resetsIn}`
: `monthly quota resets on ${quota.resetsIn}`}{" "}
· up to {quota.maxBatchSize} files per batch
</>
)}
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per batch
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
<QuotaErrorMessage message={error} />
{error}
</div>
)}
@@ -308,17 +422,16 @@ export function UploadForm() {
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
</span>
</div>
<p className="mt-2 text-xs text-gray-500">
Shared cover for all tracks. Optionally override per audio below.
</p>
</div>
<div>
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
<input
type="file"
accept={
quota?.selfHosted || quota?.plan === "PREMIUM"
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
: "audio/mpeg,.mp3"
}
accept="audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
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"
@@ -346,7 +459,7 @@ export function UploadForm() {
<PlaylistSelect
value={playlistId}
onChange={handlePlaylistChange}
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
enabled={true}
/>
</div>
@@ -384,6 +497,19 @@ export function UploadForm() {
/>
</Field>
<Field label="Artist (art-track layouts)">
<input
type="text"
value={item.metadata.artist ?? ""}
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
className="input-field"
placeholder="Optional shown in layout templates"
maxLength={80}
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Category">
<CategorySelect
value={item.metadata.categoryId}
@@ -426,6 +552,31 @@ export function UploadForm() {
/>
</Field>
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
<Field
label="Cover for this track (optional)"
>
<input
type="file"
accept="image/*"
disabled={false}
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>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Checkbox
label="Notify subscribers about this upload"
@@ -447,28 +598,30 @@ export function UploadForm() {
checked={item.metadata.creativeCommons}
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
/>
<Checkbox
label="Show 'Uploaded through Songs2YT.com' watermark (support open-source)"
checked={item.metadata.includeWatermark}
onChange={(v) => updateItemMetadata(item.id, { includeWatermark: v })}
/>
</div>
</div>
))}
</section>
)}
{quota && !quota.selfHosted && quota.plan === "FREE" && (
<>
<p className="text-sm text-gray-400">
Watermark is optional on the free plan - turn it off anytime for a clean video.
</p>
<p className="text-sm text-gray-400">
<UpgradeProLink className="text-accent hover:underline" /> for 50 videos/month, 1080p,
lossless audio, and full watermark control.
</p>
</>
)}
<LayoutStudio
locked={false}
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
title={audioItems[0]?.metadata.title || "Track title"}
artist={audioItems[0]?.metadata.artist || ""}
layout={jobLayout}
onLayoutChange={applyLayoutToAll}
watermark={jobWatermark}
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
logoPreviewUrl={logoPreviewUrl}
/>
<button
type="submit"
+397
View File
@@ -0,0 +1,397 @@
"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";
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 = false,
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"
>
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Watermark layout</h3>
</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>
);
}