Files
video-downloader/src/components/SubmitForm.tsx
T
anthonyandClaude Sonnet 5 948b77cef4 feat: add per-platform SEO landing pages (/youtube-downloader, etc.)
Create a dedicated, dynamically-routed page for each of 15 curated
platforms (YouTube, TikTok, Instagram, Facebook, X, Reddit, Pinterest,
Vimeo, SoundCloud, Twitch, Dailymotion, LinkedIn, Tumblr, VK, Snapchat)
under /[locale]/[slug], with a tailored hero, an example URL matched
to the platform, a unique "about" paragraph, and a mix of
platform-specific and shared FAQ items, all translated across
en/fr/es/it. Unknown slugs 404.

Kept the scope to a curated list rather than all 1380 yt-dlp
extractors to avoid thin/duplicate "doorway page" content that search
engines penalize.

The homepage's platform badges and the "+1000 sites" links now point
to these pages, extracted the FAQ accordion markup into a shared
FaqAccordion component reused by both the homepage FAQ and the new
platform pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:53:36 +02:00

146 lines
5.3 KiB
TypeScript

'use client'
import { useState, FormEvent } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from '@/navigation'
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
function Spinner() {
return (
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)
}
const selectClass =
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string } = {}) {
const t = useTranslations('home')
const router = useRouter()
const [url, setUrl] = useState('')
const [format, setFormat] = useState('mp4')
const [quality, setQuality] = useState('best')
const [subtitles, setSubtitles] = useState(false)
const [extraArgs, setExtraArgs] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const res = await fetch('/api/downloads', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
url,
format,
quality,
subtitles,
extraArgs: extraArgs.trim() || null,
}),
})
setLoading(false)
if (res.status === 429) {
setError(t('errorRateLimit'))
return
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
setError((body as { error?: string }).error ?? t('errorGeneric'))
return
}
const { uuid } = (await res.json()) as { uuid: string }
router.push(`/status/${uuid}`)
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
{/* URL input with inline submit button */}
<div className="relative">
{/* Violet glow behind the input */}
<div className="absolute inset-0 -z-10 rounded-xl bg-violet-500 blur-3xl opacity-15 dark:opacity-20" aria-hidden />
<div className="flex rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-violet-500">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
placeholder={examplePlaceholder ?? 'https://www.youtube.com/watch?v=...'}
className="flex-1 px-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60 cursor-pointer disabled:cursor-not-allowed"
>
{loading && <Spinner />}
{loading ? t('submitting') : t('submit')}
</button>
</div>
</div>
{/* Options row */}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
<label className="flex items-center gap-2">
<span>{t('format')}</span>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className={selectClass}
>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
</label>
<label className="flex items-center gap-2">
<span>{t('quality')}</span>
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
className={selectClass}
>
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
className="accent-violet-600 w-4 h-4"
/>
<span>{t('subtitles')}</span>
</label>
<details className="w-full mt-1">
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
{t('advanced')}
</summary>
<input
type="text"
value={extraArgs}
onChange={(e) => setExtraArgs(e.target.value)}
placeholder='["--sponsorblock-remove","all"]'
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
/>
</details>
</div>
{error && (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</form>
)
}