From c047966b433724d0892e771dbd9d7aeee58a60b9 Mon Sep 17 00:00:00 2001 From: robin Date: Mon, 19 Jan 2026 11:23:46 +0100 Subject: [PATCH] feat: Add Admin UI for banner management - Create /admin/banners page with full CRUD operations - Add BannerManager.tsx client component for interactive management - Add banner-actions.ts server actions (create, update, toggle, delete) - Add 'Manage Banners' link to admin dashboard - Features: image preview, activate/deactivate toggle, edit inline --- src/app/admin/banners/BannerManager.tsx | 396 ++++++++++++++++++++++++ src/app/admin/banners/page.tsx | 56 ++++ src/app/admin/page.tsx | 6 + src/services/banner-actions.ts | 123 ++++++++ 4 files changed, 581 insertions(+) create mode 100644 src/app/admin/banners/BannerManager.tsx create mode 100644 src/app/admin/banners/page.tsx create mode 100644 src/services/banner-actions.ts diff --git a/src/app/admin/banners/BannerManager.tsx b/src/app/admin/banners/BannerManager.tsx new file mode 100644 index 0000000..ad31cbd --- /dev/null +++ b/src/app/admin/banners/BannerManager.tsx @@ -0,0 +1,396 @@ +'use client'; + +import { useState } from 'react'; +import { Image, ExternalLink, ToggleLeft, ToggleRight, Trash2, Plus, Edit2, Save, X, Loader2, Check } from 'lucide-react'; +import { Banner, createBanner, updateBanner, toggleBannerActive, deleteBanner } from '@/services/banner-actions'; + +interface BannerManagerProps { + initialBanners: Banner[]; +} + +export default function BannerManager({ initialBanners }: BannerManagerProps) { + const [banners, setBanners] = useState(initialBanners); + const [showCreateForm, setShowCreateForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [isLoading, setIsLoading] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Form states + const [formData, setFormData] = useState({ + title: '', + image_url: '', + link_target: '', + cta_text: 'Open', + }); + + const resetForm = () => { + setFormData({ title: '', image_url: '', link_target: '', cta_text: 'Open' }); + setShowCreateForm(false); + setEditingId(null); + }; + + const showMessage = (type: 'success' | 'error', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading('create'); + + const form = new FormData(); + form.append('title', formData.title); + form.append('image_url', formData.image_url); + form.append('link_target', formData.link_target); + form.append('cta_text', formData.cta_text); + + const result = await createBanner(form); + + if (result.success) { + showMessage('success', 'Banner created successfully'); + // Refresh - in real app would revalidate + window.location.reload(); + } else { + showMessage('error', result.error || 'Failed to create banner'); + } + + setIsLoading(null); + resetForm(); + }; + + const handleUpdate = async (id: string) => { + setIsLoading(id); + + const form = new FormData(); + form.append('title', formData.title); + form.append('image_url', formData.image_url); + form.append('link_target', formData.link_target); + form.append('cta_text', formData.cta_text); + + const result = await updateBanner(id, form); + + if (result.success) { + showMessage('success', 'Banner updated successfully'); + setBanners(banners.map(b => + b.id === id + ? { ...b, ...formData } + : b + )); + } else { + showMessage('error', result.error || 'Failed to update banner'); + } + + setIsLoading(null); + resetForm(); + }; + + const handleToggleActive = async (id: string, currentStatus: boolean) => { + setIsLoading(id); + + const result = await toggleBannerActive(id, !currentStatus); + + if (result.success) { + showMessage('success', !currentStatus ? 'Banner activated' : 'Banner deactivated'); + // If activating, deactivate all others + setBanners(banners.map(b => ({ + ...b, + is_active: b.id === id ? !currentStatus : (!currentStatus ? false : b.is_active) + }))); + } else { + showMessage('error', result.error || 'Failed to toggle banner'); + } + + setIsLoading(null); + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this banner?')) return; + + setIsLoading(id); + + const result = await deleteBanner(id); + + if (result.success) { + showMessage('success', 'Banner deleted'); + setBanners(banners.filter(b => b.id !== id)); + } else { + showMessage('error', result.error || 'Failed to delete banner'); + } + + setIsLoading(null); + }; + + const startEditing = (banner: Banner) => { + setEditingId(banner.id); + setFormData({ + title: banner.title, + image_url: banner.image_url, + link_target: banner.link_target || '', + cta_text: banner.cta_text || 'Open', + }); + }; + + return ( +
+ {/* Message Toast */} + {message && ( +
+ {message.text} +
+ )} + + {/* Create Button / Form */} + {!showCreateForm ? ( + + ) : ( +
+

Create New Banner

+ +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="Banner title" + className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" + required + /> +
+ +
+ + setFormData({ ...formData, image_url: e.target.value })} + placeholder="https://example.com/banner.jpg" + className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" + required + /> +
+ +
+
+ + setFormData({ ...formData, link_target: e.target.value })} + placeholder="/sessions" + className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" + /> +
+
+ + setFormData({ ...formData, cta_text: e.target.value })} + placeholder="Open" + className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" + /> +
+
+ + {/* Preview */} + {formData.image_url && ( +
+ +
+ Preview { (e.target as HTMLImageElement).src = '/placeholder.png'; }} + /> +
+
+ )} + +
+ + +
+
+ )} + + {/* Banners List */} +
+ {banners.length === 0 ? ( +
+
+ +
+

No Banners Yet

+

Create your first banner to display on the home page.

+
+ ) : ( + banners.map(banner => ( +
+ {editingId === banner.id ? ( + /* Edit Form */ +
+ setFormData({ ...formData, title: e.target.value })} + className="w-full px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white" + /> + setFormData({ ...formData, image_url: e.target.value })} + className="w-full px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white" + /> +
+ setFormData({ ...formData, link_target: e.target.value })} + placeholder="Link target" + className="px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white" + /> + setFormData({ ...formData, cta_text: e.target.value })} + placeholder="CTA text" + className="px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white" + /> +
+
+ + +
+
+ ) : ( + /* Display Mode */ +
+ {/* Thumbnail */} +
+ {banner.title} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ + {/* Info */} +
+
+

{banner.title}

+ {banner.is_active && ( + + Active + + )} +
+ {banner.link_target && ( +

+ + {banner.link_target} +

+ )} +

+ CTA: {banner.cta_text} +

+
+ + {/* Actions */} +
+ + + +
+
+ )} +
+ )) + )} +
+
+ ); +} diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx new file mode 100644 index 0000000..e1176e8 --- /dev/null +++ b/src/app/admin/banners/page.tsx @@ -0,0 +1,56 @@ +export const dynamic = 'force-dynamic'; + +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import { checkIsAdmin } from '@/services/track-api-usage'; +import { getBanners } from '@/services/banner-actions'; +import Link from 'next/link'; +import { ArrowLeft, Image, ExternalLink, ToggleLeft, ToggleRight, Trash2, Plus, Edit2 } from 'lucide-react'; +import BannerManager from './BannerManager'; + +export default async function AdminBannersPage() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + redirect('/'); + } + + const isAdmin = await checkIsAdmin(user.id); + if (!isAdmin) { + redirect('/'); + } + + const { banners, error } = await getBanners(); + + return ( +
+
+ {/* Header */} +
+ + + +
+

Banner Management

+

+ Manage hero banners for the home page +

+
+
+ + {error && ( +
+ Error loading banners: {error} +
+ )} + + {/* Client Component for Interactive Banner Management */} + +
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 887d56e..10eac10 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -117,6 +117,12 @@ export default async function AdminPage() { > Manage Users + + Manage Banners + { + const supabase = await createClient(); + + const { data, error } = await supabase + .from('app_banners') + .select('*') + .order('created_at', { ascending: false }); + + if (error) { + console.error('[getBanners] Error:', error); + return { banners: [], error: error.message }; + } + + return { banners: data || [], error: null }; +} + +export async function createBanner(formData: FormData): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient(); + + const title = formData.get('title') as string; + const image_url = formData.get('image_url') as string; + const link_target = formData.get('link_target') as string || null; + const cta_text = formData.get('cta_text') as string || 'Open'; + + if (!title || !image_url) { + return { success: false, error: 'Title and image URL are required' }; + } + + const { error } = await supabase + .from('app_banners') + .insert({ title, image_url, link_target, cta_text, is_active: false }); + + if (error) { + console.error('[createBanner] Error:', error); + return { success: false, error: error.message }; + } + + revalidatePath('/admin/banners'); + return { success: true }; +} + +export async function updateBanner(id: string, formData: FormData): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient(); + + const title = formData.get('title') as string; + const image_url = formData.get('image_url') as string; + const link_target = formData.get('link_target') as string || null; + const cta_text = formData.get('cta_text') as string || 'Open'; + + const { error } = await supabase + .from('app_banners') + .update({ title, image_url, link_target, cta_text }) + .eq('id', id); + + if (error) { + console.error('[updateBanner] Error:', error); + return { success: false, error: error.message }; + } + + revalidatePath('/admin/banners'); + revalidatePath('/'); + return { success: true }; +} + +export async function toggleBannerActive(id: string, isActive: boolean): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient(); + + // If activating, first deactivate all other banners (only one active at a time) + if (isActive) { + await supabase + .from('app_banners') + .update({ is_active: false }) + .neq('id', id); + } + + const { error } = await supabase + .from('app_banners') + .update({ is_active: isActive }) + .eq('id', id); + + if (error) { + console.error('[toggleBannerActive] Error:', error); + return { success: false, error: error.message }; + } + + revalidatePath('/admin/banners'); + revalidatePath('/'); + return { success: true }; +} + +export async function deleteBanner(id: string): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient(); + + const { error } = await supabase + .from('app_banners') + .delete() + .eq('id', id); + + if (error) { + console.error('[deleteBanner] Error:', error); + return { success: false, error: error.message }; + } + + revalidatePath('/admin/banners'); + revalidatePath('/'); + return { success: true }; +}