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:
+24
-130
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user