diff --git a/landing/src/app/download/[platform]/route.ts b/landing/src/app/download/[platform]/route.ts index af76c315..6c007c17 100644 --- a/landing/src/app/download/[platform]/route.ts +++ b/landing/src/app/download/[platform]/route.ts @@ -1,12 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { getLatestRelease } from '@/lib/releases'; export const dynamic = 'force-dynamic'; -const PLATFORM_MAP: Record< - string, - keyof Awaited>['downloadLinks'] -> = { +// Pretty URLs from README / docs (e.g. /download/mac-arm) are kept for +// compatibility, but we now always route through the /download page so users +// see context + a donate prompt + resources while the download kicks off. +// The page handles the actual file trigger itself — no more silent redirects +// to GitHub or direct asset URLs. +const PLATFORM_ALIAS: Record = { 'mac-arm': 'macArm', 'mac-intel': 'macIntel', windows: 'windows', @@ -14,29 +15,12 @@ const PLATFORM_MAP: Record< }; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ platform: string }> }, ) { const { platform } = await params; - const key = PLATFORM_MAP[platform]; - - if (!key) { - return NextResponse.json( - { error: `Unknown platform: ${platform}. Use: ${Object.keys(PLATFORM_MAP).join(', ')}` }, - { status: 404 }, - ); - } - - try { - const release = await getLatestRelease(); - const url = release.downloadLinks[key]; - - if (!url) { - return NextResponse.json({ error: `No download available for ${platform}` }, { status: 404 }); - } - - return NextResponse.redirect(url); - } catch { - return NextResponse.redirect(`https://github.com/jamiepine/voicebox/releases/latest`); - } + const normalized = PLATFORM_ALIAS[platform]; + const target = new URL('/download', request.url); + if (normalized) target.searchParams.set('platform', normalized); + return NextResponse.redirect(target, 307); } diff --git a/landing/src/app/download/page.tsx b/landing/src/app/download/page.tsx new file mode 100644 index 00000000..087b9d4d --- /dev/null +++ b/landing/src/app/download/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { + ArrowLeft, + Bot, + Coffee, + Download as DownloadIcon, + FileText, + Github, +} from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons'; +import { Button } from '@/components/ui/button'; +import { DONATE_URL, GITHUB_REPO } from '@/lib/constants'; +import type { DownloadLinks } from '@/lib/releases'; + +type Platform = keyof DownloadLinks; + +type PlatformMeta = { + key: Platform; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; +}; + +const PLATFORMS: PlatformMeta[] = [ + { key: 'macArm', label: 'macOS', description: 'Apple Silicon', icon: AppleIcon }, + { key: 'macIntel', label: 'macOS', description: 'Intel (x64)', icon: AppleIcon }, + { key: 'windows', label: 'Windows', description: '64-bit (MSI)', icon: WindowsIcon }, + { key: 'linux', label: 'Linux', description: 'AppImage', icon: LinuxIcon }, +]; + +function detectPlatform(): Platform | null { + if (typeof navigator === 'undefined') return null; + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return 'windows'; + if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux'; + if (/Mac/i.test(ua)) { + // Apple Silicon Safari reports "Intel" for compat; default to ARM since + // M-series is the majority. Users can click the Intel button if needed. + return 'macArm'; + } + return null; +} + +function parseQueryPlatform(search: string): Platform | null { + const params = new URLSearchParams(search); + const raw = params.get('platform'); + if (!raw) return null; + // Accept both camelCase and hyphenated forms (/download/mac-arm → ?platform=mac-arm). + const normalized = raw + .toLowerCase() + .replace(/[-_\s]/g, '') + .replace('macarm', 'macArm') + .replace('macintel', 'macIntel'); + const valid: Platform[] = ['macArm', 'macIntel', 'windows', 'linux']; + return (valid as string[]).includes(normalized) ? (normalized as Platform) : null; +} + +export default function DownloadPage() { + const [links, setLinks] = useState(null); + const [linksError, setLinksError] = useState(false); + const [platform, setPlatform] = useState(null); + const [triggered, setTriggered] = useState(false); + + useEffect(() => { + const fromQuery = parseQueryPlatform(window.location.search); + setPlatform(fromQuery ?? detectPlatform()); + }, []); + + useEffect(() => { + let cancelled = false; + fetch('/api/releases') + .then((r) => { + if (!r.ok) throw new Error(`releases ${r.status}`); + return r.json(); + }) + .then((data) => { + if (cancelled) return; + if (data.downloadLinks) setLinks(data.downloadLinks as DownloadLinks); + }) + .catch(() => { + if (!cancelled) setLinksError(true); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (triggered || !links || !platform) return; + const url = links[platform]; + if (!url) return; + + const a = document.createElement('a'); + a.href = url; + a.rel = 'noopener'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTriggered(true); + }, [triggered, links, platform]); + + const activeMeta = useMemo( + () => PLATFORMS.find((p) => p.key === platform) ?? null, + [platform], + ); + + return ( +
+ {/* Minimal branded header */} +
+
+ + Voicebox + Voicebox + + + + Back to voicebox.sh + +
+
+ +
+ {/* Hero */} +
+ Voicebox +
+ {triggered ? ( + <> +

+ Your download has started. +

+

+ {activeMeta + ? `Downloading Voicebox for ${activeMeta.label} (${activeMeta.description}). Check your downloads folder.` + : 'Check your downloads folder for Voicebox.'} +

+ + ) : ( + <> +

+ Download Voicebox +

+

+ {linksError + ? "We couldn't reach the release server. Pick your platform manually below." + : 'Pick your platform to get started.'} +

+ + )} +
+
+ + {/* Platform buttons — always visible as a fallback */} + + + {/* Donate — prominent, heartfelt, post-click context */} +
+
+
+
+ + + Hi from the maintainer + +
+

+ Jamie here — Voicebox is a side project. +

+

+ I build and maintain Voicebox in my spare time. It's completely + free, open source, runs entirely on your machine — no accounts, no + cloud, no subscriptions, no upsells. If it saves you an ElevenLabs + bill or just made your day, a coffee genuinely helps me keep + shipping updates, adding new models, and fixing bugs. Every little + bit keeps the lights on. +

+ +
+
+ + {/* Resources */} + +
+
+ ); +} diff --git a/landing/src/app/page.tsx b/landing/src/app/page.tsx index 2d34c37a..0e9c857c 100644 --- a/landing/src/app/page.tsx +++ b/landing/src/app/page.tsx @@ -17,12 +17,9 @@ import {Navbar} from "@/components/Navbar"; import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons"; import {TutorialsSection} from "@/components/TutorialsSection"; import {VoiceCreator} from "@/components/VoiceCreator"; -import {DOWNLOAD_LINKS, GITHUB_REPO} from "@/lib/constants"; -import type {DownloadLinks} from "@/lib/releases"; +import {GITHUB_REPO} from "@/lib/constants"; export default function Home() { - const [downloadLinks, setDownloadLinks] = - useState(DOWNLOAD_LINKS); const [version, setVersion] = useState(null); const [totalDownloads, setTotalDownloads] = useState(null); @@ -33,7 +30,6 @@ export default function Home() { return res.json(); }) .then((data) => { - if (data.downloadLinks) setDownloadLinks(data.downloadLinks); if (data.version) setVersion(data.version); if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads); }) @@ -403,8 +399,7 @@ export default function Home() {