Skip to content

Commit cfcb44a

Browse files
authored
Merge pull request #93 from Eskyee/claude/funny-greider
Advertising system, mix uploads, broadcast scheduler, guide page, FFmpeg fix
2 parents c77af94 + 8693ef5 commit cfcb44a

19 files changed

Lines changed: 2634 additions & 139 deletions

File tree

web/app/admin/ads/page.tsx

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { CheckCircle, Loader2, XCircle, Clock, Radio } from 'lucide-react'
5+
6+
interface Campaign {
7+
id: string
8+
advertiser_name: string
9+
advertiser_email: string
10+
advertiser_url: string | null
11+
contact_handle: string | null
12+
title: string
13+
description: string | null
14+
category: string
15+
slot_type: string
16+
scheduled_slots: number
17+
broadcasts_done: number
18+
starts_at: string | null
19+
ends_at: string | null
20+
amount_pence: number | null
21+
status: string
22+
admin_notes: string | null
23+
playback_id: string | null
24+
mux_upload_id: string | null
25+
created_at: string
26+
}
27+
28+
const STATUS_COLORS: Record<string, string> = {
29+
pending_payment: 'text-zinc-500',
30+
paid: 'text-amber-400',
31+
approved: 'text-blue-400',
32+
live: 'text-green-400',
33+
complete: 'text-zinc-500',
34+
rejected: 'text-red-400',
35+
refunded: 'text-zinc-500',
36+
}
37+
38+
function fmt(pence: number | null) {
39+
if (!pence) return '—'
40+
return ${(pence / 100).toFixed(2)}`
41+
}
42+
43+
function fmtDate(iso: string | null) {
44+
if (!iso) return '—'
45+
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
46+
}
47+
48+
function CampaignRow({ c, onAction }: { c: Campaign; onAction: () => void }) {
49+
const [loading, setLoading] = useState(false)
50+
const [scheduleDate, setScheduleDate] = useState('')
51+
const [notes, setNotes] = useState(c.admin_notes || '')
52+
const [expanded, setExpanded] = useState(false)
53+
54+
async function act(action: string, extra?: Record<string, unknown>) {
55+
setLoading(true)
56+
await fetch(`/api/ads/campaigns/${c.id}`, {
57+
method: 'PATCH',
58+
headers: { 'Content-Type': 'application/json' },
59+
body: JSON.stringify({ action, notes, ...extra }),
60+
})
61+
onAction()
62+
setLoading(false)
63+
}
64+
65+
return (
66+
<div className="rounded-[20px] border border-zinc-800 bg-zinc-950/80">
67+
<button
68+
className="w-full px-5 py-4 text-left"
69+
onClick={() => setExpanded((v) => !v)}
70+
>
71+
<div className="flex items-start justify-between gap-3">
72+
<div className="min-w-0">
73+
<p className="text-sm font-bold text-white truncate">{c.title}</p>
74+
<p className="text-xs text-zinc-500 mt-0.5">{c.advertiser_name} · {c.advertiser_email}</p>
75+
</div>
76+
<div className="flex shrink-0 items-center gap-3">
77+
<span className={`text-[10px] font-bold uppercase tracking-[0.14em] ${STATUS_COLORS[c.status] || 'text-zinc-400'}`}>
78+
{c.status.replace('_', ' ')}
79+
</span>
80+
<span className="text-xs text-zinc-600">{fmt(c.amount_pence)}</span>
81+
</div>
82+
</div>
83+
<div className="mt-2 flex flex-wrap gap-3 text-[10px] text-zinc-600">
84+
<span className="uppercase tracking-[0.14em]">{c.slot_type}</span>
85+
<span>{c.scheduled_slots} slots · {c.broadcasts_done} done</span>
86+
<span className="capitalize">{c.category}</span>
87+
<span>{fmtDate(c.created_at)}</span>
88+
</div>
89+
</button>
90+
91+
{expanded && (
92+
<div className="border-t border-zinc-800 px-5 pb-5 pt-4 space-y-4">
93+
{c.description && (
94+
<p className="text-xs text-zinc-400">{c.description}</p>
95+
)}
96+
97+
<div className="grid gap-3 sm:grid-cols-2 text-xs text-zinc-500">
98+
{c.advertiser_url && <span>URL: <a href={c.advertiser_url} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline">{c.advertiser_url}</a></span>}
99+
{c.contact_handle && <span>Handle: {c.contact_handle}</span>}
100+
{c.playback_id && <span>Playback: <a href={`https://stream.mux.com/${c.playback_id}.m3u8`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline">Play</a></span>}
101+
{c.mux_upload_id && <span className="font-mono">Upload: {c.mux_upload_id}</span>}
102+
<span>Starts: {fmtDate(c.starts_at)}</span>
103+
<span>Ends: {fmtDate(c.ends_at)}</span>
104+
</div>
105+
106+
<div>
107+
<label className="block text-[10px] uppercase tracking-[0.16em] text-zinc-500 mb-1">Admin Notes</label>
108+
<textarea
109+
value={notes}
110+
onChange={(e) => setNotes(e.target.value)}
111+
rows={2}
112+
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2 text-xs text-white resize-none focus:border-zinc-600 focus:outline-none"
113+
/>
114+
</div>
115+
116+
{c.status === 'paid' && (
117+
<div className="flex flex-wrap gap-3">
118+
<div>
119+
<label className="block text-[10px] uppercase tracking-[0.16em] text-zinc-500 mb-1">Broadcast Start Date</label>
120+
<input
121+
type="datetime-local"
122+
value={scheduleDate}
123+
onChange={(e) => setScheduleDate(e.target.value)}
124+
className="rounded-xl border border-zinc-800 bg-zinc-900 px-3 py-2 text-xs text-white focus:border-zinc-600 focus:outline-none"
125+
/>
126+
</div>
127+
<div className="flex items-end gap-2">
128+
<button
129+
onClick={() => act('approve', scheduleDate ? { startsAt: scheduleDate } : {})}
130+
disabled={loading}
131+
className="flex items-center gap-1.5 rounded-xl border border-green-500/30 bg-green-500/10 px-3 py-2 text-xs text-green-300 transition-colors hover:border-green-400/60 disabled:opacity-50"
132+
>
133+
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle className="h-3 w-3" />}
134+
Approve
135+
</button>
136+
<button
137+
onClick={() => act('reject')}
138+
disabled={loading}
139+
className="flex items-center gap-1.5 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300 transition-colors hover:border-red-400/60 disabled:opacity-50"
140+
>
141+
<XCircle className="h-3 w-3" />
142+
Reject
143+
</button>
144+
</div>
145+
</div>
146+
)}
147+
148+
{c.status === 'live' && (
149+
<button
150+
onClick={() => act('complete')}
151+
disabled={loading}
152+
className="flex items-center gap-1.5 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-300 transition-colors hover:border-zinc-600"
153+
>
154+
<CheckCircle className="h-3 w-3" />
155+
Mark Complete
156+
</button>
157+
)}
158+
</div>
159+
)}
160+
</div>
161+
)
162+
}
163+
164+
export default function AdminAdsPage() {
165+
const [campaigns, setCampaigns] = useState<Campaign[]>([])
166+
const [loading, setLoading] = useState(true)
167+
const [error, setError] = useState('')
168+
const [filter, setFilter] = useState('all')
169+
170+
async function load() {
171+
setLoading(true)
172+
const res = await fetch('/api/ads/campaigns')
173+
if (res.status === 403) { setError('Admin access required'); setLoading(false); return }
174+
if (!res.ok) { setError('Failed to load campaigns'); setLoading(false); return }
175+
const data = await res.json()
176+
setCampaigns(data.campaigns || [])
177+
setLoading(false)
178+
}
179+
180+
useEffect(() => { load() }, [])
181+
182+
const statuses = ['all', 'pending_payment', 'paid', 'approved', 'live', 'complete', 'rejected']
183+
const visible = filter === 'all' ? campaigns : campaigns.filter((c) => c.status === filter)
184+
185+
const stats = {
186+
paid: campaigns.filter((c) => c.status === 'paid').length,
187+
approved: campaigns.filter((c) => c.status === 'approved').length,
188+
live: campaigns.filter((c) => c.status === 'live').length,
189+
revenue: campaigns.filter((c) => !['pending_payment', 'rejected'].includes(c.status))
190+
.reduce((sum, c) => sum + (c.amount_pence ?? 0), 0),
191+
}
192+
193+
return (
194+
<main className="min-h-screen bg-black px-4 py-12 sm:px-8">
195+
<div className="mx-auto max-w-4xl space-y-8">
196+
197+
<div className="flex items-center justify-between gap-4">
198+
<div>
199+
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-600">Admin</p>
200+
<h1 className="text-2xl font-bold tracking-tight text-white mt-1">Ad Campaigns</h1>
201+
</div>
202+
<div className="flex items-center gap-2">
203+
<Radio className="h-4 w-4 text-zinc-600" />
204+
<span className="text-xs text-zinc-500">{campaigns.length} total</span>
205+
</div>
206+
</div>
207+
208+
{/* Stats */}
209+
<div className="grid gap-4 sm:grid-cols-4">
210+
{[
211+
{ label: 'Awaiting Review', value: stats.paid, color: 'text-amber-400' },
212+
{ label: 'Approved', value: stats.approved, color: 'text-blue-400' },
213+
{ label: 'Live Now', value: stats.live, color: 'text-green-400' },
214+
{ label: 'Revenue', value: fmt(stats.revenue), color: 'text-white' },
215+
].map(({ label, value, color }) => (
216+
<div key={label} className="rounded-[20px] border border-zinc-800 bg-zinc-950/80 p-4">
217+
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-600">{label}</p>
218+
<p className={`mt-2 text-xl font-bold ${color}`}>{value}</p>
219+
</div>
220+
))}
221+
</div>
222+
223+
{/* Filter */}
224+
<div className="flex flex-wrap gap-2">
225+
{statuses.map((s) => (
226+
<button
227+
key={s}
228+
onClick={() => setFilter(s)}
229+
className={`rounded-full border px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.14em] transition-colors ${
230+
filter === s
231+
? 'border-white bg-white text-black'
232+
: 'border-zinc-800 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300'
233+
}`}
234+
>
235+
{s === 'all' ? 'All' : s.replace('_', ' ')}
236+
</button>
237+
))}
238+
</div>
239+
240+
{loading ? (
241+
<div className="flex items-center gap-2 text-sm text-zinc-500">
242+
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
243+
</div>
244+
) : error ? (
245+
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div>
246+
) : visible.length === 0 ? (
247+
<div className="rounded-[24px] border border-dashed border-zinc-800 px-6 py-12 text-center">
248+
<Clock className="mx-auto h-7 w-7 text-zinc-700 mb-3" />
249+
<p className="text-sm text-zinc-500">No campaigns in this state.</p>
250+
</div>
251+
) : (
252+
<div className="space-y-3">
253+
{visible.map((c) => (
254+
<CampaignRow key={c.id} c={c} onAction={load} />
255+
))}
256+
</div>
257+
)}
258+
259+
</div>
260+
</main>
261+
)
262+
}

0 commit comments

Comments
 (0)