feat: replace request channel form with iptv-org search & select UI

Users now browse the iptv-org catalog instead of manually typing channel
names. Adds /api/channels proxy with 1-hour cache, debounced search,
click-to-select with chips, and updated admin email format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-22 18:21:36 -07:00
parent 709a87731c
commit cee87aef10
3 changed files with 334 additions and 174 deletions

55
app/api/channels/route.ts Normal file
View File

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
const IPTV_ORG_URL = 'https://iptv-org.github.io/api/channels.json'
interface IptvChannel {
id: string
name: string
country: string
categories: string[]
is_nsfw: boolean
is_closed: boolean
}
interface SlimChannel {
id: string
name: string
country: string
categories: string[]
}
let cache: { data: SlimChannel[]; fetchedAt: number } | null = null
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
export async function GET() {
try {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
return NextResponse.json(cache.data)
}
const res = await fetch(IPTV_ORG_URL, { next: { revalidate: 3600 } })
if (!res.ok) {
throw new Error(`iptv-org API returned ${res.status}`)
}
const channels: IptvChannel[] = await res.json()
const filtered: SlimChannel[] = channels
.filter((ch) => !ch.is_nsfw && !ch.is_closed)
.map(({ id, name, country, categories }) => ({ id, name, country, categories }))
cache = { data: filtered, fetchedAt: Date.now() }
return NextResponse.json(filtered)
} catch (error) {
console.error('Failed to fetch channels:', error)
// Return cached data even if stale on error
if (cache) {
return NextResponse.json(cache.data)
}
return NextResponse.json(
{ error: 'Failed to load channel list' },
{ status: 502 }
)
}
}

View File

@ -1,28 +1,44 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
interface ChannelSelection {
id: string
name: string
country: string
categories: string[]
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { channelName, category, url, notes, email } = body const { email, channels } = body as { email: string; channels: ChannelSelection[] }
// Validate required fields // Validate email
if (!channelName || !email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!email || !emailRegex.test(email)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Channel name and email are required' }, { error: 'A valid email is required' },
{ status: 400 } { status: 400 }
) )
} }
// Email validation // Validate channels
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!Array.isArray(channels) || channels.length === 0 || channels.length > 20) {
if (!emailRegex.test(email)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid email format' }, { error: 'Select between 1 and 20 channels' },
{ status: 400 } { status: 400 }
) )
} }
for (const ch of channels) {
if (!ch.id || !ch.name) {
return NextResponse.json(
{ error: 'Invalid channel data' },
{ status: 400 }
)
}
}
const smtpHost = process.env.SMTP_HOST const smtpHost = process.env.SMTP_HOST
const smtpUser = process.env.SMTP_USER const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS const smtpPass = process.env.SMTP_PASS
@ -44,48 +60,39 @@ export async function POST(request: NextRequest) {
tls: { rejectUnauthorized: false }, tls: { rejectUnauthorized: false },
}) })
const channelListHtml = channels
.map(
(ch) =>
`<li><strong>${escapeHtml(ch.name)}</strong> — <code>${escapeHtml(ch.id)}</code>` +
(ch.country ? ` (${escapeHtml(ch.country)})` : '') +
(ch.categories.length > 0 ? ` [${ch.categories.map(escapeHtml).join(', ')}]` : '') +
`</li>`
)
.join('\n')
const subject =
channels.length === 1
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
: `[Jefflix] Channel Request: ${channels.length} channels`
await transporter.sendMail({ await transporter.sendMail({
from: `Jefflix <${smtpUser}>`, from: `Jefflix <${smtpUser}>`,
to: adminEmail, to: adminEmail,
subject: `[Jefflix] Channel Request: ${escapeHtml(channelName)}`, subject,
html: ` html: `
<h2>New Channel Request</h2> <h2>New Channel Request</h2>
<p>Someone has requested a new Live TV channel:</p> <p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p>
<table style="border-collapse: collapse; margin: 20px 0;"> <ul style="line-height: 1.8;">
<tr> ${channelListHtml}
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Channel:</td> </ul>
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(channelName)}</td> <p><strong>To add these channels:</strong></p>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Category:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(category || 'Not specified')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">URL/Link:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${url ? `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>` : 'Not provided'}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Notes:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(notes || 'None')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Email:</td>
<td style="padding: 8px; border: 1px solid #ddd;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Requested:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${new Date().toLocaleString()}</td>
</tr>
</table>
<p><strong>To add this channel:</strong></p>
<ol> <ol>
<li>Check <a href="https://iptv-org.github.io">iptv-org</a> for an existing stream</li> <li>Search each channel ID in Threadfin / iptv-org</li>
<li>If found, add to Threadfin channel list and map the stream</li> <li>Map the streams in Threadfin</li>
<li>If not in iptv-org, add as a custom M3U source in Threadfin</li> <li>Reply to <a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a> to confirm</li>
<li>Reply to ${escapeHtml(email)} to let them know it's been added</li>
</ol> </ol>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" /> <hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
<p style="color: #666; font-size: 12px;">This is an automated message from Jefflix.</p> <p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p>
`, `,
}) })

