feat: add supported sites page, remove yt-dlp mentions from public copy

yt-dlp is an internal implementation detail and should not be exposed
on the public site. Replace direct mentions in the platforms and FAQ
sections with a link to a new /supported-sites page listing every
extractor from yt-dlp's supported-sites doc (searchable, grouped
alphabetically), translated across en/fr/es/it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:36:29 +02:00
co-authored by Claude Sonnet 5
parent c8e991a8f7
commit bd863056a3
9 changed files with 1617 additions and 17 deletions
+23 -8
View File
@@ -1,7 +1,12 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/navigation'
const QUESTION_COUNT = 8
function stripRichTags(text: string) {
return text.replace(/<link>(.*?)<\/link>/g, '$1')
}
function ChevronIcon() {
return (
<svg
@@ -24,18 +29,28 @@ function ChevronIcon() {
export async function FaqSection({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'faq' })
const items = Array.from({ length: QUESTION_COUNT }, (_, i) => ({
q: t(`q${i + 1}`),
a: t(`a${i + 1}`),
}))
const items = Array.from({ length: QUESTION_COUNT }, (_, i) => {
const key = `a${i + 1}` as const
return {
q: t(`q${i + 1}`),
aText: stripRichTags(t.raw(key) as string),
aNode: t.rich(key, {
link: (chunks) => (
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
{chunks}
</Link>
),
}),
}
})
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map(({ q, a }) => ({
mainEntity: items.map(({ q, aText }) => ({
'@type': 'Question',
name: q,
acceptedAnswer: { '@type': 'Answer', text: a },
acceptedAnswer: { '@type': 'Answer', text: aText },
})),
}
@@ -50,7 +65,7 @@ export async function FaqSection({ locale }: { locale: string }) {
{t('heading')}
</h2>
<div className="space-y-3">
{items.map(({ q, a }) => (
{items.map(({ q, aNode }) => (
<details
key={q}
className="group rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-5 py-4"
@@ -59,7 +74,7 @@ export async function FaqSection({ locale }: { locale: string }) {
{q}
<ChevronIcon />
</summary>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{a}</p>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{aNode}</p>
</details>
))}
</div>
+10 -1
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/navigation'
const PLATFORMS = [
'YouTube',
@@ -33,7 +34,15 @@ export async function SupportedPlatformsSection({ locale }: { locale: string })
</span>
))}
</div>
<p className="mt-4 text-sm text-gray-400 dark:text-slate-500">{t('more')}</p>
<p className="mt-4 text-sm text-gray-400 dark:text-slate-500">
{t.rich('more', {
link: (chunks) => (
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
{chunks}
</Link>
),
})}
</p>
<p className="mt-6 text-xs text-gray-400 dark:text-slate-500 max-w-2xl mx-auto">{t('disclaimer')}</p>
</div>
</section>
+88
View File
@@ -0,0 +1,88 @@
'use client'
import { useMemo, useState } from 'react'
function SearchIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
export function SupportedSitesBrowser({
sites,
searchPlaceholder,
resultCountTemplate,
noResultsLabel,
}: {
sites: string[]
searchPlaceholder: string
resultCountTemplate: string
noResultsLabel: string
}) {
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return sites
return sites.filter((site) => site.toLowerCase().includes(q))
}, [sites, query])
const grouped = useMemo(() => {
const map = new Map<string, string[]>()
for (const site of filtered) {
const first = site[0]?.toUpperCase() ?? '#'
const letter = /[A-Z]/.test(first) ? first : '#'
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(site)
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [filtered])
return (
<div>
<div className="relative max-w-md mx-auto">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-slate-500">
<SearchIcon />
</span>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 pl-9 pr-4 py-2.5 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
<p className="mt-4 text-center text-sm text-gray-400 dark:text-slate-500">
{resultCountTemplate
.replace('{count}', String(filtered.length))
.replace('{total}', String(sites.length))}
</p>
{filtered.length === 0 ? (
<p className="mt-10 text-center text-gray-500 dark:text-gray-400">{noResultsLabel}</p>
) : (
<div className="mt-8 space-y-8">
{grouped.map(([letter, names]) => (
<div key={letter}>
<h2 className="text-sm font-bold text-violet-600 dark:text-violet-400 mb-3">{letter}</h2>
<div className="flex flex-wrap gap-2">
{names.map((name) => (
<span
key={name}
className="px-3 py-1.5 rounded-full border border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 text-sm text-gray-700 dark:text-gray-300"
>
{name}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}