feat: add Live Sports button and user access request system
- Add Live Sports button linking to Jellyfin Live TV section - Create /request-access page for users to request account access - Add API endpoint that sends email notifications via Resend - Add environment variables for RESEND_API_KEY and ADMIN_EMAIL - Add .gitignore to exclude build artifacts and node_modules Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1821194648
commit
279bd30c72
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Email notifications via Resend (required for access request feature)
|
||||||
|
# Get your API key from https://resend.com
|
||||||
|
RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Admin email to receive access request notifications
|
||||||
|
ADMIN_EMAIL=jeff@jeffemmett.com
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment files (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { name, email, reason } = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Name and email are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid email format' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification email via Resend
|
||||||
|
const resendApiKey = process.env.RESEND_API_KEY
|
||||||
|
if (!resendApiKey) {
|
||||||
|
console.error('RESEND_API_KEY not configured')
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email service not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
|
||||||
|
|
||||||
|
const emailResponse = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${resendApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: 'Jefflix <noreply@jefflix.lol>',
|
||||||
|
to: adminEmail,
|
||||||
|
subject: `[Jefflix] New Access Request from ${name}`,
|
||||||
|
html: `
|
||||||
|
<h2>New Jefflix Access Request</h2>
|
||||||
|
<p>Someone has requested access to Jefflix:</p>
|
||||||
|
<table style="border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Name:</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(name)}</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;">Reason:</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(reason || 'Not provided')}</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>To approve this request:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <a href="https://movies.jefflix.lol">Jellyfin Dashboard</a></li>
|
||||||
|
<li>Navigate to Dashboard → Users → Add User</li>
|
||||||
|
<li>Create an account for ${escapeHtml(name)} (${escapeHtml(email)})</li>
|
||||||
|
<li>Reply to this email to let them know their account is ready</li>
|
||||||
|
</ol>
|
||||||
|
<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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!emailResponse.ok) {
|
||||||
|
const errorData = await emailResponse.json()
|
||||||
|
console.error('Resend API error:', errorData)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to send notification' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request access error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}
|
||||||
|
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||||
|
}
|
||||||
37
app/page.tsx
37
app/page.tsx
|
|
@ -1,6 +1,6 @@
|
||||||
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 { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette } from "lucide-react"
|
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio } from "lucide-react"
|
||||||
import { JefflixLogo } from "@/components/jefflix-logo"
|
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||||
|
|
||||||
export default function JefflixPage() {
|
export default function JefflixPage() {
|
||||||
|
|
@ -78,6 +78,24 @@ export default function JefflixPage() {
|
||||||
Music
|
Music
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="relative group">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="text-lg px-8 py-6 font-bold bg-orange-600 hover:bg-orange-700 text-white"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<a href="https://movies.jefflix.lol/web/index.html#!/livetv.html">
|
||||||
|
<Radio className="mr-2 h-5 w-5" />
|
||||||
|
Live Sports
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<a href="/request-access" className="absolute -top-2 -right-2">
|
||||||
|
<Badge className="bg-yellow-500 hover:bg-yellow-400 text-black text-xs px-2 py-0.5 font-bold cursor-pointer transition-colors">
|
||||||
|
Request Access
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -230,6 +248,23 @@ export default function JefflixPage() {
|
||||||
Music
|
Music
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="relative group">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="text-lg px-8 py-6 font-bold bg-orange-600 hover:bg-orange-700 text-white"
|
||||||
|
>
|
||||||
|
<a href="https://movies.jefflix.lol/web/index.html#!/livetv.html">
|
||||||
|
<Radio className="mr-2 h-5 w-5" />
|
||||||
|
Live Sports
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<a href="/request-access" className="absolute -top-2 -right-2">
|
||||||
|
<Badge className="bg-yellow-500 hover:bg-yellow-400 text-black text-xs px-2 py-0.5 font-bold cursor-pointer transition-colors">
|
||||||
|
Request Access
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground pt-4">
|
<p className="text-sm text-muted-foreground pt-4">
|
||||||
Or learn how to set up your own Jellyfin server and join the movement
|
Or learn how to set up your own Jellyfin server and join the movement
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { JefflixLogo } from "@/components/jefflix-logo"
|
||||||
|
import { UserPlus, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react"
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function RequestAccessPage() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('loading')
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/request-access', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to submit request')
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('success')
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMessage(error instanceof Error ? error.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">
|
||||||
|
Your access request has been sent. You'll receive an email once your account is ready.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This usually happens within 24-48 hours.
|
||||||
|
</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-lg mx-auto">
|
||||||
|
<div className="text-center space-y-4 mb-8">
|
||||||
|
<div className="inline-block p-4 bg-orange-100 dark:bg-orange-900/30 rounded-full">
|
||||||
|
<UserPlus className="h-10 w-10 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold font-marker">Request Access</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Jefflix is a community media server. To protect the community and ensure quality access,
|
||||||
|
we require approval for new accounts.
|
||||||
|
</p>
|
||||||
|
<Badge className="bg-orange-600 text-white">
|
||||||
|
Required for Live Sports & Premium Content
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Your Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: 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-orange-500"
|
||||||
|
placeholder="Enter your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
Email Address *
|
||||||
|
</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-orange-500"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
We'll send your login details to this address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="reason" className="text-sm font-medium">
|
||||||
|
How do you know Jeff? (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="reason"
|
||||||
|
value={formData.reason}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reason: 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-orange-500 min-h-[100px]"
|
||||||
|
placeholder="Just helps me know who's joining the community..."
|
||||||
|
/>
|
||||||
|
</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 === 'loading'}
|
||||||
|
className="w-full py-6 text-lg font-bold bg-orange-600 hover:bg-orange-700 text-white"
|
||||||
|
>
|
||||||
|
{status === 'loading' ? 'Submitting...' : 'Request Access'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 p-6 bg-muted/50 rounded-lg">
|
||||||
|
<h3 className="font-bold mb-2">What happens next?</h3>
|
||||||
|
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||||
|
<li>Your request is sent to the admin for review</li>
|
||||||
|
<li>Once approved, you'll get an email with your login details</li>
|
||||||
|
<li>Log in at movies.jefflix.lol to access all content</li>
|
||||||
|
<li>Live Sports requires an active Sportsnet subscription</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ services:
|
||||||
jefflix:
|
jefflix:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.jefflix.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
- "traefik.http.routers.jefflix.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue