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>
This commit is contained in:
2026-08-11 11:53:36 +02:00
co-authored by Claude Sonnet 5
parent bd863056a3
commit 948b77cef4
10 changed files with 672 additions and 57 deletions
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react'
function ChevronIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0 transition-transform duration-200 group-open:rotate-180"
>
<path d="M6 9l6 6 6-6" />
</svg>
)
}
export function FaqAccordion({ items }: { items: { q: string; a: ReactNode }[] }) {
return (
<div className="space-y-3">
{items.map(({ q, a }) => (
<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"
>
<summary className="flex items-center justify-between gap-4 cursor-pointer select-none font-medium text-gray-900 dark:text-gray-50 list-none [&::-webkit-details-marker]:hidden">
{q}
<ChevronIcon />
</summary>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{a}</p>
</details>
))}
</div>
)
}
+3 -34
View File
@@ -1,5 +1,6 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/navigation'
import { FaqAccordion } from '@/components/FaqAccordion'
const QUESTION_COUNT = 8
@@ -7,25 +8,6 @@ function stripRichTags(text: string) {
return text.replace(/<link>(.*?)<\/link>/g, '$1')
}
function ChevronIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0 transition-transform duration-200 group-open:rotate-180"
>
<path d="M6 9l6 6 6-6" />
</svg>
)
}
export async function FaqSection({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'faq' })
@@ -34,7 +16,7 @@ export async function FaqSection({ locale }: { locale: string }) {
return {
q: t(`q${i + 1}`),
aText: stripRichTags(t.raw(key) as string),
aNode: t.rich(key, {
a: t.rich(key, {
link: (chunks) => (
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
{chunks}
@@ -64,20 +46,7 @@ export async function FaqSection({ locale }: { locale: string }) {
<h2 id="faq-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-10">
{t('heading')}
</h2>
<div className="space-y-3">
{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"
>
<summary className="flex items-center justify-between gap-4 cursor-pointer select-none font-medium text-gray-900 dark:text-gray-50 list-none [&::-webkit-details-marker]:hidden">
{q}
<ChevronIcon />
</summary>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{aNode}</p>
</details>
))}
</div>
<FaqAccordion items={items} />
</div>
</section>
)
+2 -2
View File
@@ -19,7 +19,7 @@ function Spinner() {
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() {
export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string } = {}) {
const t = useTranslations('home')
const router = useRouter()
const [url, setUrl] = useState('')
@@ -75,7 +75,7 @@ export function SubmitForm() {
value={url}
onChange={(e) => setUrl(e.target.value)}
required
placeholder="https://www.youtube.com/watch?v=..."
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
+7 -17
View File
@@ -1,17 +1,6 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/navigation'
const PLATFORMS = [
'YouTube',
'TikTok',
'Instagram',
'Facebook',
'X (Twitter)',
'Vimeo',
'Twitch',
'SoundCloud',
'Dailymotion',
]
import { DOWNLOADER_PLATFORMS } from '@/lib/downloader-platforms'
export async function SupportedPlatformsSection({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: 'platforms' })
@@ -25,13 +14,14 @@ export async function SupportedPlatformsSection({ locale }: { locale: string })
<p className="mt-2 text-gray-500 dark:text-gray-400">{t('subheading')}</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
{PLATFORMS.map((name) => (
<span
key={name}
className="px-4 py-2 rounded-full border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-medium text-gray-700 dark:text-gray-300"
{DOWNLOADER_PLATFORMS.map(({ id, slug, name }) => (
<Link
key={id}
href={`/${slug}`}
className="px-4 py-2 rounded-full border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-medium text-gray-700 dark:text-gray-300 hover:border-violet-300 dark:hover:border-violet-700 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
>
{name}
</span>
</Link>
))}
</div>
<p className="mt-4 text-sm text-gray-400 dark:text-slate-500">