Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { SUPPORT_EMAIL } from "@/lib/plans";
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export function AccountPrivacyActions({ email }: Props) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (
|
||||
!confirm(
|
||||
"Delete your account permanently? This removes your jobs, uploads, and YouTube connection. This cannot be undone.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/delete", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to delete account");
|
||||
await signOut({ callbackUrl: "/" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete account");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dataRequestSubject = encodeURIComponent("Data export request");
|
||||
const dataRequestBody = encodeURIComponent(
|
||||
`Hello,\n\nI would like to request a copy of my personal data associated with my Songs2YT account (${email}).\n\nThank you.`,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}?subject=${dataRequestSubject}&body=${dataRequestBody}`}
|
||||
className="inline-flex items-center justify-center rounded border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-surface"
|
||||
>
|
||||
Request my data
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Data requests are handled within the timelines described in our{" "}
|
||||
<a href="/privacy" className="text-gray-400 underline hover:text-white">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ApiKeyStatus = {
|
||||
configured: boolean;
|
||||
prefix: string | null;
|
||||
};
|
||||
|
||||
type RateLimitStatus = {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
windowSeconds: number;
|
||||
resetsInSeconds: number;
|
||||
bonus?: number;
|
||||
};
|
||||
|
||||
type ExtensionRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
adminNote: string | null;
|
||||
};
|
||||
|
||||
type ExtensionUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
requests: ExtensionRequest[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialStatus: ApiKeyStatus;
|
||||
initialRateLimit: RateLimitStatus;
|
||||
initialExtensionUsage: ExtensionUsage;
|
||||
};
|
||||
|
||||
export function ApiKeySettings({
|
||||
initialStatus,
|
||||
initialRateLimit,
|
||||
initialExtensionUsage,
|
||||
}: Props) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [rateLimit, setRateLimit] = useState(initialRateLimit);
|
||||
const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
fetch("/api/account/api-rate-limit")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (active) setRateLimit(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refresh();
|
||||
const id = setInterval(refresh, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function generateKey() {
|
||||
if (
|
||||
status.configured &&
|
||||
!confirm("This will replace your existing API key. Continue?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNewKey(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to generate API key");
|
||||
|
||||
setNewKey(data.apiKey);
|
||||
setStatus({ configured: true, prefix: data.prefix });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKey() {
|
||||
if (!confirm("Revoke your API key? External integrations will stop working.")) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-key", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
|
||||
|
||||
setStatus({ configured: false, prefix: null });
|
||||
setNewKey(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to revoke API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRateLimitExtension() {
|
||||
if (extensionUsage.remaining <= 0) return;
|
||||
|
||||
const reason = prompt(
|
||||
"Optional: tell us why you need a higher API rate limit (leave blank to skip).",
|
||||
);
|
||||
if (reason === null) return;
|
||||
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/api-rate-limit-extension-request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to submit request");
|
||||
|
||||
setExtensionUsage({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
remaining: data.remaining,
|
||||
requests: data.requests ?? extensionUsage.requests,
|
||||
});
|
||||
setSuccess(
|
||||
`Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING");
|
||||
const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Use the REST API to upload files and create batch video jobs programmatically. Pro plan
|
||||
only.
|
||||
</p>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-white">API rate limit</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Resets in {rateLimit.resetsInSeconds}s
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">
|
||||
{rateLimit.used} / {rateLimit.limit} requests used this minute
|
||||
{rateLimit.bonus ? (
|
||||
<span className="text-gray-500"> (includes +{rateLimit.bonus} bonus)</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded transition-all ${
|
||||
usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${usedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-gray-400">
|
||||
Extension requests this year: {extensionUsage.used} / {extensionUsage.limit}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestRateLimitExtension}
|
||||
disabled={requesting || extensionUsage.remaining <= 0 || hasPending}
|
||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{requesting
|
||||
? "Submitting…"
|
||||
: hasPending
|
||||
? "Pending request"
|
||||
: extensionUsage.remaining <= 0
|
||||
? "No extensions left"
|
||||
: "Request rate limit extension"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{extensionUsage.requests.length > 0 && (
|
||||
<ul className="space-y-1 border-t border-gray-800 pt-3 text-xs text-gray-500">
|
||||
{extensionUsage.requests.slice(0, 5).map((req) => (
|
||||
<li key={req.id}>
|
||||
{new Date(req.requestedAt).toLocaleDateString()} · {req.status}
|
||||
{req.adminNote ? ` · ${req.adminNote}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.configured && status.prefix && (
|
||||
<p className="text-sm text-gray-300">
|
||||
Active key: <span className="font-mono text-white">{status.prefix}…</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{newKey && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p>
|
||||
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
|
||||
{newKey}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
|
||||
</button>
|
||||
{status.configured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={revokeKey}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
Revoke API key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
|
||||
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
|
||||
<ul className="space-y-2 font-mono">
|
||||
<li>POST /api/v1/upload: upload image or audio file</li>
|
||||
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
|
||||
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
|
||||
<li>GET /api/v1/playlists: list YouTube playlists</li>
|
||||
<li>POST /api/v1/playlists: create a YouTube playlist</li>
|
||||
<li>GET /api/v1/jobs: list jobs</li>
|
||||
<li>GET /api/v1/jobs/:id: job status</li>
|
||||
</ul>
|
||||
<p className="mt-3">
|
||||
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
|
||||
request. For many audio files, upload each file then call{" "}
|
||||
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
|
||||
<a href="/dashboard/api-docs" className="text-accent hover:underline">
|
||||
Full API docs
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
|
||||
function FilmStripIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
|
||||
<path d="m15 9 3 3-3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
|
||||
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CoinsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="8" cy="6" rx="5" ry="2" />
|
||||
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
|
||||
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
<ellipse cx="16" cy="14" rx="5" ry="2" />
|
||||
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
|
||||
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WaveIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GearsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
title: "No editing required",
|
||||
desc: "Skip complex video editors. Just upload an image and audio, and Songs2YT handles the rest.",
|
||||
Icon: FilmStripIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube-ready output",
|
||||
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
|
||||
Icon: YouTubeIcon,
|
||||
},
|
||||
{
|
||||
title: "Free tier included",
|
||||
desc: "Start creating with 14 videos every 12 hours at up to 720p. No credit card needed.",
|
||||
Icon: CoinsIcon,
|
||||
},
|
||||
{
|
||||
title: "YouTube playlists",
|
||||
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
|
||||
Icon: PlaylistIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STOCK_THUMBS = [
|
||||
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
|
||||
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
|
||||
] as const;
|
||||
|
||||
function TypewriterText({ text }: { text: string }) {
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0;
|
||||
const interval = setInterval(() => {
|
||||
index += 1;
|
||||
setDisplayed(text.slice(0, index));
|
||||
if (index >= text.length) {
|
||||
clearInterval(interval);
|
||||
setDone(true);
|
||||
}
|
||||
}, 45);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<span className="text-xs font-medium text-gray-300">
|
||||
{displayed}
|
||||
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitCard({
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof FilmStripIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
|
||||
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
|
||||
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MockupProgressRow({
|
||||
initialWidth,
|
||||
midWidth,
|
||||
active,
|
||||
phaseOneMs = 3000,
|
||||
phaseTwoMs = 2000,
|
||||
}: {
|
||||
initialWidth: string;
|
||||
midWidth: string;
|
||||
active: boolean;
|
||||
phaseOneMs?: number;
|
||||
phaseTwoMs?: number;
|
||||
}) {
|
||||
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-white/80 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardMockup() {
|
||||
const { ref, inView } = useInView();
|
||||
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
|
||||
<div className="h-2 w-2 rounded-full bg-gray-500" />
|
||||
{sidebarIcons.map((Icon, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<TypewriterText text="Multi-step configuration" />
|
||||
<div className="h-4 w-8 rounded-full bg-red-500/80" />
|
||||
</div>
|
||||
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
|
||||
Title
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
|
||||
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
|
||||
</div>
|
||||
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
|
||||
Category <span className="text-gray-400">Music ▾</span>
|
||||
</div>
|
||||
<div className="space-y-2 pt-1">
|
||||
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessFlow() {
|
||||
return (
|
||||
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 420 240"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
|
||||
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
d="M 58 52 C 120 52, 140 58, 168 72"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
/>
|
||||
<path
|
||||
d="M 58 118 C 120 108, 140 88, 168 78"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.3s" }}
|
||||
/>
|
||||
<path
|
||||
d="M 198 75 C 250 75, 285 68, 318 68"
|
||||
fill="none"
|
||||
stroke="url(#flowGradH)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.6s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<FlowInput label="Batch Audio Files" icon="audio" />
|
||||
<FlowInput label="Single Cover Image" icon="image" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center px-2 pt-6">
|
||||
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
|
||||
<Image
|
||||
src="/database.png"
|
||||
alt="Process"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STOCK_THUMBS.map((src) => (
|
||||
<div
|
||||
key={src}
|
||||
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="scale-110 object-cover blur-[2px]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="my-2 h-10 w-6 overflow-visible"
|
||||
viewBox="0 0 6 40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="3"
|
||||
y1="2"
|
||||
x2="3"
|
||||
y2="38"
|
||||
stroke="rgb(239 68 68 / 0.55)"
|
||||
strokeWidth="1.5"
|
||||
className="benefits-flow-path"
|
||||
style={{ animationDelay: "0.9s" }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<YouTubeIcon className="h-9 w-9 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
|
||||
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
|
||||
{icon === "audio" ? (
|
||||
<NoteIcon className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsVisual() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
|
||||
<DashboardMockup />
|
||||
<ProcessFlow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
|
||||
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
|
||||
}
|
||||
|
||||
export function BenefitsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="benefits"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<BenefitsScrollTitle sectionRef={sectionRef} />
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
|
||||
<div className="flex flex-col gap-5">
|
||||
{BENEFITS.map((benefit, index) => (
|
||||
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
|
||||
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={200} direction="right">
|
||||
<BenefitsVisual />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { YOUTUBE_CATEGORIES } from "@/lib/constants";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CategorySelect({ value, onChange }: Props) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none"
|
||||
>
|
||||
{YOUTUBE_CATEGORIES.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { SignOutButton } from "@/components/SignOutButton";
|
||||
import { MobileMenuButton, MobileSidebar, dashboardSidebarLinkClass } from "@/components/MobileSidebar";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "/dashboard", label: "Create", match: (path: string) => path === "/dashboard" },
|
||||
{
|
||||
href: "/dashboard/history",
|
||||
label: "History",
|
||||
match: (path: string) => path.startsWith("/dashboard/history"),
|
||||
},
|
||||
{
|
||||
href: "/dashboard/settings",
|
||||
label: "Settings",
|
||||
match: (path: string) => path.startsWith("/dashboard/settings"),
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
channelTitle?: string | null;
|
||||
};
|
||||
|
||||
export function DashboardNav({ channelTitle }: Props) {
|
||||
const pathname = usePathname();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<Logo href="/dashboard" size="sm" />
|
||||
{channelTitle && (
|
||||
<p className="truncate text-xs text-gray-500">Channel: {channelTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-4 sm:flex">
|
||||
<nav className="flex items-center gap-4">
|
||||
{NAV_LINKS.map(({ href, label, match }) => {
|
||||
const active = match(pathname);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-medium transition-colors duration-200 ${
|
||||
active ? "text-white" : "text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
|
||||
<MobileMenuButton
|
||||
open={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Dashboard">
|
||||
{NAV_LINKS.map(({ href, label, match }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={dashboardSidebarLinkClass(match(pathname))}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
signOut({ callbackUrl: "/" });
|
||||
}}
|
||||
className={`${dashboardSidebarLinkClass(false, true)} w-full text-left`}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</MobileSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { DashboardNav } from "@/components/DashboardNav";
|
||||
import { LegalFooter } from "@/components/LegalFooter";
|
||||
|
||||
type Props = {
|
||||
channelTitle?: string | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardShell({ channelTitle, children }: Props) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col">
|
||||
<DashboardNav channelTitle={channelTitle} />
|
||||
<div className="flex-1">{children}</div>
|
||||
<LegalFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
|
||||
<path
|
||||
fill="#2496ED"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
|
||||
<path
|
||||
fill="#609926"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DEPLOY_CARDS = [
|
||||
{
|
||||
name: "Docker Hub",
|
||||
desc: "Pull the image and run Songs2YT on your own server with Docker Compose.",
|
||||
Icon: DockerIcon,
|
||||
cta: "View on Docker Hub",
|
||||
href: DOCKER_HUB_URL,
|
||||
accent: "text-[#2496ED]",
|
||||
hover:
|
||||
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
|
||||
titleHover: "group-hover:text-[#2496ED]",
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
|
||||
Icon: GiteaIcon,
|
||||
cta: "View on Gitea",
|
||||
href: GITEA_URL,
|
||||
accent: "text-[#609926]",
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function DeployCard({
|
||||
name,
|
||||
desc,
|
||||
Icon,
|
||||
cta,
|
||||
href,
|
||||
accent,
|
||||
hover,
|
||||
titleHover,
|
||||
}: (typeof DEPLOY_CARDS)[number]) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
|
||||
Open Source
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
|
||||
{cta}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="download"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
|
||||
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
|
||||
Songs2YT is open source. Self-host on your own server with Docker or deploy from source.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
|
||||
<ScrollReveal direction="left">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
|
||||
<p className="mt-3 leading-relaxed text-gray-400">
|
||||
Host Songs2YT on your infrastructure with full control over data, queues, and
|
||||
storage. Ideal for teams and creators who want a private deployment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Docker image published to Docker Hub
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Source code available on Gitea
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
Gitea Issues community support
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
PostgreSQL, Redis, FFmpeg, and worker included
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
|
||||
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
|
||||
<code>{`docker pull atakanozban/songs2yt:latest\ndocker compose up -d`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{DEPLOY_CARDS.map((card, index) => (
|
||||
<ScrollReveal key={card.name} delay={index * 120} direction="right">
|
||||
<DeployCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import {
|
||||
DOCKER_HUB_URL,
|
||||
GITEA_ISSUES_URL,
|
||||
GITEA_URL,
|
||||
SUPPORT_EMAIL,
|
||||
} from "@/lib/plans";
|
||||
|
||||
function GiteaIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-white";
|
||||
|
||||
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-gray-300";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_URL = "https://status.atakanozban.com/status/2";
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
|
||||
<div className="flex flex-col gap-4 md:col-span-5">
|
||||
<Logo size="sm" />
|
||||
<p className="max-w-sm text-gray-500">
|
||||
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
|
||||
fully open-source.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-gray-500">
|
||||
<a
|
||||
href={GITEA_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Gitea"
|
||||
className="transition-colors duration-300 hover:text-[#609926]"
|
||||
>
|
||||
<GiteaIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href={DOCKER_HUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Docker Hub"
|
||||
className="transition-colors duration-300 hover:text-[#2496ED]"
|
||||
>
|
||||
<DockerIcon className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Product
|
||||
</span>
|
||||
<FooterLink href="#pricing">Pricing</FooterLink>
|
||||
<FooterLink href="#download">Download</FooterLink>
|
||||
<FooterLink href="#benefits">Benefits</FooterLink>
|
||||
<FooterLink href="/privacy">Privacy Policy</FooterLink>
|
||||
<FooterLink href="/terms">Terms of Service</FooterLink>
|
||||
<FooterLink href="/refund">Refund Policy</FooterLink>
|
||||
<FooterLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Open Source
|
||||
</span>
|
||||
<FooterLink href={GITEA_URL} external>
|
||||
Gitea Instance
|
||||
</FooterLink>
|
||||
<FooterLink href={DOCKER_HUB_URL} external>
|
||||
Docker Image
|
||||
</FooterLink>
|
||||
<FooterLink href={GITEA_ISSUES_URL} external>
|
||||
Report a Bug
|
||||
</FooterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Contact
|
||||
</span>
|
||||
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
|
||||
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 border-t border-gray-900 pt-8 text-xs text-gray-500 md:justify-start">
|
||||
<span>© {year} Songs2YT. All rights reserved.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/privacy">Privacy Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href={STATUS_URL} external>
|
||||
Service Status
|
||||
</LegalLink>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
||||
import type { JobResponse } from "@/lib/types";
|
||||
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Queued",
|
||||
ENCODING: "Encoding",
|
||||
UPLOADING: "Uploading",
|
||||
COMPLETED: "Completed",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
function sameDay(a: Date, b: Date) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatDayLabel(date: Date) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (sameDay(date, today)) return "Today";
|
||||
if (sameDay(date, yesterday)) return "Yesterday";
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function groupJobsByDay(jobs: JobResponse[]) {
|
||||
const groups = new Map<string, { label: string; jobs: JobResponse[] }>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const date = new Date(job.createdAt);
|
||||
const key = date.toLocaleDateString("en-CA");
|
||||
const existing = groups.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.jobs.push(job);
|
||||
} else {
|
||||
groups.set(key, { label: formatDayLabel(date), jobs: [job] });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([, group]) => group);
|
||||
}
|
||||
|
||||
function statusClass(status: string) {
|
||||
if (status === "COMPLETED") return "bg-green-500/20 text-green-400";
|
||||
if (status === "FAILED") return "bg-red-500/20 text-red-400";
|
||||
return "bg-yellow-500/20 text-yellow-400";
|
||||
}
|
||||
|
||||
export function JobHistory() {
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/jobs?limit=100");
|
||||
if (!res.ok) throw new Error("Failed to load history");
|
||||
const data = await res.json();
|
||||
if (active) setJobs(data.jobs);
|
||||
} catch (err) {
|
||||
if (active) setError(err instanceof Error ? err.message : "Error loading history");
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dayGroups = useMemo(() => groupJobsByDay(jobs), [jobs]);
|
||||
const youtubeLimitHit = useMemo(
|
||||
() =>
|
||||
jobs.some((job) =>
|
||||
job.items.some(
|
||||
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
|
||||
),
|
||||
),
|
||||
[jobs],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-400">Loading history…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dayGroups.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-700 bg-surface-light p-8 text-center">
|
||||
<p className="text-gray-400">No videos created yet.</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-4 inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Create your first video
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{youtubeLimitHit && <YouTubeLimitBanner />}
|
||||
|
||||
{dayGroups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">{group.label}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{group.jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface-light p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(job.createdAt).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{" · "}
|
||||
{job.items.length} video{job.items.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Status: <span className="text-gray-300">{job.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/jobs/${job.id}`}
|
||||
className="text-sm text-accent transition-colors hover:text-accent-hover"
|
||||
>
|
||||
View job
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{job.items.map((item) => {
|
||||
const itemError =
|
||||
item.status === "FAILED" ? displayJobItemError(item.error) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded border border-gray-800 bg-surface px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-white">{item.title}</p>
|
||||
<p className="truncate text-xs text-gray-500">{item.audioFilename}</p>
|
||||
{item.youtubeVideoId && (
|
||||
<a
|
||||
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-xs text-accent hover:underline"
|
||||
>
|
||||
View on YouTube
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-1 text-xs font-medium ${statusClass(item.status)}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
{itemError && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-red-400">{itemError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { displayJobItemError, isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
||||
import type { JobResponse } from "@/lib/types";
|
||||
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
||||
|
||||
type Props = {
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Queued",
|
||||
ENCODING: "Encoding video…",
|
||||
UPLOADING: "Uploading to YouTube…",
|
||||
COMPLETED: "Completed",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
export function JobProgress({ jobId }: Props) {
|
||||
const [job, setJob] = useState<JobResponse | null>(null);
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
plan: string;
|
||||
resetsIn: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const [jobRes, quotaRes] = await Promise.all([
|
||||
fetch(`/api/jobs/${jobId}`),
|
||||
fetch("/api/quota"),
|
||||
]);
|
||||
|
||||
if (!jobRes.ok) throw new Error("Failed to load job");
|
||||
const jobData = await jobRes.json();
|
||||
if (active) setJob(jobData);
|
||||
|
||||
if (quotaRes.ok) {
|
||||
const quotaData = await quotaRes.json();
|
||||
if (active) setQuota({ remaining: quotaData.remaining, limit: quotaData.limit, plan: quotaData.plan, resetsIn: quotaData.resetsIn });
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err instanceof Error ? err.message : "Error loading job");
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded border border-red-500/50 bg-red-500/10 p-4 text-red-300">{error}</div>;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return <div className="text-gray-400">Loading job status…</div>;
|
||||
}
|
||||
|
||||
const allDone = job.items.every((i) => i.status === "COMPLETED" || i.status === "FAILED");
|
||||
const youtubeLimitHit = job.items.some(
|
||||
(i) => i.status === "FAILED" && i.error && isYouTubeUploadLimitError(i.error),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Job Status</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Overall: <span className="text-white">{job.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
{quota && (
|
||||
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
|
||||
{quota.remaining} / {quota.limit} videos remaining ·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{youtubeLimitHit && <YouTubeLimitBanner />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{job.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface-light p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-white">{item.title}</p>
|
||||
<p className="text-xs text-gray-500">{item.audioFilename}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
item.status === "COMPLETED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: item.status === "FAILED"
|
||||
? "bg-red-500/20 text-red-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.youtubeVideoId && (
|
||||
<a
|
||||
href={`https://youtube.com/watch?v=${item.youtubeVideoId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block text-sm text-accent hover:underline"
|
||||
>
|
||||
View on YouTube
|
||||
</a>
|
||||
)}
|
||||
|
||||
{item.status === "FAILED" && displayJobItemError(item.error) && (
|
||||
<p className="mt-2 text-sm text-red-400">{displayJobItemError(item.error)}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allDone && (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-block rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Create another video
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "#benefits", label: "Benefits" },
|
||||
{ href: "#download", label: "Download" },
|
||||
{ href: "#pricing", label: "Pricing" },
|
||||
{ href: "#support", label: "Support" },
|
||||
] as const;
|
||||
|
||||
function NavAnchor({
|
||||
href,
|
||||
label,
|
||||
onNavigate,
|
||||
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
const id = href.replace("#", "");
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.pushState(null, "", href);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} className={className}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingNavbar() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<Logo size="md" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="hidden items-center gap-10 sm:flex">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<NavAnchor key={href} href={href} label={label} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<MobileMenuButton
|
||||
open={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
|
||||
{NAV_LINKS.map(({ href, label }) => (
|
||||
<NavAnchor
|
||||
key={href}
|
||||
href={href}
|
||||
label={label}
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
className={sidebarLinkClass()}
|
||||
/>
|
||||
))}
|
||||
</MobileSidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function LegalLink({
|
||||
href,
|
||||
children,
|
||||
external,
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
external?: boolean;
|
||||
}) {
|
||||
const className = "transition-colors duration-300 hover:text-gray-300";
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalFooter() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-auto w-full border-t border-gray-800 bg-black/40 py-8 text-xs text-gray-500">
|
||||
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6 md:justify-start">
|
||||
<span>© {year} Songs2YT. All rights reserved.</span>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/privacy">Privacy Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/terms">Terms of Service</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="/refund">Refund Policy</LegalLink>
|
||||
<span aria-hidden="true" className="hidden sm:inline">
|
||||
|
|
||||
</span>
|
||||
<LegalLink href="https://status.atakanozban.com/status/2" external>
|
||||
Service Status
|
||||
</LegalLink>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { LEGAL_LAST_UPDATED } from "@/lib/legal/constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function LegalPageLayout({ title, description, children }: Props) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-dark">
|
||||
<header className="border-b border-gray-800 bg-black/30">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-6 py-6">
|
||||
<Logo />
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-gray-400 transition-colors duration-300 hover:text-white"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">
|
||||
Last updated: {LEGAL_LAST_UPDATED}
|
||||
</p>
|
||||
<h1 className="mb-3 text-3xl font-bold text-white">{title}</h1>
|
||||
{description && <p className="mb-10 text-gray-400">{description}</p>}
|
||||
|
||||
<article className="prose prose-invert prose-gray max-w-none prose-headings:font-semibold prose-headings:text-white prose-p:text-gray-300 prose-li:text-gray-300 prose-strong:text-gray-200 prose-a:text-accent prose-a:no-underline hover:prose-a:underline">
|
||||
{children}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-gray-800 py-8 text-center text-xs text-gray-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Link href="/privacy" className="hover:text-gray-300">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-gray-300">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/refund" className="hover:text-gray-300">
|
||||
Refund
|
||||
</Link>
|
||||
<a
|
||||
href="https://status.atakanozban.com/status/2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-gray-300"
|
||||
>
|
||||
Service Status
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
href?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function Logo({ href = "/", size = "md" }: Props) {
|
||||
const textSize = size === "sm" ? "text-xl" : "text-2xl";
|
||||
|
||||
const content = (
|
||||
<span className={`inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
|
||||
<span className="text-white">Songs</span>
|
||||
<span className="text-red-400">2YT</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="inline-flex items-center" aria-label="Songs2YT">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center" aria-label="Songs2YT">
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const PANEL_MS = 420;
|
||||
const ITEM_BASE_MS = 90;
|
||||
|
||||
function CloseIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar({ open, onClose, title = "Menu", children }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const frame = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
const timer = window.setTimeout(() => setMounted(false), PANEL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [mounted, onClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||
visible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
style={{ animationDelay: visible ? "120ms" : "0ms" }}
|
||||
className={`fixed right-4 top-4 z-[60] flex h-10 w-10 items-center justify-center rounded-full border border-gray-700 bg-surface text-gray-300 shadow-lg transition-shadow hover:bg-surface-light hover:text-white ${
|
||||
visible ? "mobile-sidebar-close-btn-open" : "mobile-sidebar-close-btn-close"
|
||||
}`}
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<aside
|
||||
className={`absolute right-0 top-0 flex h-full w-72 max-w-[85vw] flex-col border-l border-gray-800 bg-surface shadow-2xl ${
|
||||
visible ? "mobile-sidebar-panel-open" : "mobile-sidebar-panel-close"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`border-b border-gray-800 px-5 py-5 transition-all duration-300 ease-out ${
|
||||
visible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
|
||||
}`}
|
||||
style={{ transitionDelay: visible ? "100ms" : "0ms" }}
|
||||
>
|
||||
<span className="text-sm font-semibold uppercase tracking-wider text-gray-400">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||
{Children.map(children, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`transition-all duration-300 ease-out ${
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"
|
||||
}`}
|
||||
style={{
|
||||
transitionDelay: visible ? `${ITEM_BASE_MS + index * 50}ms` : "0ms",
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileMenuButton({
|
||||
onClick,
|
||||
open = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
open?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
className="relative rounded p-2 text-gray-300 transition-all duration-300 hover:scale-105 hover:bg-white/10 hover:text-white active:scale-95 sm:hidden"
|
||||
>
|
||||
<span className="relative block h-6 w-6">
|
||||
<span
|
||||
className={`absolute left-0 top-[5px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "top-[11px] rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[11px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "scale-x-0 opacity-0" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-[17px] block h-0.5 w-6 rounded-full bg-current transition-all duration-300 ease-out ${
|
||||
open ? "top-[11px] -rotate-45" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarLinkClass(active = false) {
|
||||
return `block rounded-lg px-4 py-3 text-base font-medium transition-all duration-200 hover:translate-x-0.5 ${
|
||||
active ? "bg-surface-light text-white" : "text-gray-300 hover:bg-surface-light hover:text-white"
|
||||
}`;
|
||||
}
|
||||
|
||||
export function dashboardSidebarLinkClass(active = false, danger = false) {
|
||||
const classes = ["mobile-sidebar-link"];
|
||||
if (active) classes.push("mobile-sidebar-link-active");
|
||||
if (danger) classes.push("mobile-sidebar-link-danger");
|
||||
return classes.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
type ExtensionRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
adminNote: string | null;
|
||||
};
|
||||
|
||||
type ExtensionUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
requests: ExtensionRequest[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialUsage: ExtensionUsage;
|
||||
};
|
||||
|
||||
export function PlanBillingActions({ initialUsage }: Props) {
|
||||
const router = useRouter();
|
||||
const [usage, setUsage] = useState(initialUsage);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
async function handleCancelPlan() {
|
||||
if (
|
||||
!confirm(
|
||||
"Cancel your Pro plan? You will be moved to the free plan immediately and lose Pro benefits.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCancelling(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/cancel-subscription", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaRequest() {
|
||||
if (usage.remaining <= 0) return;
|
||||
|
||||
const reason = prompt(
|
||||
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
|
||||
);
|
||||
if (reason === null) return;
|
||||
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/account/quota-extension-request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to submit request");
|
||||
|
||||
setUsage({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
remaining: data.remaining,
|
||||
requests: data.requests ?? usage.requests,
|
||||
});
|
||||
setSuccess(
|
||||
`Request submitted. ${data.used} of ${data.limit} extension requests used this year. Support will process it shortly.`,
|
||||
);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit request");
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasPending = usage.requests.some((r) => r.status === "PENDING");
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
|
||||
<p className="text-gray-300">
|
||||
Extension requests this year:{" "}
|
||||
<span className="font-medium text-white">
|
||||
{usage.used} / {usage.limit}
|
||||
</span>
|
||||
{usage.remaining > 0 ? (
|
||||
<span className="text-gray-500"> · {usage.remaining} remaining</span>
|
||||
) : (
|
||||
<span className="text-yellow-400"> · limit reached</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelPlan}
|
||||
disabled={cancelling}
|
||||
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? "Cancelling…" : "Cancel your plan"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuotaRequest}
|
||||
disabled={requesting || usage.remaining <= 0 || hasPending}
|
||||
className="inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{requesting
|
||||
? "Submitting…"
|
||||
: hasPending
|
||||
? "Request pending…"
|
||||
: "Request quota reset & extension"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{usage.requests.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Request history
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{usage.requests.slice(0, 5).map((req) => (
|
||||
<li
|
||||
key={req.id}
|
||||
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-300">
|
||||
{new Date(req.requestedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
req.status === "APPROVED"
|
||||
? "text-green-400"
|
||||
: req.status === "REJECTED"
|
||||
? "text-red-400"
|
||||
: "text-yellow-400"
|
||||
}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
|
||||
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Pro users may request up to 5 manual quota resets or extensions per calendar year. See our{" "}
|
||||
<a href="/terms" className="text-gray-400 underline hover:text-white">
|
||||
Terms of Service
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { UpgradeProLink } from "./UpgradeProLink";
|
||||
|
||||
type Playlist = {
|
||||
id: string;
|
||||
title: string;
|
||||
itemCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (playlistId: string) => void;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function PlaylistSelect({ value, onChange, enabled }: Props) {
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [privacy, setPrivacy] = useState<"public" | "unlisted" | "private">("private");
|
||||
|
||||
const loadPlaylists = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/youtube/playlists");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load playlists");
|
||||
setPlaylists(data.playlists ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load playlists");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void loadPlaylists();
|
||||
}, [enabled, loadPlaylists]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!title.trim()) {
|
||||
setError("Playlist title is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/youtube/playlists", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
privacy,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to create playlist");
|
||||
|
||||
const playlist = data.playlist as Playlist;
|
||||
setPlaylists((prev) => [playlist, ...prev]);
|
||||
onChange(playlist.id);
|
||||
setShowCreate(false);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPrivacy("private");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create playlist");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<p className="text-sm text-gray-400">
|
||||
Add uploaded videos to a YouTube playlist with{" "}
|
||||
<UpgradeProLink className="text-accent hover:underline" />.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-gray-400">Add to YouTube playlist (optional)</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={loading || creating}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="">None (don’t add to a playlist)</option>
|
||||
{playlists.map((playlist) => (
|
||||
<option key={playlist.id} value={playlist.id}>
|
||||
{playlist.title} ({playlist.itemCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate((prev) => !prev)}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
{showCreate ? "Cancel" : "Create new playlist"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadPlaylists()}
|
||||
disabled={loading}
|
||||
className="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Refresh list
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="space-y-3 rounded border border-gray-700 bg-surface-dark/60 p-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">Playlist title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
className="input-field w-full"
|
||||
placeholder="My Songs2YT uploads"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
className="input-field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">Privacy</label>
|
||||
<select
|
||||
value={privacy}
|
||||
onChange={(e) => setPrivacy(e.target.value as typeof privacy)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="private">Private</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !title.trim()}
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{creating ? "Creating…" : "Create & select"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-xs text-gray-500">Loading playlists…</p>}
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{!loading && !error && playlists.length === 0 && !showCreate && (
|
||||
<p className="text-xs text-gray-500">
|
||||
No playlists yet. Create one above. If playlist access was just added, sign out and sign
|
||||
in again to grant the new Google permission.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { SALES_EMAIL } from "@/lib/plans";
|
||||
|
||||
const FEATURE_LABELS = [
|
||||
"Infrastructure",
|
||||
"Limit",
|
||||
"Batch mode",
|
||||
"File type",
|
||||
"Playlists",
|
||||
"API support",
|
||||
"ID3 tags",
|
||||
"Support",
|
||||
"Watermark",
|
||||
] as const;
|
||||
|
||||
type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>;
|
||||
|
||||
type InfrastructureChip = "cloud" | "self-hosted";
|
||||
|
||||
type PlanConfig = {
|
||||
name: string;
|
||||
badge: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
features: PlanFeatures;
|
||||
infrastructureChips: InfrastructureChip[];
|
||||
watermarkNote?: string;
|
||||
cta:
|
||||
| { type: "signin" }
|
||||
| { type: "link"; label: string; href: string }
|
||||
| { type: "disabled"; label: string }
|
||||
| { type: "mailto"; label: string; email: string; subject: string };
|
||||
highlighted: boolean;
|
||||
hover: string;
|
||||
accent: string;
|
||||
badgeClass: string;
|
||||
ctaClass?: string;
|
||||
};
|
||||
|
||||
const PLANS: PlanConfig[] = [
|
||||
{
|
||||
name: "Bedroom Producer",
|
||||
badge: "Free",
|
||||
price: "€0",
|
||||
priceNote: "/ forever",
|
||||
features: {
|
||||
Infrastructure: "Cloud",
|
||||
Limit: "14 videos / 12 hours · 720p",
|
||||
"Batch mode": "Limited batch · up to 3 files",
|
||||
"File type": "MP3",
|
||||
Playlists: "Not included",
|
||||
"API support": "Not included",
|
||||
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
|
||||
Support: "Community",
|
||||
Watermark: "Optional · support badge",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
watermarkNote:
|
||||
"Show \"Uploaded through Songs2YT.com\" to support open-source development - opt out anytime for a clean video.",
|
||||
cta: { type: "signin" },
|
||||
highlighted: false,
|
||||
hover:
|
||||
"hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15",
|
||||
accent: "text-red-400",
|
||||
badgeClass: "bg-gray-800 text-gray-400",
|
||||
},
|
||||
{
|
||||
name: "Independent Artist",
|
||||
badge: "Pro",
|
||||
price: "€5",
|
||||
priceNote: "/ mo",
|
||||
features: {
|
||||
Infrastructure: "Cloud",
|
||||
Limit: "50 videos* / month · 1080p",
|
||||
"Batch mode": "Full batch · up to 5 files",
|
||||
"File type": "MP3 / WAV / FLAC",
|
||||
Playlists: "Create & add uploads to YouTube playlists",
|
||||
"API support": "REST API · upload, batch & playlists",
|
||||
"ID3 tags": "Extended metadata support",
|
||||
Support: "E-Mail",
|
||||
Watermark: "Fully customizable · remove completely",
|
||||
},
|
||||
infrastructureChips: ["cloud"],
|
||||
cta: { type: "disabled", label: "Coming soon" },
|
||||
highlighted: true,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
accent: "text-accent",
|
||||
badgeClass: "bg-accent/15 text-accent",
|
||||
},
|
||||
{
|
||||
name: "Record Label / Studio",
|
||||
badge: "Enterprise",
|
||||
price: "Custom",
|
||||
priceNote: "/ contact sales",
|
||||
features: {
|
||||
Infrastructure: "Cloud or self-hosted",
|
||||
Limit: "Unlimited 4K · zero limit",
|
||||
"Batch mode": "Unlimited synchronized batch processing",
|
||||
"File type": "WAV / FLAC / lossless",
|
||||
Playlists: "Org-wide playlist workflows",
|
||||
"API support": "Full API access · custom integrations & SLAs",
|
||||
"ID3 tags": "Full metadata · custom mapping",
|
||||
Support: "Top-priority**",
|
||||
Watermark: "Fully customizable · remove completely",
|
||||
},
|
||||
infrastructureChips: ["cloud", "self-hosted"],
|
||||
cta: {
|
||||
type: "mailto",
|
||||
label: "Contact Sales",
|
||||
email: SALES_EMAIL,
|
||||
subject: "Songs2YT Enterprise Inquiry",
|
||||
},
|
||||
highlighted: false,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
accent: "text-[#609926]",
|
||||
badgeClass: "bg-[#609926]/15 text-[#609926]",
|
||||
ctaClass:
|
||||
"border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20",
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureValue({ value }: { value: string }) {
|
||||
return <span className="text-sm text-gray-200">{value}</span>;
|
||||
}
|
||||
|
||||
function CloudIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="5" width="16" height="6" rx="2" />
|
||||
<rect x="4" y="13" width="16" height="6" rx="2" />
|
||||
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureChips({
|
||||
chips,
|
||||
}: {
|
||||
chips: InfrastructureChip[];
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{chips.includes("cloud") && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
||||
<CloudIcon className="h-4 w-4 text-gray-300" />
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
{chips.includes("self-hosted") && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
|
||||
<ServerIcon className="h-4 w-4 text-gray-300" />
|
||||
Self-hosted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingCard({ plan }: { plan: PlanConfig }) {
|
||||
const {
|
||||
name,
|
||||
badge,
|
||||
price,
|
||||
priceNote,
|
||||
features,
|
||||
watermarkNote,
|
||||
infrastructureChips,
|
||||
cta,
|
||||
highlighted,
|
||||
hover,
|
||||
accent,
|
||||
badgeClass,
|
||||
ctaClass,
|
||||
} = plan;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
|
||||
highlighted
|
||||
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
|
||||
: "border-gray-700/50"
|
||||
} ${hover}`}
|
||||
>
|
||||
{highlighted && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{name}</h3>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-1">
|
||||
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
|
||||
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
|
||||
{FEATURE_LABELS.map((label) => (
|
||||
<li key={label}>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
||||
{label === "Infrastructure" ? (
|
||||
<InfrastructureChips chips={infrastructureChips} />
|
||||
) : (
|
||||
<FeatureValue value={features[label]} />
|
||||
)}
|
||||
{label === "Watermark" && watermarkNote && (
|
||||
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
|
||||
{watermarkNote}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
{cta.type === "signin" && <SignInButton fullWidth />}
|
||||
{cta.type === "link" && (
|
||||
<Link
|
||||
href={cta.href}
|
||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
|
||||
>
|
||||
{cta.label}
|
||||
</Link>
|
||||
)}
|
||||
{cta.type === "disabled" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
{cta.type === "mailto" && (
|
||||
<a
|
||||
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
|
||||
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
|
||||
>
|
||||
{cta.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="pricing"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Free for getting started, Pro for creators, or Enterprise for teams.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{PLANS.map((plan, index) => (
|
||||
<ScrollReveal key={plan.name} delay={index * 100}>
|
||||
<PricingCard plan={plan} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={360}>
|
||||
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
|
||||
*Pro is a monthly subscription with included quota (extensions via support). Self-hosted
|
||||
open-source deployments have no quotas.
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
|
||||
Technical support is strictly reserved for Managed Cloud and paid Professional Setup
|
||||
agreements; independent self-hosted deployments are community-supported.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Privacy } from "@prisma/client";
|
||||
|
||||
type Props = {
|
||||
value: Privacy;
|
||||
onChange: (value: Privacy) => void;
|
||||
};
|
||||
|
||||
const OPTIONS: { value: Privacy; label: string }[] = [
|
||||
{ value: "PUBLIC", label: "Public" },
|
||||
{ value: "PRIVATE", label: "Private" },
|
||||
{ value: "UNLISTED", label: "Unlisted" },
|
||||
];
|
||||
|
||||
export function PrivacyToggle({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex rounded border border-gray-600 overflow-hidden">
|
||||
{OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`flex-1 px-3 py-2 text-sm transition-colors ${
|
||||
value === opt.value
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-light text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isYouTubeUploadLimitError } from "@/lib/youtube/errors";
|
||||
import { YouTubeLimitBanner } from "./YouTubeLimitBanner";
|
||||
|
||||
/** Shows a dashboard alert when a recent job hit YouTube's upload limit. */
|
||||
export function RecentYouTubeLimitAlert() {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
fetch("/api/jobs?limit=10")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const hit = (data.jobs ?? []).some((job: { items?: Array<{ status: string; error?: string | null }> }) =>
|
||||
(job.items ?? []).some(
|
||||
(item) => item.status === "FAILED" && item.error && isYouTubeUploadLimitError(item.error),
|
||||
),
|
||||
);
|
||||
if (active) setShow(hit);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!show) return null;
|
||||
return <YouTubeLimitBanner className="mb-6" />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Plan } from "@prisma/client";
|
||||
import { getResolutionsForPlan } from "@/lib/plans";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
plan?: Plan;
|
||||
};
|
||||
|
||||
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) {
|
||||
const resolutions = getResolutionsForPlan(plan);
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white focus:border-accent focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{resolutions.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Direction = "up" | "left" | "right";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
direction?: Direction;
|
||||
};
|
||||
|
||||
const OFFSET: Record<Direction, string> = {
|
||||
up: "translate-y-10",
|
||||
left: "-translate-x-10",
|
||||
right: "translate-x-10",
|
||||
};
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
direction = "up",
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${className} ${
|
||||
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
|
||||
}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
type Props = {
|
||||
sectionRef: RefObject<HTMLElement | null>;
|
||||
title: string;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) return;
|
||||
|
||||
const update = () => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const progress = Math.min(
|
||||
1,
|
||||
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
|
||||
);
|
||||
setOffsetX(progress * offset);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [sectionRef, offset]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
|
||||
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
large?: boolean;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export function SignInButton({ large, fullWidth }: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
|
||||
className={`inline-flex items-center justify-center gap-3 rounded border border-gray-300 bg-white font-medium text-gray-800 transition-all duration-300 hover:scale-[1.02] hover:bg-gray-50 hover:shadow-lg hover:shadow-white/10 ${
|
||||
large ? "px-8 py-3 text-base" : "px-4 py-2 text-sm"
|
||||
} ${fullWidth ? "w-full" : ""}`}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:bg-surface-light"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useMockupProgress } from "@/hooks/useMockupProgress";
|
||||
|
||||
function LayersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SlidersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
|
||||
<circle cx="4" cy="14" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="20" cy="16" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudUploadIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
|
||||
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Single/Batch creation",
|
||||
desc: "One image, many audios, each becomes a separate video.",
|
||||
Icon: LayersIcon,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Per-video settings",
|
||||
desc: "Title, tags, privacy, and category for every upload.",
|
||||
Icon: SlidersIcon,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Direct upload",
|
||||
desc: "Connect YouTube once and publish automatically.",
|
||||
Icon: CloudUploadIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function CheckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppMockup() {
|
||||
const phaseOneMs = 3000;
|
||||
const phaseTwoMs = 2000;
|
||||
const initialWidth = "20%";
|
||||
const midWidth = "45%";
|
||||
const { ref, inView } = useInView();
|
||||
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
|
||||
|
||||
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
|
||||
</div>
|
||||
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
|
||||
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
|
||||
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-400">
|
||||
{processed ? (
|
||||
"Processed"
|
||||
) : (
|
||||
<>
|
||||
Processing
|
||||
<span className="mockup-dot-1">.</span>
|
||||
<span className="mockup-dot-2">.</span>
|
||||
<span className="mockup-dot-3">.</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
className="relative h-full rounded-full bg-white/90 ease-out"
|
||||
style={{
|
||||
width,
|
||||
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
|
||||
}}
|
||||
>
|
||||
{!processed && (
|
||||
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
|
||||
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({
|
||||
number,
|
||||
title,
|
||||
desc,
|
||||
Icon,
|
||||
}: {
|
||||
number: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
Icon: typeof LayersIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
|
||||
<span
|
||||
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepsSection() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<ScrollReveal direction="left">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
|
||||
Upload your content in 3 simple steps:
|
||||
</h2>
|
||||
<p className="mt-6 text-base leading-relaxed text-gray-400">
|
||||
From a single track to a full batch, Songs2YT walks you through creating and
|
||||
publishing videos to YouTube without touching a video editor.
|
||||
</p>
|
||||
<AppMockup />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="flex flex-col gap-7">
|
||||
{STEPS.map((step, index) => (
|
||||
<ScrollReveal key={step.number} delay={index * 120} direction="right">
|
||||
<StepCard
|
||||
number={step.number}
|
||||
title={step.title}
|
||||
desc={step.desc}
|
||||
Icon={step.Icon}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ScrollReveal } from "@/components/ScrollReveal";
|
||||
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
|
||||
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
|
||||
|
||||
type SupportCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
external?: boolean;
|
||||
hover: string;
|
||||
titleHover: string;
|
||||
linkClass: string;
|
||||
linkHover: string;
|
||||
};
|
||||
|
||||
function SupportCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
cta,
|
||||
external,
|
||||
hover,
|
||||
titleHover,
|
||||
linkClass,
|
||||
linkHover,
|
||||
}: SupportCardProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
|
||||
>
|
||||
{cta}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const SUPPORT_CARDS: SupportCardProps[] = [
|
||||
{
|
||||
title: "🛠️ Community & Self-Hosting",
|
||||
description:
|
||||
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
|
||||
href: GITEA_ISSUES_URL,
|
||||
cta: "Open Gitea Issues",
|
||||
external: true,
|
||||
hover:
|
||||
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
|
||||
titleHover: "group-hover:text-[#609926]",
|
||||
linkClass: "text-[#609926]",
|
||||
linkHover: "group-hover:text-[#7ab33a]",
|
||||
},
|
||||
{
|
||||
title: "📩 Billing & Premium Support",
|
||||
description:
|
||||
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
|
||||
href: `mailto:${SUPPORT_EMAIL}`,
|
||||
cta: SUPPORT_EMAIL,
|
||||
hover:
|
||||
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
|
||||
titleHover: "group-hover:text-accent",
|
||||
linkClass: "text-accent",
|
||||
linkHover: "group-hover:text-accent-hover",
|
||||
},
|
||||
];
|
||||
|
||||
export function SupportSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id="support"
|
||||
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
|
||||
>
|
||||
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
|
||||
Community help for self-hosters, or reach us directly for billing and premium support.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
|
||||
{SUPPORT_CARDS.map((card, index) => (
|
||||
<ScrollReveal
|
||||
key={card.title}
|
||||
direction={index === 0 ? "left" : "right"}
|
||||
delay={index * 120}
|
||||
>
|
||||
<SupportCard {...card} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { UPGRADE_URL } from "@/lib/plans";
|
||||
|
||||
const PRO_UPGRADE_SUFFIX = " Upload up to 50 videos with Pro!";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) {
|
||||
return (
|
||||
<Link href={UPGRADE_URL} className={className}>
|
||||
{children ?? "Upgrade to Pro"}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaErrorMessage({ message }: { message: string }) {
|
||||
if (message.endsWith(PRO_UPGRADE_SUFFIX)) {
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, -PRO_UPGRADE_SUFFIX.length)}{" "}
|
||||
<UpgradeProLink className="font-medium text-red-200 underline hover:text-white">
|
||||
Upload up to 50 videos with Pro!
|
||||
</UpgradeProLink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{message}</>;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plan, Privacy } from "@prisma/client";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { audioTagsToMetadata } from "@/lib/audio-tags";
|
||||
import type { ItemMetadata } from "@/lib/types";
|
||||
import { CategorySelect } from "./CategorySelect";
|
||||
import { PlaylistSelect } from "./PlaylistSelect";
|
||||
import { PrivacyToggle } from "./PrivacyToggle";
|
||||
import { ResolutionSelect } from "./ResolutionSelect";
|
||||
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
|
||||
|
||||
const UPLOAD_CONCURRENCY = 4;
|
||||
|
||||
type AudioItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
path: string | null;
|
||||
uploading: boolean;
|
||||
metadata: ItemMetadata;
|
||||
};
|
||||
|
||||
function defaultMetadata(title = ""): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC" as Privacy,
|
||||
categoryId: "10",
|
||||
resolution: "1280x720",
|
||||
notifySubscribers: true,
|
||||
madeForKids: false,
|
||||
embeddable: true,
|
||||
creativeCommons: false,
|
||||
includeWatermark: true,
|
||||
playlistId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function UploadForm() {
|
||||
const router = useRouter();
|
||||
const uploadSessionRef = useRef<string>(crypto.randomUUID());
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [audioItems, setAudioItems] = useState<AudioItem[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playlistId, setPlaylistId] = useState("");
|
||||
const [quota, setQuota] = useState<{
|
||||
remaining: number;
|
||||
limit: number;
|
||||
plan: Plan;
|
||||
maxBatchSize: number;
|
||||
resetsIn: string;
|
||||
videoCredits: number;
|
||||
totalAvailable: number;
|
||||
used: number;
|
||||
selfHosted?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
const res = await fetch("/api/quota");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuota({
|
||||
remaining: data.remaining,
|
||||
limit: data.limit,
|
||||
plan: data.plan,
|
||||
maxBatchSize: data.maxBatchSize,
|
||||
resetsIn: data.resetsIn,
|
||||
videoCredits: data.videoCredits ?? 0,
|
||||
totalAvailable: data.totalAvailable ?? data.remaining,
|
||||
used: data.used ?? 0,
|
||||
selfHosted: Boolean(data.selfHosted),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuota();
|
||||
}, [loadQuota]);
|
||||
|
||||
async function uploadFile(
|
||||
file: File,
|
||||
type: "image" | "audio",
|
||||
): Promise<{ path: string; audioTags?: Parameters<typeof audioTagsToMetadata>[0] | null }> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
formData.append("session", uploadSessionRef.current);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Upload failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
return { path: data.path, audioTags: data.audioTags };
|
||||
}
|
||||
|
||||
async function uploadFilesWithConcurrency(files: File[]) {
|
||||
const pending = files.map((file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const autoTitle = filenameWithoutExtension(file.name);
|
||||
|
||||
setAudioItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
file,
|
||||
path: null,
|
||||
uploading: true,
|
||||
metadata: {
|
||||
...defaultMetadata(autoTitle),
|
||||
playlistId: playlistId || null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return { id, file, autoTitle };
|
||||
});
|
||||
|
||||
for (let i = 0; i < pending.length; i += UPLOAD_CONCURRENCY) {
|
||||
const chunk = pending.slice(i, i + UPLOAD_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(async ({ id, file, autoTitle }) => {
|
||||
const upload = await uploadFile(file, "audio");
|
||||
const tagMetadata = upload.audioTags
|
||||
? audioTagsToMetadata(upload.audioTags, autoTitle)
|
||||
: null;
|
||||
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
path: upload.path,
|
||||
uploading: false,
|
||||
metadata: tagMetadata
|
||||
? { ...item.metadata, ...tagMetadata }
|
||||
: item.metadata,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
if (results[j].status === "rejected") {
|
||||
const { id } = chunk[j];
|
||||
const reason = results[j] as PromiseRejectedResult;
|
||||
setError(reason.reason instanceof Error ? reason.reason.message : "Audio upload failed");
|
||||
setAudioItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setImageFile(file);
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const path = await uploadFile(file, "image");
|
||||
setImagePath(path.path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Image upload failed");
|
||||
setImageFile(null);
|
||||
setImagePath(null);
|
||||
} finally {
|
||||
setImageUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAudioChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setError(null);
|
||||
|
||||
const maxBatch = quota?.maxBatchSize ?? 3;
|
||||
if (audioItems.length + files.length > maxBatch) {
|
||||
setError(`Your plan allows up to ${maxBatch} audio files per batch.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFilesWithConcurrency(files);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function updateItemMetadata(id: string, updates: Partial<ItemMetadata>) {
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, metadata: { ...item.metadata, ...updates } }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePlaylistChange(nextPlaylistId: string) {
|
||||
setPlaylistId(nextPlaylistId);
|
||||
setAudioItems((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
playlistId: nextPlaylistId || null,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function removeAudioItem(id: string) {
|
||||
setAudioItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
const readyAudios = audioItems.filter((a) => a.path && !a.uploading);
|
||||
const canSubmit =
|
||||
imagePath &&
|
||||
!imageUploading &&
|
||||
readyAudios.length > 0 &&
|
||||
readyAudios.every((a) => a.metadata.title.trim()) &&
|
||||
!submitting;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || !imagePath) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imagePath,
|
||||
items: readyAudios.map((item) => ({
|
||||
audioPath: item.path!,
|
||||
audioFilename: item.file.name,
|
||||
metadata: item.metadata,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to create job");
|
||||
|
||||
router.push(`/jobs/${data.jobId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Submission failed");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{quota && (
|
||||
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
|
||||
{quota.selfHosted ? (
|
||||
<>
|
||||
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
|
||||
batch
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{quota.remaining} of {quota.limit} plan videos remaining
|
||||
{quota.plan === "FREE" && quota.videoCredits > 0
|
||||
? ` · ${quota.videoCredits} pay-as-you-go credits`
|
||||
: ""}{" "}
|
||||
·{" "}
|
||||
{quota.plan === "FREE"
|
||||
? `resets in ${quota.resetsIn}`
|
||||
: `monthly quota resets on ${quota.resetsIn}`}{" "}
|
||||
· up to {quota.maxBatchSize} files per batch
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
<QuotaErrorMessage message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Files</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Image</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<StatusDot ok={!!imagePath && !imageUploading} />
|
||||
<span className="text-gray-400">
|
||||
{imageUploading ? "Uploading…" : imageFile?.name || "No image selected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-gray-400">Audio files</label>
|
||||
<input
|
||||
type="file"
|
||||
accept={
|
||||
quota?.selfHosted || quota?.plan === "PREMIUM"
|
||||
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
|
||||
: "audio/mpeg,.mp3"
|
||||
}
|
||||
multiple
|
||||
onChange={handleAudioChange}
|
||||
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<StatusDot ok={readyAudios.length > 0} />
|
||||
<span className="text-gray-400">
|
||||
{audioItems.length === 0
|
||||
? "No audio files selected"
|
||||
: `${readyAudios.length} of ${audioItems.length} ready`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{audioItems.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Each audio file gets its own metadata. Title is auto-filled from the filename.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-6">
|
||||
<PlaylistSelect
|
||||
value={playlistId}
|
||||
onChange={handlePlaylistChange}
|
||||
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{audioItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-700 bg-surface p-6 space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-white">
|
||||
Video {index + 1}: {item.file.name}
|
||||
</h3>
|
||||
{item.uploading && (
|
||||
<p className="text-xs text-yellow-400">Uploading…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAudioItem(item.id)}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Title">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.title}
|
||||
onChange={(e) => updateItemMetadata(item.id, { title: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Category">
|
||||
<CategorySelect
|
||||
value={item.metadata.categoryId}
|
||||
onChange={(v) => updateItemMetadata(item.id, { categoryId: v })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<textarea
|
||||
value={item.metadata.description}
|
||||
onChange={(e) => updateItemMetadata(item.id, { description: e.target.value })}
|
||||
rows={3}
|
||||
className="input-field resize-y"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tags">
|
||||
<input
|
||||
type="text"
|
||||
value={item.metadata.tags}
|
||||
onChange={(e) => updateItemMetadata(item.id, { tags: e.target.value })}
|
||||
placeholder='Separate with spaces or commas. Use "quoted phrases" for multi-word tags.'
|
||||
className="input-field"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Privacy">
|
||||
<PrivacyToggle
|
||||
value={item.metadata.privacy}
|
||||
onChange={(v) => updateItemMetadata(item.id, { privacy: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Video size">
|
||||
<ResolutionSelect
|
||||
value={item.metadata.resolution}
|
||||
onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
|
||||
plan={quota?.plan}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Checkbox
|
||||
label="Notify subscribers about this upload"
|
||||
checked={item.metadata.notifySubscribers}
|
||||
onChange={(v) => updateItemMetadata(item.id, { notifySubscribers: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Made For Kids?"
|
||||
checked={item.metadata.madeForKids}
|
||||
onChange={(v) => updateItemMetadata(item.id, { madeForKids: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Embeddable?"
|
||||
checked={item.metadata.embeddable}
|
||||
onChange={(v) => updateItemMetadata(item.id, { embeddable: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Creative Commons?"
|
||||
checked={item.metadata.creativeCommons}
|
||||
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Show 'Uploaded through Songs2YT.com' watermark (support open-source)"
|
||||
checked={item.metadata.includeWatermark}
|
||||
onChange={(v) => updateItemMetadata(item.id, { includeWatermark: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{quota && !quota.selfHosted && quota.plan === "FREE" && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400">
|
||||
Watermark is optional on the free plan - turn it off anytime for a clean video.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
<UpgradeProLink className="text-accent hover:underline" /> for 50 videos/month, 1080p,
|
||||
lossless audio, and full watermark control.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded bg-accent px-6 py-3 font-medium text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? "Creating videos…"
|
||||
: `Create ${readyAudios.length || ""} Video${readyAudios.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ ok }: { ok: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-400">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className={`flex items-center gap-2 text-sm text-gray-300 ${disabled ? "opacity-60" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="rounded border-gray-600 bg-surface-light text-accent focus:ring-accent"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE } from "@/lib/youtube/errors";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function YouTubeLimitBanner({ className = "" }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100 ${className}`}
|
||||
>
|
||||
<p className="font-medium text-amber-200">YouTube upload limit reached</p>
|
||||
<p className="mt-1 text-amber-100/90">{YOUTUBE_UPLOAD_LIMIT_USER_MESSAGE}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user