Initial open-source self-hosted Songs2YT edition
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user