diff --git a/app/api/channels/route.ts b/app/api/channels/route.ts new file mode 100644 index 0000000..1324f55 --- /dev/null +++ b/app/api/channels/route.ts @@ -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 } + ) + } +} diff --git a/app/api/request-channel/route.ts b/app/api/request-channel/route.ts index 774f11f..32c38e1 100644 --- a/app/api/request-channel/route.ts +++ b/app/api/request-channel/route.ts @@ -1,28 +1,44 @@ import { NextRequest, NextResponse } from 'next/server' import nodemailer from 'nodemailer' +interface ChannelSelection { + id: string + name: string + country: string + categories: string[] +} + export async function POST(request: NextRequest) { try { const body = await request.json() - const { channelName, category, url, notes, email } = body + const { email, channels } = body as { email: string; channels: ChannelSelection[] } - // Validate required fields - if (!channelName || !email) { + // Validate email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!email || !emailRegex.test(email)) { return NextResponse.json( - { error: 'Channel name and email are required' }, + { error: 'A valid email is required' }, { status: 400 } ) } - // Email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(email)) { + // Validate channels + if (!Array.isArray(channels) || channels.length === 0 || channels.length > 20) { return NextResponse.json( - { error: 'Invalid email format' }, + { error: 'Select between 1 and 20 channels' }, { 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 smtpUser = process.env.SMTP_USER const smtpPass = process.env.SMTP_PASS @@ -44,48 +60,39 @@ export async function POST(request: NextRequest) { tls: { rejectUnauthorized: false }, }) + const channelListHtml = channels + .map( + (ch) => + `
${escapeHtml(ch.id)}` +
+ (ch.country ? ` (${escapeHtml(ch.country)})` : '') +
+ (ch.categories.length > 0 ? ` [${ch.categories.map(escapeHtml).join(', ')}]` : '') +
+ `Someone has requested a new Live TV channel:
-| Channel: | -${escapeHtml(channelName)} | -
| Category: | -${escapeHtml(category || 'Not specified')} | -
| URL/Link: | -${url ? `${escapeHtml(url)}` : 'Not provided'} | -
| Notes: | -${escapeHtml(notes || 'None')} | -
| Email: | -${escapeHtml(email)} | -
| Requested: | -${new Date().toLocaleString()} | -
To add this channel:
+${escapeHtml(email)} requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:
+To add these channels:
This is an automated message from Jefflix.
+Automated message from Jefflix · ${new Date().toLocaleString()}
`, }) diff --git a/app/request-channel/page.tsx b/app/request-channel/page.tsx index 37833e6..3b43ddc 100644 --- a/app/request-channel/page.tsx +++ b/app/request-channel/page.tsx @@ -1,49 +1,105 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" 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' -const categories = ['Sports', 'News', 'Entertainment', 'Music', 'Movies', 'Kids', 'Other'] +interface Channel { + id: string + name: string + country: string + categories: string[] +} export default function RequestChannelPage() { - const [formData, setFormData] = useState({ - channelName: '', - category: '', - url: '', - notes: '', - email: '', - }) - const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [channels, setChannels] = useState- 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.
Most channels are added within a few days. @@ -85,128 +142,169 @@ export default function RequestChannelPage() { {/* Main Content */}
- Can't find a channel you're looking for? Let us know and we'll try to add it - to the Live TV lineup. + Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.
-