| @@ -34,3 +34,8 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next | |||
| The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |||
| Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. | |||
| ## Configurazione | |||
| Per configruare usare il file lib/config.ts | |||
| Per disattivare l'utilizzo di external link, settare a false la variabile EXTERNAL_LINK_ENABLED a false; | |||
| @@ -1,30 +1,117 @@ | |||
| 'use client'; | |||
| import { useState, useEffect } from 'react'; | |||
| import { Card, Portal } from '@/types'; | |||
| import { Card, Portal, MediaItem } from '@/types'; | |||
| import { EXTERNAL_LINK_ENABLED } from '@/lib/config'; | |||
| const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | |||
| const isPdfFile = (file: File) => | |||
| file.type === 'application/pdf' || /\.pdf$/i.test(file.name); | |||
| const isVideoFile = (file: File) => | |||
| file.type.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/i.test(file.name); | |||
| async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> { | |||
| const formData = new FormData(); | |||
| formData.append('file', new File([blob], name, { type: blob.type || 'image/png' })); | |||
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); | |||
| const data = await res.json(); | |||
| return data.url || null; | |||
| } | |||
| async function extractVideoFrame(file: File): Promise<Blob | null> { | |||
| const url = URL.createObjectURL(file); | |||
| try { | |||
| const video = document.createElement('video'); | |||
| video.muted = true; | |||
| video.playsInline = true; | |||
| video.preload = 'metadata'; | |||
| video.src = url; | |||
| await new Promise<void>((resolve, reject) => { | |||
| video.addEventListener('loadedmetadata', () => resolve(), { once: true }); | |||
| video.addEventListener('error', () => reject(new Error('video load error')), { once: true }); | |||
| }); | |||
| // Seek slightly past 0 — at exactly 0 some codecs return a black frame | |||
| video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10)); | |||
| await new Promise<void>((resolve, reject) => { | |||
| video.addEventListener('seeked', () => resolve(), { once: true }); | |||
| video.addEventListener('error', () => reject(new Error('video seek error')), { once: true }); | |||
| }); | |||
| const canvas = document.createElement('canvas'); | |||
| canvas.width = video.videoWidth; | |||
| canvas.height = video.videoHeight; | |||
| const ctx = canvas.getContext('2d'); | |||
| if (!ctx) return null; | |||
| ctx.drawImage(video, 0, 0); | |||
| return await new Promise<Blob | null>((resolve) => | |||
| canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85) | |||
| ); | |||
| } finally { | |||
| URL.revokeObjectURL(url); | |||
| } | |||
| } | |||
| async function pdfToImageItems( | |||
| file: File, | |||
| onProgress: (page: number, total: number) => void | |||
| ): Promise<MediaItem[]> { | |||
| const pdfjs = await import('pdfjs-dist'); | |||
| // Worker file is copied to /public via the postinstall script | |||
| pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'; | |||
| const arrayBuffer = await file.arrayBuffer(); | |||
| const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise; | |||
| const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_'); | |||
| const items: MediaItem[] = []; | |||
| for (let i = 1; i <= pdf.numPages; i++) { | |||
| onProgress(i, pdf.numPages); | |||
| const page = await pdf.getPage(i); | |||
| const viewport = page.getViewport({ scale: 1.5 }); | |||
| const canvas = document.createElement('canvas'); | |||
| canvas.width = viewport.width; | |||
| canvas.height = viewport.height; | |||
| const ctx = canvas.getContext('2d'); | |||
| if (!ctx) continue; | |||
| await page.render({ canvasContext: ctx, viewport }).promise; | |||
| const blob: Blob = await new Promise((resolve, reject) => { | |||
| canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png'); | |||
| }); | |||
| const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`); | |||
| if (url) items.push({ url }); | |||
| } | |||
| return items; | |||
| } | |||
| 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); | |||
| const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | 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)); | |||
| @@ -33,12 +120,12 @@ export default function AdminDashboard() { | |||
| 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 })); | |||
| @@ -49,6 +136,104 @@ export default function AdminDashboard() { | |||
| setUploading(prev => ({ ...prev, [field]: false })); | |||
| }; | |||
| const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const files = e.target.files; | |||
| if (!files || files.length === 0) return; | |||
| setUploading(prev => ({ ...prev, extraMedia: true })); | |||
| const startedWithoutCover = !isEditing?.imageUrl; | |||
| let pendingCover: string | null = null; | |||
| const canPromote = () => startedWithoutCover && !pendingCover; | |||
| const uploaded: MediaItem[] = []; | |||
| for (const file of Array.from(files)) { | |||
| try { | |||
| if (isPdfFile(file)) { | |||
| const items = await pdfToImageItems(file, (page, total) => | |||
| setPdfProgress({ name: file.name, page, total }) | |||
| ); | |||
| setPdfProgress(null); | |||
| if (items.length > 0 && canPromote()) { | |||
| // Promote the first PDF page to cover; skip it from the gallery to avoid duplication. | |||
| pendingCover = items[0].url; | |||
| uploaded.push(...items.slice(1)); | |||
| } else { | |||
| uploaded.push(...items); | |||
| } | |||
| } else { | |||
| const formData = new FormData(); | |||
| formData.append('file', file); | |||
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); | |||
| const data = await res.json(); | |||
| if (!data.url) continue; | |||
| if (isVideoFile(file)) { | |||
| // Video always goes to the gallery so users can play it. | |||
| uploaded.push({ url: data.url }); | |||
| // If no cover yet, extract the first frame and use it as the cover. | |||
| if (canPromote()) { | |||
| try { | |||
| const blob = await extractVideoFrame(file); | |||
| if (blob) { | |||
| const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_'); | |||
| const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`); | |||
| if (posterUrl) pendingCover = posterUrl; | |||
| } | |||
| } catch (err) { | |||
| console.warn('Could not extract video poster for', file.name, err); | |||
| } | |||
| } | |||
| } else { | |||
| // Plain image | |||
| if (canPromote()) { | |||
| // Promote to cover; skip the gallery to avoid duplication. | |||
| pendingCover = data.url; | |||
| } else { | |||
| uploaded.push({ url: data.url }); | |||
| } | |||
| } | |||
| } | |||
| } catch (err) { | |||
| console.error('Upload failed for', file.name, err); | |||
| showToast(`Failed to process "${file.name}".`); | |||
| setPdfProgress(null); | |||
| } | |||
| } | |||
| setIsEditing(prev => ({ | |||
| ...prev, | |||
| imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''), | |||
| extraMedia: [...(prev?.extraMedia || []), ...uploaded], | |||
| })); | |||
| setUploading(prev => ({ ...prev, extraMedia: false })); | |||
| e.target.value = ''; | |||
| }; | |||
| const removeExtraMedia = (index: number) => { | |||
| setIsEditing(prev => ({ | |||
| ...prev, | |||
| extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index), | |||
| })); | |||
| }; | |||
| const toggleAutoplay = (index: number) => { | |||
| setIsEditing(prev => ({ | |||
| ...prev, | |||
| extraMedia: (prev?.extraMedia || []).map((m, i) => | |||
| i === index ? { ...m, autoplay: !m.autoplay } : m | |||
| ), | |||
| })); | |||
| }; | |||
| const toggleMuted = (index: number) => { | |||
| setIsEditing(prev => ({ | |||
| ...prev, | |||
| extraMedia: (prev?.extraMedia || []).map((m, i) => | |||
| i === index ? { ...m, muted: !m.muted } : m | |||
| ), | |||
| })); | |||
| }; | |||
| const handleSaveCard = async () => { | |||
| if (!isEditing) return; | |||
| const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); | |||
| @@ -159,7 +344,15 @@ export default function AdminDashboard() { | |||
| // 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>} | |||
| {(() => { | |||
| const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || ''; | |||
| if (!previewUrl) { | |||
| return <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>; | |||
| } | |||
| return isVideoUrl(previewUrl) | |||
| ? <video src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" /> | |||
| : <img src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />; | |||
| })()} | |||
| <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> | |||
| @@ -306,31 +499,161 @@ export default function AdminDashboard() { | |||
| <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> | |||
| {EXTERNAL_LINK_ENABLED && <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..." /> | |||
| {isEditing.cardType === 'EXTERNAL_LINK' ? ( | |||
| <> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">URL</label> | |||
| <input | |||
| type="url" | |||
| value={isEditing.actionUrl || ''} | |||
| onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })} | |||
| className={inputClasses} | |||
| placeholder="https://esempio.it/pagina" | |||
| /> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label> | |||
| <input | |||
| type="text" | |||
| value={isEditing.shortDescription || ''} | |||
| onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} | |||
| className={inputClasses} | |||
| placeholder="es. Visita il sito ufficiale" | |||
| /> | |||
| <p className="text-xs text-gray-500 mt-1">Testo visualizzato come link cliccabile nel modale. Se vuoto, viene mostrata l’URL stessa.</p> | |||
| </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 className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3"> | |||
| <label className="flex items-start gap-3 cursor-pointer"> | |||
| <input | |||
| type="checkbox" | |||
| checked={!!isEditing.autoFullscreen} | |||
| onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })} | |||
| className="w-5 h-5 text-blue-600 rounded mt-0.5" | |||
| /> | |||
| <div> | |||
| <span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span> | |||
| <span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span> | |||
| </div> | |||
| </label> | |||
| <label className="flex items-start gap-3 cursor-pointer"> | |||
| <input | |||
| type="checkbox" | |||
| checked={!!isEditing.skipPreview} | |||
| onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })} | |||
| className="w-5 h-5 text-blue-600 rounded mt-0.5" | |||
| /> | |||
| <div> | |||
| <span className="block text-sm font-semibold text-gray-900">Skip preview</span> | |||
| <span className="block text-xs text-gray-600">Don’t show the cover as a slide in the gallery. The cover stays as the card thumbnail only. Combine with “Auto fullscreen” to jump straight into the gallery items.</span> | |||
| </div> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| <div className="space-y-5"> | |||
| {/* Cover Image */} | |||
| <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"> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1"> | |||
| Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span> | |||
| </label> | |||
| <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 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>} | |||
| {uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</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: ''})} | |||
| <div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group"> | |||
| <img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover 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> | |||
| title="Remove cover image" | |||
| >✕</button> | |||
| </div> | |||
| )} | |||
| </div> | |||
| {/* Gallery Media (images + videos + PDFs) */} | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1"> | |||
| Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become slides)</span> | |||
| </label> | |||
| <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors"> | |||
| <input | |||
| type="file" | |||
| accept="image/*,video/*,application/pdf,.pdf" | |||
| multiple | |||
| onChange={handleUploadExtraMedia} | |||
| 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-purple-50 file:text-purple-700 hover:file:bg-purple-100 cursor-pointer" | |||
| /> | |||
| {uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>} | |||
| {pdfProgress && ( | |||
| <p className="mt-2 text-sm text-purple-600 font-medium"> | |||
| Processing “{pdfProgress.name}”: page {pdfProgress.page} of {pdfProgress.total} | |||
| </p> | |||
| )} | |||
| </div> | |||
| {(isEditing.extraMedia || []).length > 0 && ( | |||
| <div className="mt-3 space-y-2"> | |||
| {(isEditing.extraMedia || []).map((item, i) => { | |||
| const video = isVideoUrl(item.url); | |||
| return ( | |||
| <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg"> | |||
| <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0"> | |||
| {video ? ( | |||
| <> | |||
| <video src={item.url} className="w-full h-full object-cover" muted preload="metadata" /> | |||
| <div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div> | |||
| </> | |||
| ) : ( | |||
| <img src={item.url} className="w-full h-full object-cover" alt="" /> | |||
| )} | |||
| <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span> | |||
| </div> | |||
| <div className="flex-1 min-w-0"> | |||
| <div className="text-xs font-semibold text-gray-700 uppercase tracking-wider"> | |||
| {video ? 'Video' : 'Image'} | |||
| </div> | |||
| {video && ( | |||
| <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1"> | |||
| <label className="flex items-center gap-2 cursor-pointer"> | |||
| <input | |||
| type="checkbox" | |||
| checked={!!item.autoplay} | |||
| onChange={() => toggleAutoplay(i)} | |||
| className="w-4 h-4 text-blue-600 rounded" | |||
| /> | |||
| <span className="text-sm text-gray-700">Autoplay</span> | |||
| </label> | |||
| <label className="flex items-center gap-2 cursor-pointer"> | |||
| <input | |||
| type="checkbox" | |||
| checked={!!item.muted} | |||
| onChange={() => toggleMuted(i)} | |||
| className="w-4 h-4 text-blue-600 rounded" | |||
| /> | |||
| <span className="text-sm text-gray-700">Muted</span> | |||
| </label> | |||
| </div> | |||
| )} | |||
| </div> | |||
| <button | |||
| onClick={() => removeExtraMedia(i)} | |||
| className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0" | |||
| title="Remove" | |||
| >✕</button> | |||
| </div> | |||
| ); | |||
| })} | |||
| </div> | |||
| )} | |||
| </div> | |||
| @@ -1,35 +1,84 @@ | |||
| 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 }); | |||
| } | |||
| } | |||
| import { NextResponse } from 'next/server'; | |||
| import fs from 'fs'; | |||
| import path from 'path'; | |||
| export const dynamic = 'force-dynamic'; | |||
| const MIME: Record<string, string> = { | |||
| '.png': 'image/png', | |||
| '.jpg': 'image/jpeg', | |||
| '.jpeg': 'image/jpeg', | |||
| '.gif': 'image/gif', | |||
| '.svg': 'image/svg+xml', | |||
| '.webp': 'image/webp', | |||
| '.mp4': 'video/mp4', | |||
| '.webm': 'video/webm', | |||
| '.mov': 'video/quicktime', | |||
| '.m4v': 'video/x-m4v', | |||
| '.ogv': 'video/ogg', | |||
| }; | |||
| 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); | |||
| let stat: fs.Stats; | |||
| try { | |||
| stat = fs.statSync(filePath); | |||
| } catch { | |||
| return new NextResponse('File not found', { status: 404 }); | |||
| } | |||
| const ext = path.extname(name).toLowerCase(); | |||
| const mimeType = MIME[ext] || 'application/octet-stream'; | |||
| const fileSize = stat.size; | |||
| // Handle Range requests (essential for video seeking) | |||
| const range = request.headers.get('range'); | |||
| if (range) { | |||
| const match = /bytes=(\d*)-(\d*)/.exec(range); | |||
| if (match) { | |||
| const start = match[1] ? parseInt(match[1], 10) : 0; | |||
| const end = match[2] ? parseInt(match[2], 10) : fileSize - 1; | |||
| const chunkSize = end - start + 1; | |||
| const stream = fs.createReadStream(filePath, { start, end }); | |||
| // Convert Node stream to Web ReadableStream | |||
| const webStream = new ReadableStream({ | |||
| start(controller) { | |||
| stream.on('data', chunk => controller.enqueue(new Uint8Array(chunk as Buffer))); | |||
| stream.on('end', () => controller.close()); | |||
| stream.on('error', err => controller.error(err)); | |||
| }, | |||
| cancel() { | |||
| stream.destroy(); | |||
| }, | |||
| }); | |||
| return new NextResponse(webStream, { | |||
| status: 206, | |||
| headers: { | |||
| 'Content-Type': mimeType, | |||
| 'Content-Length': chunkSize.toString(), | |||
| 'Content-Range': `bytes ${start}-${end}/${fileSize}`, | |||
| 'Accept-Ranges': 'bytes', | |||
| 'Cache-Control': 'public, max-age=86400', | |||
| }, | |||
| }); | |||
| } | |||
| } | |||
| // Full file response (for images, or videos without Range header) | |||
| const buffer = fs.readFileSync(filePath); | |||
| return new NextResponse(buffer, { | |||
| headers: { | |||
| 'Content-Type': mimeType, | |||
| 'Content-Length': fileSize.toString(), | |||
| 'Accept-Ranges': 'bytes', | |||
| 'Cache-Control': 'public, max-age=86400', | |||
| }, | |||
| }); | |||
| } | |||
| @@ -1,20 +1,11 @@ | |||
| import type { Metadata } from "next"; | |||
| import { Geist, Geist_Mono } from "next/font/google"; | |||
| import { GeistSans } from "geist/font/sans"; | |||
| import { GeistMono } from "geist/font/mono"; | |||
| import "./globals.css"; | |||
| const geistSans = Geist({ | |||
| variable: "--font-geist-sans", | |||
| subsets: ["latin"], | |||
| }); | |||
| const geistMono = Geist_Mono({ | |||
| variable: "--font-geist-mono", | |||
| subsets: ["latin"], | |||
| }); | |||
| export const metadata: Metadata = { | |||
| title: "Create Next App", | |||
| description: "Generated by create next app", | |||
| title: "Captive Portal", | |||
| description: "Welcome", | |||
| }; | |||
| export default function RootLayout({ | |||
| @@ -24,8 +15,8 @@ export default function RootLayout({ | |||
| }>) { | |||
| return ( | |||
| <html | |||
| lang="en" | |||
| className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} | |||
| lang="it" | |||
| className={`${GeistSans.variable} ${GeistMono.variable} h-full antialiased`} | |||
| > | |||
| <body className="min-h-full flex flex-col">{children}</body> | |||
| </html> | |||
| @@ -1,88 +1,561 @@ | |||
| '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> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| 'use client'; | |||
| import { useState, useEffect, useCallback, useRef } from 'react'; | |||
| import { Card, MediaItem } from '@/types'; | |||
| const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | |||
| function MediaCarousel({ | |||
| items, | |||
| onMediaClick, | |||
| }: { | |||
| items: MediaItem[]; | |||
| onMediaClick?: (index: number) => void; | |||
| }) { | |||
| const [current, setCurrent] = useState(0); | |||
| const [playing, setPlaying] = useState<Set<number>>(new Set()); | |||
| const touchStartX = useRef<number | null>(null); | |||
| const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({}); | |||
| const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; }); | |||
| const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; }); | |||
| const applyVolume = (v: HTMLVideoElement, item: MediaItem) => { | |||
| v.muted = !!item.muted; | |||
| }; | |||
| const togglePlay = (i: number) => { | |||
| const v = videoRefs.current[i]; | |||
| if (!v) return; | |||
| if (v.paused) { | |||
| const item = items[i]; | |||
| if (item) applyVolume(v, item); | |||
| v.play().catch(() => {}); | |||
| } else { | |||
| v.pause(); | |||
| } | |||
| }; | |||
| const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]); | |||
| const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]); | |||
| useEffect(() => { | |||
| const onKey = (e: KeyboardEvent) => { | |||
| if (e.key === 'ArrowLeft') prev(); | |||
| if (e.key === 'ArrowRight') next(); | |||
| }; | |||
| window.addEventListener('keydown', onKey); | |||
| return () => window.removeEventListener('keydown', onKey); | |||
| }, [prev, next]); | |||
| useEffect(() => { setCurrent(0); }, [items]); | |||
| // Pause non-current videos; autoplay current if flagged | |||
| useEffect(() => { | |||
| Object.entries(videoRefs.current).forEach(([key, vid]) => { | |||
| if (!vid) return; | |||
| const idx = parseInt(key, 10); | |||
| if (idx !== current) { vid.pause(); return; } | |||
| const item = items[idx]; | |||
| if (item && isVideoUrl(item.url) && item.autoplay) { | |||
| applyVolume(vid, item); | |||
| vid.play().catch(() => { | |||
| // Browser blocked unmuted autoplay — fall back to muted. | |||
| vid.muted = true; | |||
| vid.play().catch(() => {}); | |||
| }); | |||
| } | |||
| }); | |||
| }, [current, items]); | |||
| const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; | |||
| const onTouchEnd = (e: React.TouchEvent) => { | |||
| if (touchStartX.current === null) return; | |||
| const delta = e.changedTouches[0].clientX - touchStartX.current; | |||
| if (Math.abs(delta) > 50) delta < 0 ? next() : prev(); | |||
| touchStartX.current = null; | |||
| }; | |||
| if (items.length === 0) { | |||
| return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>; | |||
| } | |||
| return ( | |||
| <div | |||
| className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none" | |||
| onTouchStart={onTouchStart} | |||
| onTouchEnd={onTouchEnd} | |||
| > | |||
| {items.map((item, i) => { | |||
| const isActive = i === current; | |||
| const video = isVideoUrl(item.url); | |||
| return ( | |||
| <div | |||
| key={item.url + i} | |||
| className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} | |||
| > | |||
| {video ? ( | |||
| <div | |||
| className="relative w-full h-full cursor-pointer" | |||
| onClick={(e) => { e.stopPropagation(); togglePlay(i); }} | |||
| > | |||
| <video | |||
| ref={el => { videoRefs.current[i] = el; }} | |||
| src={item.url} | |||
| className="w-full h-full object-contain bg-black pointer-events-none" | |||
| playsInline | |||
| preload="metadata" | |||
| onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)} | |||
| onPlay={() => markPlaying(i)} | |||
| onPause={() => markPaused(i)} | |||
| onEnded={() => markPaused(i)} | |||
| /> | |||
| {/* Custom play overlay (shown when paused) */} | |||
| {!playing.has(i) && ( | |||
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |||
| <span className="bg-black/60 text-white w-20 h-20 rounded-full flex items-center justify-center text-4xl shadow-2xl pl-1">▶</span> | |||
| </div> | |||
| )} | |||
| {/* Custom expand button */} | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }} | |||
| className="absolute bottom-3 right-3 bg-black/60 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10" | |||
| title="Expand fullscreen" | |||
| aria-label="Expand fullscreen" | |||
| > | |||
| <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |||
| <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" /> | |||
| </svg> | |||
| </button> | |||
| </div> | |||
| ) : ( | |||
| <img | |||
| src={item.url} | |||
| alt="" | |||
| className="w-full h-full object-cover cursor-zoom-in" | |||
| onClick={() => onMediaClick?.(i)} | |||
| title="Click to view fullscreen" | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| })} | |||
| {items.length > 1 && ( | |||
| <> | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); prev(); }} | |||
| className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10" | |||
| aria-label="Previous" | |||
| >‹</button> | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); next(); }} | |||
| className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10" | |||
| aria-label="Next" | |||
| >›</button> | |||
| <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10"> | |||
| {items.map((_, i) => ( | |||
| <button | |||
| key={i} | |||
| onClick={(e) => { e.stopPropagation(); setCurrent(i); }} | |||
| className={`rounded-full transition-all duration-200 ${i === current ? 'w-5 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`} | |||
| aria-label={`Go to slide ${i + 1}`} | |||
| /> | |||
| ))} | |||
| </div> | |||
| <div className="absolute top-3 left-3 bg-black/60 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10 flex items-center gap-1"> | |||
| <span>⊞</span> | |||
| <span>{current + 1} / {items.length}</span> | |||
| </div> | |||
| </> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| function FullscreenViewer({ | |||
| items, | |||
| startIndex, | |||
| onClose, | |||
| }: { | |||
| items: MediaItem[]; | |||
| startIndex: number; | |||
| onClose: () => void; | |||
| }) { | |||
| const [current, setCurrent] = useState(startIndex); | |||
| const [playing, setPlaying] = useState<Set<number>>(new Set()); | |||
| const [zoom, setZoom] = useState(1); | |||
| const [pan, setPan] = useState({ x: 0, y: 0 }); | |||
| const touchStartX = useRef<number | null>(null); | |||
| const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({}); | |||
| const containerRef = useRef<HTMLDivElement | null>(null); | |||
| const dragRef = useRef<{ sx: number; sy: number; px: number; py: number; moved: boolean } | null>(null); | |||
| const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; }); | |||
| const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; }); | |||
| const applyVolume = (v: HTMLVideoElement, item: MediaItem) => { | |||
| v.muted = !!item.muted; | |||
| }; | |||
| const togglePlay = (i: number) => { | |||
| const v = videoRefs.current[i]; | |||
| if (!v) return; | |||
| if (v.paused) { | |||
| const item = items[i]; | |||
| if (item) applyVolume(v, item); | |||
| v.play().catch(() => {}); | |||
| } else { | |||
| v.pause(); | |||
| } | |||
| }; | |||
| const onImgClick = (e: React.MouseEvent<HTMLImageElement>) => { | |||
| if (dragRef.current?.moved) { | |||
| dragRef.current = null; | |||
| return; | |||
| } | |||
| dragRef.current = null; | |||
| if (zoom > 1) { | |||
| setZoom(1); | |||
| setPan({ x: 0, y: 0 }); | |||
| } else { | |||
| const r = e.currentTarget.getBoundingClientRect(); | |||
| const ox = e.clientX - r.left - r.width / 2; | |||
| const oy = e.clientY - r.top - r.height / 2; | |||
| setZoom(2); | |||
| setPan({ x: -2 * ox, y: -2 * oy }); | |||
| } | |||
| }; | |||
| const onImgPointerDown = (e: React.PointerEvent<HTMLImageElement>) => { | |||
| if (zoom <= 1) return; | |||
| dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false }; | |||
| e.currentTarget.setPointerCapture?.(e.pointerId); | |||
| }; | |||
| const onImgPointerMove = (e: React.PointerEvent<HTMLImageElement>) => { | |||
| if (!dragRef.current) return; | |||
| const dx = e.clientX - dragRef.current.sx; | |||
| const dy = e.clientY - dragRef.current.sy; | |||
| if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true; | |||
| setPan({ x: dragRef.current.px + dx, y: dragRef.current.py + dy }); | |||
| }; | |||
| const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]); | |||
| const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]); | |||
| useEffect(() => { | |||
| const onKey = (e: KeyboardEvent) => { | |||
| if (e.key === 'Escape') onClose(); | |||
| else if (e.key === 'ArrowLeft') prev(); | |||
| else if (e.key === 'ArrowRight') next(); | |||
| }; | |||
| window.addEventListener('keydown', onKey); | |||
| return () => window.removeEventListener('keydown', onKey); | |||
| }, [prev, next, onClose]); | |||
| // Pause non-current videos in fullscreen | |||
| useEffect(() => { | |||
| Object.entries(videoRefs.current).forEach(([key, vid]) => { | |||
| if (!vid) return; | |||
| const idx = parseInt(key, 10); | |||
| if (idx !== current) { vid.pause(); return; } | |||
| const item = items[idx]; | |||
| if (item && isVideoUrl(item.url) && item.autoplay) { | |||
| applyVolume(vid, item); | |||
| vid.play().catch(() => { | |||
| vid.muted = true; | |||
| vid.play().catch(() => {}); | |||
| }); | |||
| } | |||
| }); | |||
| }, [current, items]); | |||
| // Reset zoom whenever the active slide changes | |||
| useEffect(() => { | |||
| setZoom(1); | |||
| setPan({ x: 0, y: 0 }); | |||
| }, [current]); | |||
| // Wheel zoom (only on images). preventDefault requires passive: false → manual listener. | |||
| useEffect(() => { | |||
| const el = containerRef.current; | |||
| if (!el) return; | |||
| const onWheel = (e: WheelEvent) => { | |||
| const item = items[current]; | |||
| if (!item || isVideoUrl(item.url)) return; | |||
| e.preventDefault(); | |||
| const factor = 1 - e.deltaY * 0.001; | |||
| setZoom(prev => { | |||
| const next = Math.max(1, Math.min(4, prev * factor)); | |||
| if (next === 1) setPan({ x: 0, y: 0 }); | |||
| return next; | |||
| }); | |||
| }; | |||
| el.addEventListener('wheel', onWheel, { passive: false }); | |||
| return () => el.removeEventListener('wheel', onWheel); | |||
| }, [current, items]); | |||
| const onTouchStart = (e: React.TouchEvent) => { | |||
| if (zoom > 1) return; // pan via pointer events instead | |||
| touchStartX.current = e.touches[0].clientX; | |||
| }; | |||
| const onTouchEnd = (e: React.TouchEvent) => { | |||
| if (zoom > 1) return; | |||
| if (touchStartX.current === null) return; | |||
| const delta = e.changedTouches[0].clientX - touchStartX.current; | |||
| if (Math.abs(delta) > 50) delta < 0 ? next() : prev(); | |||
| touchStartX.current = null; | |||
| }; | |||
| return ( | |||
| <div | |||
| ref={containerRef} | |||
| className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200" | |||
| onTouchStart={onTouchStart} | |||
| onTouchEnd={onTouchEnd} | |||
| > | |||
| {/* Media — full resolution, contained */} | |||
| {items.map((item, i) => { | |||
| const isActive = i === current; | |||
| const video = isVideoUrl(item.url); | |||
| return ( | |||
| <div | |||
| key={item.url + i} | |||
| className={`absolute inset-0 flex items-center justify-center p-4 pb-28 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} | |||
| > | |||
| {video ? ( | |||
| <div | |||
| className="relative flex items-center justify-center max-w-full max-h-full cursor-pointer" | |||
| onClick={(e) => { e.stopPropagation(); togglePlay(i); }} | |||
| > | |||
| <video | |||
| ref={el => { videoRefs.current[i] = el; }} | |||
| src={item.url} | |||
| className="max-w-full max-h-full pointer-events-none" | |||
| playsInline | |||
| preload="metadata" | |||
| onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)} | |||
| onPlay={() => markPlaying(i)} | |||
| onPause={() => markPaused(i)} | |||
| onEnded={() => markPaused(i)} | |||
| /> | |||
| {!playing.has(i) && ( | |||
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |||
| <span className="bg-black/60 text-white w-24 h-24 rounded-full flex items-center justify-center text-5xl shadow-2xl pl-1">▶</span> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ) : ( | |||
| <img | |||
| src={item.url} | |||
| alt="" | |||
| className="max-w-full max-h-full object-contain" | |||
| draggable={false} | |||
| style={isActive ? { | |||
| transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, | |||
| cursor: zoom > 1 ? 'grab' : 'zoom-in', | |||
| transition: dragRef.current ? 'none' : 'transform 0.2s', | |||
| touchAction: zoom > 1 ? 'none' : 'auto', | |||
| willChange: 'transform', | |||
| } : undefined} | |||
| onClick={isActive ? onImgClick : undefined} | |||
| onPointerDown={isActive ? onImgPointerDown : undefined} | |||
| onPointerMove={isActive ? onImgPointerMove : undefined} | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| })} | |||
| {/* Counter — top center */} | |||
| {items.length > 1 && ( | |||
| <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/60 text-white text-sm font-semibold px-3 py-1 rounded-full z-20"> | |||
| {current + 1} / {items.length} | |||
| </div> | |||
| )} | |||
| {/* Side arrows */} | |||
| {items.length > 1 && ( | |||
| <> | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); prev(); }} | |||
| className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20" | |||
| aria-label="Previous" | |||
| >‹</button> | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); next(); }} | |||
| className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20" | |||
| aria-label="Next" | |||
| >›</button> | |||
| </> | |||
| )} | |||
| {/* Close button — bottom center, ABOVE the dots */} | |||
| <button | |||
| onClick={onClose} | |||
| className="absolute bottom-12 left-1/2 -translate-x-1/2 bg-white/90 hover:bg-white text-black px-6 py-2.5 rounded-full font-semibold shadow-2xl flex items-center gap-2 z-20 transition-transform hover:scale-105" | |||
| aria-label="Close fullscreen" | |||
| > | |||
| <span className="text-lg leading-none">✕</span> | |||
| <span>Close</span> | |||
| </button> | |||
| {/* Dots — at the very bottom */} | |||
| {items.length > 1 && ( | |||
| <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20"> | |||
| {items.map((_, i) => ( | |||
| <button | |||
| key={i} | |||
| onClick={(e) => { e.stopPropagation(); setCurrent(i); }} | |||
| className={`rounded-full transition-all duration-200 ${i === current ? 'w-6 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`} | |||
| aria-label={`Go to slide ${i + 1}`} | |||
| /> | |||
| ))} | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { | |||
| const [activeCard, setActiveCard] = useState<Card | null>(null); | |||
| const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null); | |||
| useEffect(() => { | |||
| if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden'; | |||
| else document.body.style.overflow = 'unset'; | |||
| return () => { document.body.style.overflow = 'unset'; }; | |||
| }, [activeCard, fullscreenIndex]); | |||
| const gridClasses: Record<number, string> = { | |||
| 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', | |||
| 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]; | |||
| const carouselItems: MediaItem[] = activeCard | |||
| ? [ | |||
| ...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []), | |||
| ...(activeCard.extraMedia || []), | |||
| ] | |||
| : []; | |||
| return ( | |||
| <> | |||
| <div className={`grid gap-4 ${activeGridClass}`}> | |||
| {cards.map((card) => { | |||
| const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0); | |||
| // Fall back to the first gallery item when no explicit cover is set. | |||
| const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || ''; | |||
| const previewIsVideo = !!previewUrl && isVideoUrl(previewUrl); | |||
| return ( | |||
| <div | |||
| key={card.id} | |||
| onClick={() => { | |||
| setActiveCard(card); | |||
| if (card.autoFullscreen) setFullscreenIndex(0); | |||
| }} | |||
| 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" | |||
| > | |||
| {previewUrl ? ( | |||
| previewIsVideo ? ( | |||
| <video | |||
| src={previewUrl} | |||
| className="absolute inset-0 w-full h-full object-cover pointer-events-none" | |||
| muted | |||
| playsInline | |||
| preload="metadata" | |||
| /> | |||
| ) : ( | |||
| <img src={previewUrl} 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> | |||
| )} | |||
| {galleryCount > 1 && ( | |||
| <div className="absolute top-2 left-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1 z-10"> | |||
| <span>⊞</span> | |||
| <span>{galleryCount}</span> | |||
| </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> | |||
| {activeCard && fullscreenIndex === null && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4" | |||
| onClick={() => setActiveCard(null)} | |||
| > | |||
| <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()} | |||
| > | |||
| <div className="relative"> | |||
| <MediaCarousel | |||
| items={carouselItems} | |||
| onMediaClick={(i) => setFullscreenIndex(i)} | |||
| /> | |||
| <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 z-20" | |||
| title="Close" | |||
| >✕</button> | |||
| </div> | |||
| {(activeCard.title || activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) && ( | |||
| <div className="p-8"> | |||
| <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div> | |||
| {activeCard.title && ( | |||
| <h2 className={`text-3xl font-bold text-gray-900 ${(activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) ? 'mb-4' : ''}`}> | |||
| {activeCard.title} | |||
| </h2> | |||
| )} | |||
| {activeCard.cardType === 'EXTERNAL_LINK' && (activeCard.actionUrl || activeCard.shortDescription) ? ( | |||
| <a | |||
| href={activeCard.actionUrl || activeCard.shortDescription} | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline break-all text-lg font-medium" | |||
| > | |||
| <span>{activeCard.shortDescription || activeCard.actionUrl}</span> | |||
| <span aria-hidden>↗</span> | |||
| </a> | |||
| ) : activeCard.fullContent ? ( | |||
| <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} /> | |||
| ) : activeCard.shortDescription ? ( | |||
| <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p> | |||
| ) : null} | |||
| </div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| )} | |||
| {fullscreenIndex !== null && activeCard && ( | |||
| <FullscreenViewer | |||
| items={carouselItems} | |||
| startIndex={fullscreenIndex} | |||
| onClose={() => { | |||
| setFullscreenIndex(null); | |||
| setActiveCard(null); | |||
| }} | |||
| /> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| // Feature flags compilati nel bundle. Modifica e ricostruisci (npm run build) per applicare. | |||
| export const EXTERNAL_LINK_ENABLED = true; | |||
| @@ -23,12 +23,21 @@ 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 || '[]'); | |||
| // Backward-compat: convert old string[] extraImages → MediaItem[] extraMedia | |||
| cards = cards.map(c => { | |||
| const legacy = (c as any).extraImages; | |||
| if (Array.isArray(legacy) && !c.extraMedia) { | |||
| c.extraMedia = legacy.map((url: string) => ({ url })); | |||
| delete (c as any).extraImages; | |||
| } | |||
| return c; | |||
| }); | |||
| 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); | |||
| } | |||
| @@ -1,7 +1,7 @@ | |||
| import type { NextConfig } from "next"; | |||
| const nextConfig: NextConfig = { | |||
| /* config options here */ | |||
| allowedDevOrigins: ['10.210.1.225'], | |||
| }; | |||
| export default nextConfig; | |||
| @@ -6,10 +6,13 @@ | |||
| "dev": "next dev", | |||
| "build": "next build", | |||
| "start": "next start", | |||
| "lint": "eslint" | |||
| "lint": "eslint", | |||
| "postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\"" | |||
| }, | |||
| "dependencies": { | |||
| "geist": "^1.4.2", | |||
| "next": "16.2.4", | |||
| "pdfjs-dist": "^4.7.76", | |||
| "react": "19.2.4", | |||
| "react-dom": "19.2.4" | |||
| }, | |||
| @@ -1,25 +1,24 @@ | |||
| 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 type MediaItem = { | |||
| url: string; | |||
| autoplay?: boolean; // videos only — start playing as soon as the slide is shown | |||
| muted?: boolean; // videos only — start muted (default: unmuted) | |||
| }; | |||
| export interface Card { | |||
| id: string; | |||
| portalId: string; | |||
| title: string; | |||
| imageUrl: string; | |||
| extraMedia?: MediaItem[]; | |||
| shortDescription: string; | |||
| fullContent: string; | |||
| cardType: CardType; | |||
| actionUrl?: string; | |||
| displayOrder: number; | |||
| autoFullscreen?: boolean; // open the fullscreen viewer immediately when this card is clicked | |||
| skipPreview?: boolean; // skip the modal preview AND exclude the cover image from the swipe sequence | |||
| } | |||
| export interface Portal { | |||