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:
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/navigation'
|
||||
import { SUPPORTED_SITES } from '@/lib/supported-sites'
|
||||
import { SupportedSitesBrowser } from '@/components/SupportedSitesBrowser'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'supportedSitesPage' })
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SupportedSitesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'supportedSitesPage' })
|
||||
|
||||
return (
|
||||
<main className="px-6 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block text-sm text-violet-600 dark:text-violet-400 hover:underline"
|
||||
>
|
||||
{t('back')}
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 text-3xl md:text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center">
|
||||
{t('heading')}
|
||||
</h1>
|
||||
<p className="mt-3 text-gray-500 dark:text-gray-400 text-center max-w-2xl mx-auto">
|
||||
{t('subheading')}
|
||||
</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<SupportedSitesBrowser
|
||||
sites={SUPPORTED_SITES}
|
||||
searchPlaceholder={t('searchPlaceholder')}
|
||||
resultCountTemplate={t.raw('resultCount')}
|
||||
noResultsLabel={t('noResults')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-12 text-xs text-gray-400 dark:text-slate-500 text-center max-w-2xl mx-auto">
|
||||
{t('note')}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user