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:
parent
709a87731c
commit
cee87aef10
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'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,128 +142,169 @@ 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'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>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col items-center gap-4 py-16">
|
||||||
<label htmlFor="channelName" className="text-sm font-medium">
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-600" />
|
||||||
Channel Name *
|
<p className="text-muted-foreground">Loading channel catalog...</p>
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="channelName"
|
|
||||||
required
|
|
||||||
value={formData.channelName}
|
|
||||||
onChange={(e) => setFormData({ ...formData, channelName: 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="e.g. BBC World News, ESPN2"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col items-center gap-4 py-16">
|
||||||
<label htmlFor="category" className="text-sm font-medium">
|
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||||
Category *
|
<p className="text-muted-foreground">{loadError}</p>
|
||||||
</label>
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
<select
|
Try Again
|
||||||
id="category"
|
</Button>
|
||||||
required
|
|
||||||
value={formData.category}
|
|
||||||
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>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<label htmlFor="url" className="text-sm font-medium">
|
{/* Search input */}
|
||||||
Link / URL (optional)
|
<div className="relative">
|
||||||
</label>
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="url"
|
value={query}
|
||||||
value={formData.url}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onChange={(e) => setFormData({ ...formData, url: 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"
|
||||||
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="Search by channel name, country, or category..."
|
||||||
placeholder="M3U URL or channel website"
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
If you know where to find a stream URL or M3U link, paste it here
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="email" className="text-sm font-medium">
|
|
||||||
Your Email *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
required
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({ ...formData, email: 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'll let you know when the channel is added
|
|
||||||
</p>
|
|
||||||
</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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
{/* Selected chips */}
|
||||||
type="submit"
|
{selected.length > 0 && (
|
||||||
disabled={status === 'loading'}
|
<div className="flex flex-wrap gap-2">
|
||||||
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white"
|
{selected.map((ch) => (
|
||||||
>
|
<Badge
|
||||||
{status === 'loading' ? 'Submitting...' : 'Request Channel'}
|
key={ch.id}
|
||||||
</Button>
|
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"
|
||||||
</form>
|
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 "{debouncedQuery}"
|
||||||
|
</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'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">
|
<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>,
|
||||||
lists—a 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'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 added—availability depends on public stream sources. We'll do our best!
|
Not all channels have working streams—we'll do our best and let you know the result.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue