| @@ -0,0 +1,388 @@ | |||
| 'use client'; | |||
| import { useState, useEffect } from 'react'; | |||
| import { Card, Portal } from '@/types'; | |||
| export default function AdminDashboard() { | |||
| const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards'); | |||
| // Card State | |||
| const [cards, setCards] = useState<Card[]>([]); | |||
| const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null); | |||
| // Portal State | |||
| const [portal, setPortal] = useState<Partial<Portal>>({}); | |||
| const [savingPortal, setSavingPortal] = useState(false); | |||
| const [uploading, setUploading] = useState<{ [key: string]: boolean }>({}); | |||
| // NEW UI STATES: Toast and Confirm Dialog | |||
| const [toast, setToast] = useState<string | null>(null); | |||
| const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); | |||
| // Helper to show auto-dismissing toast | |||
| const showToast = (message: string) => { | |||
| setToast(message); | |||
| setTimeout(() => setToast(null), 3000); | |||
| }; | |||
| useEffect(() => { | |||
| fetch('/api/cards').then(res => res.json()).then(setCards); | |||
| fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data)); | |||
| }, []); | |||
| const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => { | |||
| if (!e.target.files?.[0]) return; | |||
| setUploading(prev => ({ ...prev, [field]: true })); | |||
| const formData = new FormData(); | |||
| formData.append('file', e.target.files[0]); | |||
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); | |||
| const data = await res.json(); | |||
| if (data.url) { | |||
| if (isPortal) { | |||
| setPortal(prev => ({ ...prev, [field]: data.url })); | |||
| } else { | |||
| setIsEditing(prev => ({ ...prev, [field]: data.url })); | |||
| } | |||
| } | |||
| setUploading(prev => ({ ...prev, [field]: false })); | |||
| }; | |||
| const handleSaveCard = async () => { | |||
| if (!isEditing) return; | |||
| const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); | |||
| const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card; | |||
| await fetch('/api/cards', { | |||
| method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard) | |||
| }); | |||
| setCards(prev => { | |||
| const exists = prev.find(c => c.id === newCard.id); | |||
| return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard]; | |||
| }); | |||
| setIsEditing(null); | |||
| }; | |||
| const handleDeleteCard = (id: string) => { | |||
| // Replace window.confirm with our custom dialog | |||
| setConfirmDialog({ | |||
| message: 'Are you sure you want to delete this card? This action cannot be undone.', | |||
| onConfirm: async () => { | |||
| await fetch(`/api/cards?id=${id}`, { method: 'DELETE' }); | |||
| setCards(prev => prev.filter(c => c.id !== id)); | |||
| setConfirmDialog(null); | |||
| showToast('Card successfully deleted.'); | |||
| } | |||
| }); | |||
| }; | |||
| const moveCard = async (index: number, direction: 'up' | 'down') => { | |||
| const newCards = [...cards]; | |||
| if (direction === 'up' && index > 0) { | |||
| [newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]]; | |||
| } else if (direction === 'down' && index < newCards.length - 1) { | |||
| [newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]]; | |||
| } else { | |||
| return; // Do nothing if trying to move out of bounds | |||
| } | |||
| // Recalculate displayOrder for the whole array | |||
| const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i })); | |||
| // Optimistically update the UI | |||
| setCards(updatedCards); | |||
| // Persist the new order to the backend | |||
| await fetch('/api/cards', { | |||
| method: 'PUT', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(updatedCards) | |||
| }); | |||
| }; | |||
| const handleSavePortal = async () => { | |||
| setSavingPortal(true); | |||
| await fetch('/api/portals', { | |||
| method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal) | |||
| }); | |||
| setSavingPortal(false); | |||
| showToast('Portal settings saved successfully!'); // Replaced window.alert | |||
| }; | |||
| // Shared Input Classes for high contrast | |||
| const inputClasses = "w-full border border-gray-300 p-2.5 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400"; | |||
| return ( | |||
| <div className="min-h-screen bg-gray-50 font-sans pb-12"> | |||
| {/* Top Header */} | |||
| <div className="bg-blue-900 text-white shadow-md py-6 px-4"> | |||
| <div className="max-w-5xl mx-auto flex justify-between items-center"> | |||
| <div> | |||
| <h1 className="text-2xl font-bold">Captive Portal CMS</h1> | |||
| <p className="text-sm text-blue-200">Local Administration</p> | |||
| </div> | |||
| <a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors"> | |||
| View Live Portal ↗ | |||
| </a> | |||
| </div> | |||
| </div> | |||
| <div className="max-w-5xl mx-auto mt-8 px-4"> | |||
| {/* Tab Navigation */} | |||
| <div className="flex space-x-2 mb-6"> | |||
| <button onClick={() => setActiveTab('cards')} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'cards' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}> | |||
| Manage Cards | |||
| </button> | |||
| <button onClick={() => setActiveTab('settings')} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'settings' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}> | |||
| Portal Settings | |||
| </button> | |||
| </div> | |||
| <div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]"> | |||
| {/* TAB: CARDS */} | |||
| {activeTab === 'cards' && ( | |||
| <div className="p-6 md:p-8"> | |||
| <div className="flex justify-between items-center mb-8 border-b pb-4"> | |||
| <h2 className="text-xl font-bold text-gray-800">Card Grid</h2> | |||
| <button onClick={() => setIsEditing({ title: '', cardType: 'INFO_PAGE', displayOrder: cards.length })} className="bg-blue-600 text-white px-5 py-2.5 rounded-lg shadow-sm hover:bg-blue-700 font-medium"> | |||
| + Add New Card | |||
| </button> | |||
| </div> | |||
| <div className="space-y-3 mb-8"> | |||
| {cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>} | |||
| {cards.map((card, idx) => ( | |||
| // CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing | |||
| <div key={card.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors gap-4"> | |||
| <div className="flex items-center gap-4"> | |||
| {card.imageUrl ? <img src={card.imageUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" /> : <div className="w-16 h-16 bg-gray-200 rounded-md shadow-sm flex items-center justify-center text-gray-400 text-xs shrink-0">No Image</div>} | |||
| <div> | |||
| <span className="font-semibold text-gray-800 block">{card.title}</span> | |||
| <span className="text-xs text-gray-500 uppercase tracking-wider">{card.cardType}</span> | |||
| </div> | |||
| </div> | |||
| {/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */} | |||
| <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end"> | |||
| <button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button> | |||
| <button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button> | |||
| <div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div> | |||
| <button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button> | |||
| <button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button> | |||
| </div> | |||
| </div> | |||
| ))} | |||
| </div> | |||
| </div> | |||
| )} | |||
| {/* TAB: SETTINGS */} | |||
| {activeTab === 'settings' && ( | |||
| <div className="p-6 md:p-8"> | |||
| <h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2> | |||
| <div className="grid grid-cols-1 md:grid-cols-2 gap-10"> | |||
| <div className="space-y-6"> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label> | |||
| <input type="text" value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} /> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label> | |||
| <textarea value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} /> | |||
| </div> | |||
| <div className="flex gap-8"> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label> | |||
| <div className="flex items-center gap-4"> | |||
| <input type="color" value={portal.themeColor || '#1e3a8a'} onChange={e => setPortal({...portal, themeColor: e.target.value})} className="h-12 w-12 rounded cursor-pointer border-0 p-0" /> | |||
| <span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span> | |||
| </div> | |||
| </div> | |||
| {/* NEW: Max Columns Setting updated for 3 */} | |||
| <div className="flex-1"> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label> | |||
| <input | |||
| type="range" | |||
| min="3" | |||
| max="8" | |||
| value={portal.maxGridColumns || 5} | |||
| onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})} | |||
| className="w-full mt-3 accent-blue-600" | |||
| /> | |||
| <div className="flex justify-between text-xs text-gray-400 mt-1"> | |||
| <span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className="space-y-6"> | |||
| {/* Logo Upload with Remove Button */} | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label> | |||
| <input type="file" accept="image/*" onChange={e => handleUpload(e, 'logoUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" /> | |||
| {uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>} | |||
| {portal.logoUrl && ( | |||
| <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border"> | |||
| <img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" /> | |||
| <button onClick={() => setPortal({...portal, logoUrl: ''})} className="absolute -top-2 -right-2 bg-red-500 text-white w-6 h-6 rounded-full text-xs font-bold hover:bg-red-600 shadow">✕</button> | |||
| </div> | |||
| )} | |||
| </div> | |||
| {/* Hero Upload with Remove Button */} | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Hero Background Image</label> | |||
| <input type="file" accept="image/*" onChange={e => handleUpload(e, 'heroImageUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" /> | |||
| {uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>} | |||
| {portal.heroImageUrl && ( | |||
| <div className="mt-2 relative rounded shadow border inline-block w-full"> | |||
| <img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" /> | |||
| <button onClick={() => setPortal({...portal, heroImageUrl: ''})} className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold hover:bg-red-600 shadow-lg">✕</button> | |||
| </div> | |||
| )} | |||
| </div> | |||
| <div className="bg-gray-50 p-4 rounded-lg border border-gray-200"> | |||
| <label className="flex items-center gap-3 cursor-pointer"> | |||
| <input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" /> | |||
| <div> | |||
| <span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span> | |||
| <span className="block text-xs text-gray-600">Creates a smooth gradient from the top of the image into the solid theme color at the bottom.</span> | |||
| </div> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className="mt-10 pt-6 border-t border-gray-200 flex justify-end"> | |||
| <button onClick={handleSavePortal} disabled={savingPortal} className="bg-blue-600 text-white px-10 py-3 rounded-lg hover:bg-blue-700 font-bold shadow disabled:opacity-50 transition-colors"> | |||
| {savingPortal ? 'Saving...' : 'Save Portal Settings'} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| {/* MODAL FOR EDITING/CREATING CARDS */} | |||
| {isEditing && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity" | |||
| onClick={() => setIsEditing(null)} // Click outside to close | |||
| > | |||
| <div | |||
| className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl p-6 md:p-8 relative animate-in fade-in zoom-in-95 duration-200" | |||
| onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing | |||
| > | |||
| <button | |||
| onClick={() => setIsEditing(null)} | |||
| className="absolute top-6 right-6 text-gray-400 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center transition-colors" | |||
| > | |||
| ✕ | |||
| </button> | |||
| <h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4"> | |||
| {isEditing.id ? 'Edit Card' : 'Create New Card'} | |||
| </h3> | |||
| <div className="grid grid-cols-1 md:grid-cols-2 gap-8"> | |||
| <div className="space-y-5"> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label> | |||
| <input type="text" value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" /> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label> | |||
| <select value={isEditing.cardType || 'INFO_PAGE'} onChange={e => setIsEditing({...isEditing, cardType: e.target.value as any})} className={inputClasses}> | |||
| <option value="INFO_PAGE">Info Page</option> | |||
| <option value="IMAGE_GALLERY">Image Gallery</option> | |||
| <option value="EXTERNAL_LINK">External Link</option> | |||
| </select> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label> | |||
| <textarea value={isEditing.shortDescription || ''} onChange={e => setIsEditing({...isEditing, shortDescription: e.target.value})} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." /> | |||
| </div> | |||
| </div> | |||
| <div className="space-y-5"> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Cover Image</label> | |||
| <div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:bg-gray-50 transition-colors"> | |||
| <input type="file" accept="image/*" onChange={e => handleUpload(e, 'imageUrl')} className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer" /> | |||
| {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading image...</p>} | |||
| </div> | |||
| {isEditing.imageUrl && ( | |||
| <div className="mt-4 relative rounded-lg overflow-hidden border border-gray-200 group"> | |||
| <img src={isEditing.imageUrl} className="w-full h-40 object-cover" alt="Preview" /> | |||
| <button | |||
| onClick={() => setIsEditing({...isEditing, imageUrl: ''})} | |||
| className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600" | |||
| title="Remove Image" | |||
| > | |||
| ✕ | |||
| </button> | |||
| </div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end"> | |||
| <button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"> | |||
| Cancel | |||
| </button> | |||
| <button onClick={handleSaveCard} className="bg-green-600 text-white px-8 py-2.5 rounded-lg hover:bg-green-700 font-medium shadow-sm transition-colors"> | |||
| Save Card | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| {/* CUSTOM CONFIRM DIALOG */} | |||
| {confirmDialog && ( | |||
| <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200"> | |||
| <div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95"> | |||
| <h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3> | |||
| <p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p> | |||
| <div className="flex justify-end gap-3"> | |||
| <button | |||
| onClick={() => setConfirmDialog(null)} | |||
| className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors" | |||
| > | |||
| Cancel | |||
| </button> | |||
| <button | |||
| onClick={confirmDialog.onConfirm} | |||
| className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm" | |||
| > | |||
| Delete | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| {/* CUSTOM TOAST NOTIFICATION */} | |||
| {toast && ( | |||
| <div className="fixed bottom-6 right-6 z-[70] bg-gray-900 text-white px-6 py-4 rounded-lg shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 fade-in duration-300"> | |||
| <div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-gray-900 font-bold text-sm"> | |||
| ✓ | |||
| </div> | |||
| <span className="font-medium">{toast}</span> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,63 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { revalidatePath } from 'next/cache'; | |||
| import { getCards, saveCards } from '@/lib/db'; | |||
| import { Card } from '@/types'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export async function GET(request: Request) { | |||
| const { searchParams } = new URL(request.url); | |||
| const portalId = searchParams.get('portalId'); | |||
| const cards = await getCards(portalId || undefined); | |||
| return NextResponse.json(cards); | |||
| } | |||
| export async function POST(request: Request) { | |||
| try { | |||
| const incomingCard: Card = await request.json(); | |||
| const cards = await getCards(); | |||
| const existingIndex = cards.findIndex(c => c.id === incomingCard.id); | |||
| if (existingIndex >= 0) { | |||
| cards[existingIndex] = incomingCard; | |||
| } else { | |||
| cards.push(incomingCard); | |||
| } | |||
| await saveCards(cards); | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| return NextResponse.json(incomingCard, { status: 200 }); | |||
| } catch (error) { | |||
| return NextResponse.json({ error: 'Failed to save card' }, { status: 500 }); | |||
| } | |||
| } | |||
| // NEW: Bulk update for saving card reordering | |||
| export async function PUT(request: Request) { | |||
| try { | |||
| const updatedCards: Card[] = await request.json(); | |||
| await saveCards(updatedCards); | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| return NextResponse.json({ success: true }, { status: 200 }); | |||
| } catch (error) { | |||
| return NextResponse.json({ error: 'Failed to reorder cards' }, { status: 500 }); | |||
| } | |||
| } | |||
| export async function DELETE(request: Request) { | |||
| try { | |||
| const { searchParams } = new URL(request.url); | |||
| const id = searchParams.get('id'); | |||
| if (!id) return NextResponse.json({ error: 'Card ID required' }, { status: 400 }); | |||
| const cards = await getCards(); | |||
| const filteredCards = cards.filter(c => c.id !== id); | |||
| await saveCards(filteredCards); | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| return NextResponse.json({ success: true }, { status: 200 }); | |||
| } catch (error) { | |||
| return NextResponse.json({ error: 'Failed to delete card' }, { status: 500 }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,35 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import fs from 'fs'; | |||
| import path from 'path'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export async function GET(request: Request) { | |||
| const { searchParams } = new URL(request.url); | |||
| const name = searchParams.get('name'); | |||
| if (!name) return new NextResponse('File name required', { status: 400 }); | |||
| const filePath = path.join(process.cwd(), 'data', 'uploads', name); | |||
| try { | |||
| const fileBuffer = fs.readFileSync(filePath); | |||
| // Determine basic mime types | |||
| const ext = path.extname(name).toLowerCase(); | |||
| let mimeType = 'image/jpeg'; | |||
| if (ext === '.png') mimeType = 'image/png'; | |||
| if (ext === '.gif') mimeType = 'image/gif'; | |||
| if (ext === '.svg') mimeType = 'image/svg+xml'; | |||
| if (ext === '.webp') mimeType = 'image/webp'; | |||
| return new NextResponse(fileBuffer, { | |||
| headers: { | |||
| 'Content-Type': mimeType, | |||
| 'Cache-Control': 'public, max-age=86400', // Cache in browser for 1 day | |||
| }, | |||
| }); | |||
| } catch (error) { | |||
| return new NextResponse('Image not found', { status: 404 }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { revalidatePath } from 'next/cache'; // ADD THIS | |||
| import { getPortals, savePortals } from '@/lib/db'; | |||
| import { Portal } from '@/types'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export async function GET() { | |||
| const portals = await getPortals(); | |||
| return NextResponse.json(portals[0] || null); | |||
| } | |||
| export async function POST(request: Request) { | |||
| try { | |||
| const incomingPortal: Portal = await request.json(); | |||
| const portals = await getPortals(); | |||
| if (portals.length > 0) { | |||
| portals[0] = { ...portals[0], ...incomingPortal }; | |||
| } else { | |||
| portals.push({ ...incomingPortal, id: 'default-portal', tenantId: 'default' }); | |||
| } | |||
| await savePortals(portals); | |||
| // ADD THIS LINE | |||
| revalidatePath('/'); | |||
| return NextResponse.json(portals[0], { status: 200 }); | |||
| } catch (error) { | |||
| return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { writeFile, mkdir } from 'fs/promises'; | |||
| import path from 'path'; | |||
| export async function POST(request: Request) { | |||
| try { | |||
| const formData = await request.formData(); | |||
| const file = formData.get('file') as File; | |||
| if (!file) { | |||
| return NextResponse.json({ error: 'No file received.' }, { status: 400 }); | |||
| } | |||
| const buffer = Buffer.from(await file.arrayBuffer()); | |||
| // Strip special characters to prevent URL breaking | |||
| const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_'); | |||
| const filename = `${Date.now()}-${safeName}`; | |||
| // Save to data/uploads instead of public/uploads | |||
| const uploadDir = path.join(process.cwd(), 'data', 'uploads'); | |||
| await mkdir(uploadDir, { recursive: true }); | |||
| await writeFile(path.join(uploadDir, filename), buffer); | |||
| // Return a dynamic API route URL instead of a static path | |||
| return NextResponse.json({ url: `/api/files?name=${filename}` }, { status: 201 }); | |||
| } catch (error) { | |||
| console.error('Upload Error:', error); | |||
| return NextResponse.json({ error: 'Failed to upload image.' }, { status: 500 }); | |||
| } | |||
| } | |||
| @@ -1,65 +1,26 @@ | |||
| import Image from "next/image"; | |||
| import { getCards, getPortals } from '@/lib/db'; | |||
| import PublicGrid from '@/components/PublicGrid'; | |||
| import HeroBanner from '@/components/HeroBanner'; | |||
| export const dynamic = 'force-dynamic'; | |||
| export default async function PublicHomePage() { | |||
| const portals = await getPortals(); | |||
| const cards = await getCards(); | |||
| const portal = portals[0] || {}; | |||
| export default function Home() { | |||
| return ( | |||
| <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> | |||
| <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> | |||
| <Image | |||
| className="dark:invert" | |||
| src="/next.svg" | |||
| alt="Next.js logo" | |||
| width={100} | |||
| height={20} | |||
| priority | |||
| /> | |||
| <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> | |||
| <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> | |||
| To get started, edit the page.tsx file. | |||
| </h1> | |||
| <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> | |||
| Looking for a starting point or more instructions? Head over to{" "} | |||
| <a | |||
| href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |||
| className="font-medium text-zinc-950 dark:text-zinc-50" | |||
| > | |||
| Templates | |||
| </a>{" "} | |||
| or the{" "} | |||
| <a | |||
| href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |||
| className="font-medium text-zinc-950 dark:text-zinc-50" | |||
| > | |||
| Learning | |||
| </a>{" "} | |||
| center. | |||
| </p> | |||
| </div> | |||
| <div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> | |||
| <a | |||
| className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" | |||
| href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| > | |||
| <Image | |||
| className="dark:invert" | |||
| src="/vercel.svg" | |||
| alt="Vercel logomark" | |||
| width={16} | |||
| height={16} | |||
| /> | |||
| Deploy Now | |||
| </a> | |||
| <a | |||
| className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" | |||
| href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| > | |||
| Documentation | |||
| </a> | |||
| </div> | |||
| </main> | |||
| </div> | |||
| <main className="min-h-screen bg-gray-100 font-sans"> | |||
| <HeroBanner portal={portal} /> | |||
| <div className="max-w-[1600px] mx-auto py-12 px-4"> | |||
| {cards.length > 0 ? ( | |||
| <PublicGrid cards={cards} maxCols={portal.maxGridColumns || 5} /> | |||
| ) : ( | |||
| <div className="text-center text-gray-500 py-20"> | |||
| <p>No cards have been added yet.</p> | |||
| </div> | |||
| )} | |||
| </div> | |||
| </main> | |||
| ); | |||
| } | |||
| } | |||
| @@ -0,0 +1,43 @@ | |||
| import { Portal } from '@/types'; | |||
| export default function HeroBanner({ portal }: { portal: Partial<Portal> }) { | |||
| const themeColor = portal?.themeColor || '#1e3a8a'; | |||
| return ( | |||
| <div | |||
| className="relative text-white text-center py-20 px-4 min-h-[40vh] flex flex-col justify-center" | |||
| style={{ | |||
| backgroundImage: portal?.heroImageUrl ? `url(${portal.heroImageUrl})` : 'none', | |||
| backgroundSize: 'cover', | |||
| backgroundPosition: 'center', | |||
| backgroundColor: themeColor | |||
| }} | |||
| > | |||
| {/* Dynamic Overlay: Either a solid dark tint, or a fade into the theme color */} | |||
| <div | |||
| className="absolute inset-0" | |||
| style={{ | |||
| background: portal?.fadeHeroImage | |||
| ? `linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, ${themeColor} 100%)` | |||
| : 'rgba(0,0,0,0.6)' | |||
| }} | |||
| ></div> | |||
| <div className="relative z-10 max-w-4xl mx-auto flex flex-col items-center"> | |||
| {portal?.logoUrl && ( | |||
| <img | |||
| src={portal.logoUrl} | |||
| alt="Institution Logo" | |||
| className="h-24 mb-6 object-contain bg-white/90 p-2 rounded-xl shadow-lg" | |||
| /> | |||
| )} | |||
| <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 drop-shadow-md"> | |||
| {portal?.title || 'Welcome to the Network'} | |||
| </h1> | |||
| <p className="text-lg md:text-2xl drop-shadow-md font-light"> | |||
| {portal?.welcomeText || 'Please contact administration to set up.'} | |||
| </p> | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,88 @@ | |||
| 'use client'; | |||
| import { useState, useEffect } from 'react'; | |||
| import { Card } from '@/types'; | |||
| export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { | |||
| const [activeCard, setActiveCard] = useState<Card | null>(null); | |||
| // Prevent background scrolling when modal is open | |||
| useEffect(() => { | |||
| if (activeCard) document.body.style.overflow = 'hidden'; | |||
| else document.body.style.overflow = 'unset'; | |||
| return () => { document.body.style.overflow = 'unset'; } | |||
| }, [activeCard]); | |||
| // Tailwind classes mapping based on the admin's chosen max columns | |||
| const gridClasses: Record<number, string> = { | |||
| 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', // ADDED THIS LINE | |||
| 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', | |||
| 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1', | |||
| 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2', | |||
| 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2', | |||
| 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2', | |||
| }; | |||
| const activeGridClass = gridClasses[maxCols] || gridClasses[5]; | |||
| return ( | |||
| <> | |||
| <div className={`grid gap-4 ${activeGridClass}`}> | |||
| {cards.map((card) => ( | |||
| <div | |||
| key={card.id} | |||
| onClick={() => setActiveCard(card)} | |||
| className="group relative cursor-pointer overflow-hidden rounded-xl shadow-md aspect-square bg-gray-200 transition-all duration-300 hover:shadow-xl hover:-translate-y-1" | |||
| > | |||
| {card.imageUrl ? ( | |||
| <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" /> | |||
| ) : ( | |||
| <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div> | |||
| )} | |||
| <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white"> | |||
| <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3> | |||
| <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow"> | |||
| {card.shortDescription} | |||
| </p> | |||
| </div> | |||
| </div> | |||
| ))} | |||
| </div> | |||
| {/* Improved Modal Pop-up */} | |||
| {activeCard && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 transition-opacity" | |||
| onClick={() => setActiveCard(null)} // Click outside to close | |||
| > | |||
| <div | |||
| className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in-95 duration-200" | |||
| onClick={(e) => e.stopPropagation()} // Prevent clicks inside modal from closing it | |||
| > | |||
| <div className="relative h-72 w-full bg-gray-100"> | |||
| {activeCard.imageUrl && ( | |||
| <img src={activeCard.imageUrl} className="w-full h-full object-cover rounded-t-2xl" alt="" /> | |||
| )} | |||
| {/* Improved Close Button */} | |||
| <button | |||
| onClick={() => setActiveCard(null)} | |||
| className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg" | |||
| title="Close" | |||
| > | |||
| ✕ | |||
| </button> | |||
| </div> | |||
| <div className="p-8"> | |||
| <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div> | |||
| <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2> | |||
| {activeCard.fullContent ? ( | |||
| <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} /> | |||
| ) : ( | |||
| <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p> | |||
| )} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,50 @@ | |||
| [ | |||
| { | |||
| "title": "La Casa Della Scuola", | |||
| "cardType": "EXTERNAL_LINK", | |||
| "displayOrder": 0, | |||
| "shortDescription": "https://mail.afasystems.it/frontend/mail/?_task=mail&_mbox=INBOX", | |||
| "imageUrl": "/uploads/1775385607750-01-La-Casa-della-Scuola.jpg", | |||
| "id": "card-mnlmr330z43ga1fzbz" | |||
| }, | |||
| { | |||
| "title": "Una storia di cultura", | |||
| "cardType": "EXTERNAL_LINK", | |||
| "displayOrder": 1, | |||
| "shortDescription": "I sistemi di videosorveglianza avanzata che tracciano in tempo reale oggetti e soggetti, sfruttando algoritmi e machine learning per gestire sicurezza, traffico, accessi, spostamenti, informazioni e molto altro ancora", | |||
| "imageUrl": "/api/files?name=1777903155955-ai-concetto-di-dispositivo-alimentato.jpg", | |||
| "id": "card-mnlnrsf4u79zqctk1y" | |||
| }, | |||
| { | |||
| "title": "Eco Smart City", | |||
| "cardType": "EXTERNAL_LINK", | |||
| "displayOrder": 2, | |||
| "imageUrl": "/api/files?name=1777903241584-vecteezy_ai-generated-aerial-top-down-drone-view-autonomous-self_35872738.jpg", | |||
| "shortDescription": "Unendo la potenza della fibra ottica a soluzioni wireless evolute, affianchiamo amministrazioni pubbliche e investitori privati nella costruzione di reti intelligenti su scala locale e sovralocale, realizzando un’infrastruttura permanente, efficiente, strategica.", | |||
| "id": "card-mnlnt57lc41br0howcg" | |||
| }, | |||
| { | |||
| "title": "MajorNet", | |||
| "cardType": "EXTERNAL_LINK", | |||
| "displayOrder": 3, | |||
| "shortDescription": "La piattaforma sviluppata da AFA Systems che integra in un ambiente digitale avanzato e sicuro il tuo cloud privato, gestisce la comunicazione unificata e garantisce conformità normativa (GDPR) senza compromessi", | |||
| "imageUrl": "/api/files?name=1777903347085-majornet-cloud-privato-on-premises-cybersecurity-01.jpg", | |||
| "id": "card-mnlntjvb8ximmhzdky4" | |||
| }, | |||
| { | |||
| "title": "Notifica Massiva", | |||
| "cardType": "INFO_PAGE", | |||
| "displayOrder": 4, | |||
| "shortDescription": "Invio rapido e tracciabile di messaggi a grandi gruppi di destinatari.", | |||
| "imageUrl": "/api/files?name=1777903640444-sistemi-di-ia-1536x861.webp", | |||
| "id": "card-mnlnu47vi20a86uaz1l" | |||
| }, | |||
| { | |||
| "title": "Whistleblowing", | |||
| "cardType": "EXTERNAL_LINK", | |||
| "displayOrder": 5, | |||
| "shortDescription": "Il servizio per i canali interni di segnalazione Whistleblowing. Semplice, sicuro, certificato.", | |||
| "imageUrl": "/api/files?name=1777903785702-vecteezy_modern-black-telephone-on-a-dark-surface-showcasing-its_55270514.jpeg", | |||
| "id": "card-mnlnucp3zhaqkurtxpp" | |||
| } | |||
| ] | |||
| @@ -0,0 +1,13 @@ | |||
| [ | |||
| { | |||
| "title": "AFA Systems", | |||
| "welcomeText": "Benvenuto in AFA Systems", | |||
| "themeColor": "#004263", | |||
| "fadeHeroImage": true, | |||
| "id": "default-portal", | |||
| "tenantId": "default", | |||
| "logoUrl": "/api/files?name=1777902386600-logo-afasystems-blu-orizzontale.png", | |||
| "heroImageUrl": "/api/files?name=1777902340532-vecteezy_ai-generated-futuristic-data-cable-network_38464261.jpeg", | |||
| "maxGridColumns": 3 | |||
| } | |||
| ] | |||
| @@ -0,0 +1,80 @@ | |||
| import fs from 'fs/promises'; | |||
| import path from 'path'; | |||
| import { Portal, Card } from '@/types'; | |||
| const DATA_DIR = path.join(process.cwd(), 'data'); | |||
| const PORTALS_FILE = path.join(DATA_DIR, 'portals.txt'); | |||
| const CARDS_FILE = path.join(DATA_DIR, 'cards.txt'); | |||
| // Helper to ensure files exist | |||
| async function ensureDb() { | |||
| try { await fs.access(DATA_DIR); } catch { await fs.mkdir(DATA_DIR); } | |||
| try { await fs.access(PORTALS_FILE); } catch { await fs.writeFile(PORTALS_FILE, '[]'); } | |||
| try { await fs.access(CARDS_FILE); } catch { await fs.writeFile(CARDS_FILE, '[]'); } | |||
| } | |||
| export async function getPortals(): Promise<Portal[]> { | |||
| await ensureDb(); | |||
| const data = await fs.readFile(PORTALS_FILE, 'utf-8'); | |||
| return JSON.parse(data || '[]'); | |||
| } | |||
| export async function getCards(portalId?: string): Promise<Card[]> { | |||
| await ensureDb(); | |||
| const data = await fs.readFile(CARDS_FILE, 'utf-8'); | |||
| let cards: Card[] = JSON.parse(data || '[]'); | |||
| if (portalId) { | |||
| cards = cards.filter(c => c.portalId === portalId); | |||
| } | |||
| // ALWAYS sort, regardless of whether portalId was passed | |||
| return cards.sort((a, b) => a.displayOrder - b.displayOrder); | |||
| } | |||
| export async function saveCards(cards: Card[]): Promise<void> { | |||
| await ensureDb(); | |||
| await fs.writeFile(CARDS_FILE, JSON.stringify(cards, null, 2)); | |||
| } | |||
| // Seed function for "Casa della scuola" | |||
| export async function seedDatabase() { | |||
| const portalId = 'uuid-casa-della-scuola'; | |||
| const portals: Portal[] = [{ | |||
| id: portalId, | |||
| tenantId: 'casa-della-scuola', | |||
| title: 'Benvenuti alla Casa della Scuola', | |||
| welcomeText: 'Discover the rich history and beautiful landscapes of our heritage.', | |||
| heroImageUrl: '/hero-bg.jpg', | |||
| logoUrl: '/logo.png', | |||
| themeColor: '#1e3a8a' | |||
| }]; | |||
| const cards: Card[] = [ | |||
| { | |||
| id: 'uuid-card-1', portalId, title: 'History before the war', | |||
| imageUrl: '/history.jpg', shortDescription: 'Explore the origins.', | |||
| fullContent: '<p>Long text about the history...</p>', cardType: 'INFO_PAGE', displayOrder: 1 | |||
| }, | |||
| { | |||
| id: 'uuid-card-2', portalId, title: 'Pettinicchio Biography', | |||
| imageUrl: '/pettinicchio.jpg', shortDescription: 'Life and legacy.', | |||
| fullContent: '<p>Biography details...</p>', cardType: 'INFO_PAGE', displayOrder: 2 | |||
| }, | |||
| { | |||
| id: 'uuid-card-3', portalId, title: 'Campobasso Landscapes', | |||
| imageUrl: '/campobasso.jpg', shortDescription: 'Scenic views of the region.', | |||
| fullContent: '', cardType: 'IMAGE_GALLERY', displayOrder: 3 | |||
| } | |||
| ]; | |||
| await fs.writeFile(PORTALS_FILE, JSON.stringify(portals, null, 2)); | |||
| await fs.writeFile(CARDS_FILE, JSON.stringify(cards, null, 2)); | |||
| } | |||
| export async function savePortals(portals: Portal[]): Promise<void> { | |||
| const fs = require('fs/promises'); | |||
| const path = require('path'); | |||
| const PORTALS_FILE = path.join(process.cwd(), 'data', 'portals.txt'); | |||
| await fs.writeFile(PORTALS_FILE, JSON.stringify(portals, null, 2)); | |||
| } | |||
| @@ -0,0 +1,35 @@ | |||
| export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST'; | |||
| export interface Portal { | |||
| id: string; | |||
| tenantId: string; | |||
| title: string; | |||
| welcomeText: string; | |||
| heroImageUrl: string; | |||
| logoUrl: string; | |||
| themeColor: string; | |||
| } | |||
| export interface Card { | |||
| id: string; | |||
| portalId: string; | |||
| title: string; | |||
| imageUrl: string; | |||
| shortDescription: string; | |||
| fullContent: string; | |||
| cardType: CardType; | |||
| actionUrl?: string; | |||
| displayOrder: number; | |||
| } | |||
| export interface Portal { | |||
| id: string; | |||
| tenantId: string; | |||
| title: string; | |||
| welcomeText: string; | |||
| heroImageUrl: string; | |||
| logoUrl: string; | |||
| themeColor: string; | |||
| fadeHeroImage?: boolean; | |||
| maxGridColumns?: number; | |||
| } | |||