View File

@ -1,49 +1,105 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { JefflixLogo } from "@/components/jefflix-logo" import { JefflixLogo } from "@/components/jefflix-logo"
import { Radio, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react" import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2 } from "lucide-react"
import Link from 'next/link' import Link from 'next/link'
const categories = ['Sports', 'News', 'Entertainment', 'Music', 'Movies', 'Kids', 'Other'] interface Channel {
id: string
name: string
country: string
categories: string[]
}
export default function RequestChannelPage() { export default function RequestChannelPage() {
const [formData, setFormData] = useState({ const [channels, setChannels] = useState<Channel[]>([])
channelName: '', const [loading, setLoading] = useState(true)
category: '', const [loadError, setLoadError] = useState('')
url: '', const [query, setQuery] = useState('')
notes: '', const [debouncedQuery, setDebouncedQuery] = useState('')
email: '', const [selected, setSelected] = useState<Channel[]>([])
}) const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null)
// 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setStatus('loading') if (selected.length === 0) return
setStatus('submitting')
setErrorMessage('') setErrorMessage('')
try { try {
const response = await fetch('/api/request-channel', { const res = await fetch('/api/request-channel', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({
}, email,
body: JSON.stringify(formData), channels: selected.map(({ id, name, country, categories }) => ({
id, name, country, categories,
})),
}),
}) })
const data = await res.json()
const data = await response.json() if (!res.ok) throw new Error(data.error || 'Failed to submit request')
if (!response.ok) {
throw new Error(data.error || 'Failed to submit request')
}
setStatus('success') setStatus('success')
} catch (error) { } catch (err) {
setStatus('error') setStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong') setErrorMessage(err instanceof Error ? err.message : 'Something went wrong')
} }
} }
@ -56,7 +112,8 @@ export default function RequestChannelPage() {
</div> </div>
<h1 className="text-2xl font-bold">Request Submitted!</h1> <h1 className="text-2xl font-bold">Request Submitted!</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
We'll look into adding the channel and let you know once it's available. 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>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Most channels are added within a few days. Most channels are added within a few days.
@ -85,103 +142,138 @@ export default function RequestChannelPage() {
{/* Main Content */} {/* Main Content */}
<div className="container mx-auto px-4 py-12 md:py-20"> <div className="container mx-auto px-4 py-12 md:py-20">
<div className="max-w-lg mx-auto"> <div className="max-w-2xl mx-auto">
<div className="text-center space-y-4 mb-8"> <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"> <div className="inline-block p-4 bg-cyan-100 dark:bg-cyan-900/30 rounded-full">
<Radio className="h-10 w-10 text-cyan-600 dark:text-cyan-400" /> <Search className="h-10 w-10 text-cyan-600 dark:text-cyan-400" />
</div> </div>
<h1 className="text-3xl font-bold font-marker">Request a Channel</h1> <h1 className="text-3xl font-bold font-marker">Request a Channel</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Can't find a channel you're looking for? Let us know and we'll try to add it Search the iptv-org catalog and select channels you&apos;d like us to add to the Live TV lineup.
to the Live TV lineup.
</p> </p>
<Badge className="bg-cyan-600 text-white">
3,468 Channels & Growing
</Badge>
</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>
) : (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2"> {/* Search input */}
<label htmlFor="channelName" className="text-sm font-medium"> <div className="relative">
Channel Name * <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
</label>
<input <input
type="text" type="text"
id="channelName" value={query}
required onChange={(e) => setQuery(e.target.value)}
value={formData.channelName} 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"
onChange={(e) => setFormData({ ...formData, channelName: e.target.value })} placeholder="Search by channel name, country, or category..."
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500" autoFocus
placeholder="e.g. BBC World News, ESPN2"
/> />
</div> </div>
<div className="space-y-2"> {/* Selected chips */}
<label htmlFor="category" className="text-sm font-medium"> {selected.length > 0 && (
Category * <div className="flex flex-wrap gap-2">
</label> {selected.map((ch) => (
<select <Badge
id="category" key={ch.id}
required 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"
value={formData.category} onClick={() => removeChannel(ch.id)}
onChange={(e) => setFormData({ ...formData, category: 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"
> >
<option value="">Select a category</option> {ch.name}
{categories.map((cat) => ( <X className="h-3.5 w-3.5 ml-1" />
<option key={cat} value={cat}>{cat}</option> </Badge>
))} ))}
</select>
</div> </div>
)}
<div className="space-y-2"> {/* Results */}
<label htmlFor="url" className="text-sm font-medium"> {debouncedQuery.length >= 2 && (
Link / URL (optional) <div className="border border-border rounded-lg divide-y divide-border max-h-[400px] overflow-y-auto">
</label> {displayResults.length === 0 ? (
<input <div className="p-6 text-center text-muted-foreground">
type="text" No channels found for &quot;{debouncedQuery}&quot;
id="url" </div>
value={formData.url} ) : (
onChange={(e) => setFormData({ ...formData, url: 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" {displayResults.map((ch) => {
placeholder="M3U URL or channel website" const isSelected = selected.some((s) => s.id === ch.id)
/> return (
<p className="text-xs text-muted-foreground"> <button
If you know where to find a stream URL or M3U link, paste it here 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> </p>
</div> )}
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium">
Notes (optional)
</label>
<textarea
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: 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 min-h-[100px]"
placeholder="Any extra details—specific shows, time zones, language, etc."
/>
</div>
{/* Email */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium"> <label htmlFor="email" className="text-sm font-medium">
Your Email * Your Email
</label> </label>
<input <input
type="email" type="email"
id="email" id="email"
required required
value={formData.email} value={email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} 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" 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" placeholder="your@email.com"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
We'll let you know when the channel is added We&apos;ll let you know when the channels are added
</p> </p>
</div> </div>
{/* Error */}
{status === '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"> <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" /> <AlertCircle className="h-5 w-5 flex-shrink-0" />
@ -189,24 +281,30 @@ export default function RequestChannelPage() {
</div> </div>
)} )}
{/* Submit */}
<Button <Button
type="submit" type="submit"
disabled={status === 'loading'} disabled={status === 'submitting' || selected.length === 0}
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white" className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
> >
{status === 'loading' ? 'Submitting...' : 'Request Channel'} {status === 'submitting'
? 'Submitting...'
: selected.length === 0
? 'Select channels to request'
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
</Button> </Button>
</form> </form>
)}
<div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4"> <div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4">
<h3 className="font-bold mb-2">Where do channels come from?</h3> <h3 className="font-bold mb-2">How does this work?</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Jefflix Live TV pulls from the <a href="https://iptv-org.github.io" className="text-cyan-600 hover:underline font-medium">iptv-org</a> community This catalog comes from <a href="https://iptv-org.github.io" className="text-cyan-600 hover:underline font-medium">iptv-org</a>,
listsa massive open-source collection of publicly available IPTV streams from around the world. a community-maintained collection of publicly available IPTV streams. Select the channels
We can also add custom M3U sources for channels not in the main list. you want and we&apos;ll map them into the Jefflix Live TV lineup.
</p> </p>
<p className="text-xs text-muted-foreground border-t border-border pt-3"> <p className="text-xs text-muted-foreground border-t border-border pt-3">
Not all channels can be addedavailability depends on public stream sources. We'll do our best! Not all channels have working streamswe&apos;ll do our best and let you know the result.
</p> </p>
</div> </div>
</div> </div>