jefflix-website/app/request-channel/page.tsx

487 lines
20 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { JefflixLogo } from "@/components/jefflix-logo"
import { GlobeLoader, type GlobePoint } from "@/components/globe/globe-loader"
import { countryToLatLng } from "@/lib/country-centroids"
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2, Globe2, List } from "lucide-react"
import Link from 'next/link'
interface Channel {
id: string
name: string
country: string
categories: string[]
}
export default function RequestChannelPage() {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [selected, setSelected] = useState<Channel[]>([])
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null)
// Globe view toggle
const [view, setView] = useState<'search' | 'globe'>('search')
const [globeCountry, setGlobeCountry] = useState<string | null>(null)
// Build globe points from channels (aggregate by country)
const globePoints: GlobePoint[] = useMemo(() => {
const countryMap = new Map<string, { count: number; coords: [number, number] }>()
for (const ch of channels) {
if (!ch.country) continue
const existing = countryMap.get(ch.country)
if (existing) {
existing.count++
} else {
const coords = countryToLatLng(ch.country)
if (coords) {
countryMap.set(ch.country, { count: 1, coords })
}
}
}
return Array.from(countryMap.entries()).map(([country, { count, coords }]) => ({
lat: coords[0],
lng: coords[1],
id: country,
label: `${country} (${count} channels)`,
color: '#06b6d4', // cyan-500
size: Math.max(1, Math.min(8, Math.log2(count + 1) * 2)),
}))
}, [channels])
// Channels filtered for globe-selected country
const globeFilteredChannels = useMemo(() => {
if (!globeCountry) return []
return channels.filter((ch) => ch.country === globeCountry)
}, [channels, globeCountry])
const handleGlobePointClick = useCallback((point: GlobePoint) => {
setGlobeCountry(point.id) // id = country name
}, [])
// Fetch channel list on mount
useEffect(() => {
fetch('/api/channels')
.then((res) => {
if (!res.ok) throw new Error('Failed to load channels')
return res.json()
})
.then((data: Channel[]) => {
setChannels(data)
setLoading(false)
})
.catch((err) => {
setLoadError(err.message)
setLoading(false)
})
}, [])
// Debounce search input
useEffect(() => {
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 200)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
const results = debouncedQuery.length < 2
? []
: channels.filter((ch) => {
const q = debouncedQuery.toLowerCase()
return (
ch.name.toLowerCase().includes(q) ||
ch.country.toLowerCase().includes(q) ||
ch.categories.some((c) => c.toLowerCase().includes(q))
)
})
const totalMatches = results.length
const displayResults = results.slice(0, 50)
const toggleChannel = useCallback((ch: Channel) => {
setSelected((prev) =>
prev.some((s) => s.id === ch.id)
? prev.filter((s) => s.id !== ch.id)
: prev.length < 20
? [...prev, ch]
: prev
)
}, [])
const removeChannel = useCallback((id: string) => {
setSelected((prev) => prev.filter((s) => s.id !== id))
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (selected.length === 0) return
setStatus('submitting')
setErrorMessage('')
try {
const res = await fetch('/api/request-channel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
channels: selected.map(({ id, name, country, categories }) => ({
id, name, country, categories,
})),
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to submit request')
setStatus('success')
} catch (err) {
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Something went wrong')
}
}
if (status === 'success') {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-block p-6 bg-green-100 dark:bg-green-900/30 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold">Request Submitted!</h1>
<p className="text-muted-foreground">
We&apos;ll look into adding {selected.length === 1 ? 'this channel' : `these ${selected.length} channels`} and
let you know once {selected.length === 1 ? "it's" : "they're"} available.
</p>
<p className="text-sm text-muted-foreground">
Most channels are added within a few days.
</p>
<Link href="/">
<Button variant="outline" className="mt-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Jefflix
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border">
<div className="container mx-auto px-4 py-4">
<Link href="/" className="inline-block">
<JefflixLogo size="small" />
</Link>
</div>
</div>
{/* Main Content */}
<div className="container mx-auto px-4 py-12 md:py-20">
<div className="max-w-2xl mx-auto">
<div className="text-center space-y-4 mb-8">
<div className="inline-block p-4 bg-cyan-100 dark:bg-cyan-900/30 rounded-full">
<Search className="h-10 w-10 text-cyan-600 dark:text-cyan-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Request a Channel</h1>
<p className="text-muted-foreground">
Search the iptv-org catalog and select channels you&apos;d like us to add to the Live TV lineup.
</p>
{/* View toggle */}
{!loading && !loadError && (
<div className="flex items-center justify-center gap-2">
<Button
variant={view === 'search' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('search')}
className={view === 'search' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
>
<List className="h-4 w-4 mr-1.5" />
Search
</Button>
<Button
variant={view === 'globe' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('globe')}
className={view === 'globe' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
>
<Globe2 className="h-4 w-4 mr-1.5" />
Globe
</Button>
</div>
)}
</div>
{loading ? (
<div className="flex flex-col items-center gap-4 py-16">
<Loader2 className="h-8 w-8 animate-spin text-cyan-600" />
<p className="text-muted-foreground">Loading channel catalog...</p>
</div>
) : loadError ? (
<div className="flex flex-col items-center gap-4 py-16">
<AlertCircle className="h-8 w-8 text-red-500" />
<p className="text-muted-foreground">{loadError}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Try Again
</Button>
</div>
) : view === 'globe' ? (
/* Globe View */
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<GlobeLoader
points={globePoints}
onPointClick={handleGlobePointClick}
height={450}
autoRotate={!globeCountry}
/>
</div>
{/* Selected chips (shared with search view) */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2">
{selected.map((ch) => (
<Badge
key={ch.id}
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
onClick={() => removeChannel(ch.id)}
>
{ch.name}
<X className="h-3.5 w-3.5 ml-1" />
</Badge>
))}
</div>
)}
{/* Country channel list */}
{globeCountry && (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div>
<h3 className="font-semibold">{globeCountry}</h3>
<p className="text-xs text-muted-foreground">{globeFilteredChannels.length} channels</p>
</div>
<button onClick={() => setGlobeCountry(null)} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[300px] overflow-y-auto divide-y divide-border">
{globeFilteredChannels.map((ch) => {
const isSelected = selected.some((s) => s.id === ch.id)
return (
<button
key={ch.id}
type="button"
onClick={() => toggleChannel(ch)}
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
}`}
>
<div className="min-w-0">
<div className="font-medium truncate">{ch.name}</div>
<div className="flex flex-wrap gap-1 mt-1">
{ch.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs">{cat}</Badge>
))}
</div>
</div>
{isSelected && <CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />}
</button>
)
})}
</div>
</div>
)}
{!globeCountry && (
<p className="text-center text-sm text-muted-foreground">
Click a point on the globe to browse channels by country
</p>
)}
{/* Email + Submit (same as search view) */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="globe-email" className="text-sm font-medium">Your Email</label>
<input
type="email"
id="globe-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="your@email.com"
/>
</div>
{status === 'error' && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
)}
<Button
type="submit"
disabled={status === 'submitting' || selected.length === 0}
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
>
{status === 'submitting'
? 'Submitting...'
: selected.length === 0
? 'Select channels to request'
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
</Button>
</form>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="Search by channel name, country, or category..."
autoFocus
/>
</div>
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2">
{selected.map((ch) => (
<Badge
key={ch.id}
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
onClick={() => removeChannel(ch.id)}
>
{ch.name}
<X className="h-3.5 w-3.5 ml-1" />
</Badge>
))}
</div>
)}
{/* Results */}
{debouncedQuery.length >= 2 && (
<div className="border border-border rounded-lg divide-y divide-border max-h-[400px] overflow-y-auto">
{displayResults.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
No channels found for &quot;{debouncedQuery}&quot;
</div>
) : (
<>
{displayResults.map((ch) => {
const isSelected = selected.some((s) => s.id === ch.id)
return (
<button
key={ch.id}
type="button"
onClick={() => toggleChannel(ch)}
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
}`}
>
<div className="min-w-0">
<div className="font-medium truncate">{ch.name}</div>
<div className="flex flex-wrap gap-1 mt-1">
{ch.country && (
<Badge variant="outline" className="text-xs">
{ch.country}
</Badge>
)}
{ch.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
</div>
</div>
{isSelected && (
<CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />
)}
</button>
)
})}
{totalMatches > 50 && (
<div className="p-3 text-center text-sm text-muted-foreground bg-muted/30">
{totalMatches - 50} more refine your search
</div>
)}
</>
)}
</div>
)}
{debouncedQuery.length > 0 && debouncedQuery.length < 2 && (
<p className="text-sm text-muted-foreground text-center">
Type at least 2 characters to search
</p>
)}
{/* Email */}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Your Email
</label>
<input
type="email"
id="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="your@email.com"
/>
<p className="text-xs text-muted-foreground">
We&apos;ll let you know when the channels are added
</p>
</div>
{/* Error */}
{status === 'error' && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
)}
{/* Submit */}
<Button
type="submit"
disabled={status === 'submitting' || selected.length === 0}
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
>
{status === 'submitting'
? 'Submitting...'
: selected.length === 0
? 'Select channels to request'
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
</Button>
</form>
)}
<div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4">
<h3 className="font-bold mb-2">How does this work?</h3>
<p className="text-sm text-muted-foreground">
This catalog comes from <a href="https://iptv-org.github.io" className="text-cyan-600 hover:underline font-medium">iptv-org</a>,
a community-maintained collection of publicly available IPTV streams. Select the channels
you want and we&apos;ll map them into the Jefflix Live TV lineup.
</p>
<p className="text-xs text-muted-foreground border-t border-border pt-3">
Not all channels have working streamswe&apos;ll do our best and let you know the result.
</p>
</div>
</div>
</div>
</div>
)
}