Resolved conflicts in components/PublicGrid.tsx and types/index.ts by taking the Sviluppo_Carrello_Immagini version — it carries the latest work (BOOK card with cardboard FlipBook, A4 spread aspect ratio, MediaItem, Portal.fontFamily and externalLinkEnabled).main
| @@ -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,44 +1,221 @@ | |||
| 'use client'; | |||
| import { useState, useEffect } from 'react'; | |||
| import { Card, Portal } from '@/types'; | |||
| import { useState, useEffect, useRef } from 'react'; | |||
| import { Card, Portal, MediaItem, CardType } from '@/types'; | |||
| import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; | |||
| function StyledSelect<T extends string>({ | |||
| value, | |||
| onChange, | |||
| options, | |||
| }: { | |||
| value: T; | |||
| onChange: (v: T) => void; | |||
| options: { value: T; label: string; style?: React.CSSProperties }[]; | |||
| }) { | |||
| const [open, setOpen] = useState(false); | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| useEffect(() => { | |||
| if (!open) return; | |||
| const onClick = (e: MouseEvent) => { | |||
| if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); | |||
| }; | |||
| const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; | |||
| document.addEventListener('mousedown', onClick); | |||
| document.addEventListener('keydown', onKey); | |||
| return () => { | |||
| document.removeEventListener('mousedown', onClick); | |||
| document.removeEventListener('keydown', onKey); | |||
| }; | |||
| }, [open]); | |||
| const current = options.find(o => o.value === value); | |||
| // Fallback: se il value non matcha nessuna opzione (es. tipo disattivato dalla flag), mostra il valore raw prettificato | |||
| const displayLabel = current?.label | |||
| ?? (typeof value === 'string' && value.length > 0 | |||
| ? value.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) | |||
| : ''); | |||
| const inputBase = "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"; | |||
| return ( | |||
| <div ref={ref} className="relative"> | |||
| <button | |||
| type="button" | |||
| onClick={() => setOpen(o => !o)} | |||
| className={`${inputBase} text-left flex items-center justify-between cursor-pointer`} | |||
| > | |||
| <span className={displayLabel ? '' : 'text-gray-400'} style={current?.style}>{displayLabel || 'Seleziona…'}</span> | |||
| <span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span> | |||
| </button> | |||
| {open && ( | |||
| <div className="absolute left-0 right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-30 overflow-hidden"> | |||
| {options.map(o => ( | |||
| <button | |||
| key={o.value} | |||
| type="button" | |||
| onClick={() => { onChange(o.value); setOpen(false); }} | |||
| className={`w-full text-left px-3 py-2.5 hover:bg-blue-50 transition-colors ${o.value === value ? 'bg-blue-100 font-semibold text-blue-700' : 'text-gray-800'}`} | |||
| style={o.style} | |||
| > | |||
| {o.label} | |||
| </button> | |||
| ))} | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|mkv|avi|divx|wmv|asf|flv|f4v|3gp|3gpp|3g2|mts|m2ts|ts|mpg|mpeg|vob|mxf|ogv|ogg'; | |||
| // Sottoinsieme di formati video davvero riproducibili dai browser moderni | |||
| const PLAYBACK_SUPPORTED_VIDEO = 'mp4|m4v|webm|mov|qt|ogv|ogg'; | |||
| const PLAYBACK_SUPPORTED_LABEL = 'MP4, M4V, WebM, MOV, OGV'; | |||
| const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url); | |||
| const isPdfFile = (file: File) => | |||
| file.type === 'application/pdf' || /\.pdf$/i.test(file.name); | |||
| const isVideoFile = (file: File) => | |||
| file.type.startsWith('video/') || new RegExp(`\\.(${VIDEO_EXTENSIONS})$`, 'i').test(file.name); | |||
| const isPlayableVideoFile = (file: File) => | |||
| new RegExp(`\\.(${PLAYBACK_SUPPORTED_VIDEO})$`, 'i').test(file.name); | |||
| const previewFontFamily = (filename: string): string => | |||
| `PortalPreview-${filename.replace(/[^A-Za-z0-9]/g, '_')}`; | |||
| const fontFormatFromName = (filename: string): string => { | |||
| const ext = filename.match(/\.([^.]+)$/)?.[1].toLowerCase() ?? 'woff2'; | |||
| return ({ woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' } as Record<string, string>)[ext] ?? 'woff2'; | |||
| }; | |||
| const extractFileName = (url: string): string => { | |||
| const match = url.match(/[?&]name=([^&]+)/); | |||
| if (match) return decodeURIComponent(match[1]); | |||
| const seg = url.split('/').pop() || 'download'; | |||
| return seg.split('?')[0]; | |||
| }; | |||
| 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 [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); | |||
| const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); | |||
| const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null); | |||
| const [availableFonts, setAvailableFonts] = useState<string[]>([]); | |||
| // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config. | |||
| const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT; | |||
| // Helper to show auto-dismissing toast | |||
| const showToast = (message: string) => { | |||
| setToast(message); | |||
| setTimeout(() => setToast(null), 3000); | |||
| const showToast = (message: string, type: 'success' | 'error' = 'success') => { | |||
| setToast({ message, type }); | |||
| setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000); | |||
| }; | |||
| useEffect(() => { | |||
| fetch('/api/cards').then(res => res.json()).then(setCards); | |||
| fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data)); | |||
| fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([])); | |||
| }, []); | |||
| 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 +226,143 @@ 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; | |||
| // Pre-filtro: scarta video con formati non riproducibili nei browser | |||
| const rejected: string[] = []; | |||
| const acceptedFiles: File[] = []; | |||
| for (const file of Array.from(files)) { | |||
| if (isVideoFile(file) && !isPlayableVideoFile(file)) { | |||
| rejected.push(file.name); | |||
| } else { | |||
| acceptedFiles.push(file); | |||
| } | |||
| } | |||
| if (rejected.length > 0) { | |||
| const list = rejected.length <= 3 | |||
| ? rejected.join(', ') | |||
| : `${rejected.slice(0, 3).join(', ')} e altri ${rejected.length - 3}`; | |||
| showToast( | |||
| `Formato non supportato! I formati supportati sono: ${PLAYBACK_SUPPORTED_LABEL}. File ignorati: ${list}`, | |||
| 'error' | |||
| ); | |||
| } | |||
| if (acceptedFiles.length === 0) { | |||
| setUploading(prev => ({ ...prev, extraMedia: false })); | |||
| e.target.value = ''; | |||
| return; | |||
| } | |||
| const uploaded: MediaItem[] = []; | |||
| for (const file of acceptedFiles) { | |||
| 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 moveExtraMedia = (index: number, direction: 'up' | 'down') => { | |||
| setIsEditing(prev => { | |||
| const items = [...(prev?.extraMedia || [])]; | |||
| if (direction === 'up' && index > 0) { | |||
| [items[index - 1], items[index]] = [items[index], items[index - 1]]; | |||
| } else if (direction === 'down' && index < items.length - 1) { | |||
| [items[index + 1], items[index]] = [items[index], items[index + 1]]; | |||
| } else { | |||
| return prev; | |||
| } | |||
| return { ...prev, extraMedia: items }; | |||
| }); | |||
| }; | |||
| 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,10 +473,23 @@ 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> | |||
| <span className="text-xs text-gray-500 uppercase tracking-wider"> | |||
| {card.cardType} | |||
| {card.extraMedia && card.extraMedia.length > 0 && ( | |||
| <span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span> | |||
| )} | |||
| </span> | |||
| </div> | |||
| </div> | |||
| @@ -222,6 +549,28 @@ export default function AdminDashboard() { | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Font del portale</label> | |||
| <style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => ` | |||
| @font-face { | |||
| font-family: '${previewFontFamily(f)}'; | |||
| src: url('/api/fonts?name=${encodeURIComponent(f)}') format('${fontFormatFromName(f)}'); | |||
| font-display: swap; | |||
| }`).join('') }} /> | |||
| <StyledSelect<string> | |||
| value={portal.fontFamily ?? ''} | |||
| onChange={(v) => setPortal({ ...portal, fontFamily: v })} | |||
| options={[ | |||
| { value: '', label: 'Sistema (Arial)' }, | |||
| ...availableFonts.map(f => ({ | |||
| value: f, | |||
| label: f.replace(/\.(woff2?|ttf|otf)$/i, ''), | |||
| style: { fontFamily: `'${previewFontFamily(f)}', Arial, Helvetica, sans-serif` }, | |||
| })), | |||
| ]} | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div className="space-y-6"> | |||
| @@ -233,6 +582,7 @@ export default function AdminDashboard() { | |||
| {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" /> | |||
| <a href={portal.logoUrl} download={extractFileName(portal.logoUrl)} className="absolute -top-2 right-6 bg-gray-700 hover:bg-gray-800 text-white w-6 h-6 rounded-full text-xs font-bold shadow flex items-center justify-center" title="Scarica" aria-label="Scarica logo">⬇</a> | |||
| <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> | |||
| )} | |||
| @@ -246,12 +596,13 @@ export default function AdminDashboard() { | |||
| {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" /> | |||
| <a href={portal.heroImageUrl} download={extractFileName(portal.heroImageUrl)} className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold shadow-lg" title="Scarica" aria-label="Scarica hero">⬇</a> | |||
| <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"> | |||
| <div className="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-3"> | |||
| <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> | |||
| @@ -259,6 +610,18 @@ export default function AdminDashboard() { | |||
| <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> | |||
| <label className="flex items-center gap-3 cursor-pointer"> | |||
| <input | |||
| type="checkbox" | |||
| checked={portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT} | |||
| onChange={e => setPortal({...portal, externalLinkEnabled: e.target.checked})} | |||
| className="w-5 h-5 text-blue-600 rounded" | |||
| /> | |||
| <div> | |||
| <span className="block text-sm font-semibold text-gray-900">Abilita “External Link”</span> | |||
| <span className="block text-xs text-gray-600">Mostra il tipo “External Link” nel dropdown del Card Type. Le card esistenti di quel tipo restano comunque visibili e cliccabili.</span> | |||
| </div> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -303,37 +666,218 @@ export default function AdminDashboard() { | |||
| </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> | |||
| <StyledSelect<CardType> | |||
| value={(isEditing.cardType || 'INFO_PAGE') as CardType} | |||
| onChange={(v) => setIsEditing({ ...isEditing, cardType: v })} | |||
| options={[ | |||
| { value: 'INFO_PAGE', label: 'Info Page' }, | |||
| { value: 'IMAGE_GALLERY', label: 'Image Gallery' }, | |||
| { value: 'BOOK', label: 'Flip-Book' }, | |||
| ...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []), | |||
| ]} | |||
| /> | |||
| </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 className="bg-gray-50 p-3 rounded-lg border border-gray-200"> | |||
| <label className="flex items-start gap-3 cursor-pointer"> | |||
| <input | |||
| type="checkbox" | |||
| checked={!!isEditing.redirectOnClick} | |||
| onChange={e => setIsEditing({ ...isEditing, redirectOnClick: 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">Redirect on click</span> | |||
| <span className="block text-xs text-gray-600">Cliccando la card sul portale pubblico, il browser apre direttamente l’URL senza mostrare il modale.</span> | |||
| </div> | |||
| </label> | |||
| </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> | |||
| )} | |||
| {isEditing.cardType !== 'BOOK' && ( | |||
| <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">Cover not in the gallery</span> | |||
| <span className="block text-xs text-gray-600">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" /> | |||
| <a | |||
| href={isEditing.imageUrl} | |||
| download={extractFileName(isEditing.imageUrl)} | |||
| className="absolute top-2 right-12 bg-gray-700 hover:bg-gray-800 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center" | |||
| title="Scarica" | |||
| aria-label="Scarica cover" | |||
| >⬇</a> | |||
| <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) — nascosta per INFO_PAGE (solo cover ammessa) */} | |||
| {isEditing.cardType !== 'INFO_PAGE' && ( | |||
| <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 images)</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,.mov,.qt,.mkv,.avi,.divx,.wmv,.asf,.flv,.f4v,.3gp,.3gpp,.3g2,.mts,.m2ts,.ts,.mpg,.mpeg,.vob,.mxf,.ogv,.ogg" | |||
| 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={() => moveExtraMedia(i, 'up')} | |||
| className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded shrink-0 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:outline-none" | |||
| title="Sposta su" | |||
| aria-label="Sposta su" | |||
| disabled={i === 0} | |||
| >↑</button> | |||
| <button | |||
| onClick={() => moveExtraMedia(i, 'down')} | |||
| className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded shrink-0 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:outline-none" | |||
| title="Sposta giù" | |||
| aria-label="Sposta giù" | |||
| disabled={i === (isEditing.extraMedia || []).length - 1} | |||
| >↓</button> | |||
| <a | |||
| href={item.url} | |||
| download={extractFileName(item.url)} | |||
| className="bg-gray-500 hover:bg-gray-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0 flex items-center justify-center" | |||
| title="Scarica" | |||
| aria-label="Scarica" | |||
| >⬇</a> | |||
| <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> | |||
| )} | |||
| </div> | |||
| </div> | |||
| @@ -375,11 +919,15 @@ export default function AdminDashboard() { | |||
| {/* 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 className={`fixed bottom-6 right-6 z-[70] text-white px-6 py-4 rounded-lg shadow-2xl flex items-start gap-3 animate-in slide-in-from-bottom-5 fade-in duration-300 max-w-md ${ | |||
| toast.type === 'error' ? 'bg-red-700' : 'bg-gray-900' | |||
| }`}> | |||
| <div className={`w-6 h-6 rounded-full flex items-center justify-center font-bold text-sm shrink-0 mt-0.5 ${ | |||
| toast.type === 'error' ? 'bg-white text-red-700' : 'bg-green-500 text-gray-900' | |||
| }`}> | |||
| {toast.type === 'error' ? '!' : '✓'} | |||
| </div> | |||
| <span className="font-medium">{toast}</span> | |||
| <span className="font-medium leading-snug">{toast.message}</span> | |||
| </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', | |||
| }, | |||
| }); | |||
| } | |||
| @@ -0,0 +1,58 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import fs from 'fs'; | |||
| import path from 'path'; | |||
| export const dynamic = 'force-dynamic'; | |||
| const MIME: Record<string, string> = { | |||
| '.woff2': 'font/woff2', | |||
| '.woff': 'font/woff', | |||
| '.ttf': 'font/ttf', | |||
| '.otf': 'font/otf', | |||
| }; | |||
| const FONTS_DIR = path.join(process.cwd(), 'data', 'fonts'); | |||
| function ensureFontsDir() { | |||
| try { fs.mkdirSync(FONTS_DIR, { recursive: true }); } catch {} | |||
| } | |||
| export async function GET(request: Request) { | |||
| const { searchParams } = new URL(request.url); | |||
| const name = searchParams.get('name'); | |||
| if (name) { | |||
| // Serve un singolo file font | |||
| const safeName = path.basename(name); // protezione path traversal | |||
| const ext = path.extname(safeName).toLowerCase(); | |||
| const mimeType = MIME[ext]; | |||
| if (!mimeType) return new NextResponse('Unsupported format', { status: 400 }); | |||
| const filePath = path.join(FONTS_DIR, safeName); | |||
| try { | |||
| const buffer = fs.readFileSync(filePath); | |||
| return new NextResponse(buffer, { | |||
| headers: { | |||
| 'Content-Type': mimeType, | |||
| 'Cache-Control': 'public, max-age=31536000, immutable', | |||
| 'Access-Control-Allow-Origin': '*', | |||
| }, | |||
| }); | |||
| } catch { | |||
| return new NextResponse('Font not found', { status: 404 }); | |||
| } | |||
| } | |||
| // List mode: ritorna i font "regular" (non-italic) presenti | |||
| ensureFontsDir(); | |||
| try { | |||
| const files = fs.readdirSync(FONTS_DIR); | |||
| const list = files | |||
| .filter(f => /\.(woff2?|ttf|otf)$/i.test(f)) | |||
| .filter(f => !/italic|bold/i.test(f)) | |||
| .sort(); | |||
| return NextResponse.json(list); | |||
| } catch { | |||
| return NextResponse.json([]); | |||
| } | |||
| } | |||
| @@ -22,10 +22,10 @@ export async function POST(request: Request) { | |||
| } | |||
| await savePortals(portals); | |||
| // ADD THIS LINE | |||
| revalidatePath('/'); | |||
| // Rivalida sia la home che il layout (il font @font-face è applicato nel layout root) | |||
| revalidatePath('/', 'layout'); | |||
| return NextResponse.json(portals[0], { status: 200 }); | |||
| } catch (error) { | |||
| return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | |||
| @@ -8,7 +8,7 @@ | |||
| @theme inline { | |||
| --color-background: var(--background); | |||
| --color-foreground: var(--foreground); | |||
| --font-sans: var(--font-geist-sans); | |||
| --font-sans: var(--font-portal, var(--font-geist-sans)); | |||
| --font-mono: var(--font-geist-mono); | |||
| } | |||
| @@ -22,5 +22,14 @@ | |||
| body { | |||
| background: var(--background); | |||
| color: var(--foreground); | |||
| font-family: Arial, Helvetica, sans-serif; | |||
| font-family: var(--font-portal, Arial, Helvetica, sans-serif); | |||
| } | |||
| /* Force native form controls to inherit the page font (Firefox/Chromium use the OS UI font for <option> otherwise). */ | |||
| select, | |||
| option, | |||
| input, | |||
| textarea, | |||
| button { | |||
| font-family: inherit; | |||
| } | |||
| @@ -1,33 +1,92 @@ | |||
| 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 fs from "fs/promises"; | |||
| import path from "path"; | |||
| import { getPortals } from "@/lib/db"; | |||
| import { DEFAULT_FONT } from "@/lib/config"; | |||
| 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({ | |||
| function fontFormat(ext: string): string { | |||
| switch (ext.toLowerCase()) { | |||
| case ".woff2": return "woff2"; | |||
| case ".woff": return "woff"; | |||
| case ".ttf": return "truetype"; | |||
| case ".otf": return "opentype"; | |||
| default: return "woff2"; | |||
| } | |||
| } | |||
| async function findSibling(regularFilename: string, suffix: string): Promise<string | null> { | |||
| const m = regularFilename.match(/^(.*)(\.(?:woff2?|ttf|otf))$/i); | |||
| if (!m) return null; | |||
| const [, base, ext] = m; | |||
| const lower = suffix.toLowerCase(); | |||
| const candidates = [ | |||
| `${base}-${suffix}${ext}`, `${base}-${lower}${ext}`, | |||
| `${base}_${suffix}${ext}`, `${base}_${lower}${ext}`, | |||
| `${base} ${suffix}${ext}`, `${base} ${lower}${ext}`, | |||
| `${base}${suffix}${ext}`, `${base}${lower}${ext}`, | |||
| ]; | |||
| try { | |||
| const files = await fs.readdir(path.join(process.cwd(), "data", "fonts")); | |||
| for (const c of candidates) { | |||
| if (files.includes(c)) return c; | |||
| } | |||
| } catch {} | |||
| return null; | |||
| } | |||
| export default async function RootLayout({ | |||
| children, | |||
| }: Readonly<{ | |||
| children: React.ReactNode; | |||
| }>) { | |||
| }: Readonly<{ children: React.ReactNode }>) { | |||
| // Leggi il portale per scegliere il font | |||
| let chosenFont = DEFAULT_FONT; | |||
| try { | |||
| const portals = await getPortals(); | |||
| const portal = portals[0]; | |||
| if (portal && portal.fontFamily !== undefined) chosenFont = portal.fontFamily; | |||
| } catch {} | |||
| let fontStyleCss = ""; | |||
| if (chosenFont) { | |||
| const fontUrl = (name: string) => `/api/fonts?name=${encodeURIComponent(name)}`; | |||
| const faceBlock = (file: string, weight: 400 | 700, style: 'normal' | 'italic') => ` | |||
| @font-face { | |||
| font-family: 'PortalFont'; | |||
| src: url('${fontUrl(file)}') format('${fontFormat(path.extname(file))}'); | |||
| font-style: ${style}; | |||
| font-weight: ${weight}; | |||
| font-display: swap; | |||
| }`; | |||
| const italicFile = await findSibling(chosenFont, 'Italic'); | |||
| const boldFile = await findSibling(chosenFont, 'Bold'); | |||
| const boldItalicFile = await findSibling(chosenFont, 'BoldItalic'); | |||
| fontStyleCss = [ | |||
| faceBlock(chosenFont, 400, 'normal'), | |||
| italicFile ? faceBlock(italicFile, 400, 'italic') : '', | |||
| boldFile ? faceBlock(boldFile, 700, 'normal') : '', | |||
| boldItalicFile ? faceBlock(boldItalicFile, 700, 'italic') : '', | |||
| `\n:root { --font-portal: 'PortalFont', Arial, Helvetica, sans-serif; }\n`, | |||
| ].join(''); | |||
| } | |||
| 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> | |||
| <body className="min-h-full flex flex-col"> | |||
| {fontStyleCss && <style dangerouslySetInnerHTML={{ __html: fontStyleCss }} />} | |||
| {children} | |||
| </body> | |||
| </html> | |||
| ); | |||
| } | |||
| @@ -421,44 +421,11 @@ function FullscreenViewer({ | |||
| ); | |||
| } | |||
| function playFlipSound(ctx: AudioContext | null) { | |||
| if (!ctx) return; | |||
| const duration = 0.28; | |||
| const sr = ctx.sampleRate; | |||
| const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); | |||
| const data = buffer.getChannelData(0); | |||
| for (let i = 0; i < data.length; i++) { | |||
| const t = i / sr; | |||
| const envelope = Math.exp(-t * 14) * (1 - Math.exp(-t * 80)); | |||
| data[i] = (Math.random() * 2 - 1) * envelope * 0.45; | |||
| } | |||
| const src = ctx.createBufferSource(); | |||
| src.buffer = buffer; | |||
| const bp = ctx.createBiquadFilter(); | |||
| bp.type = 'bandpass'; | |||
| bp.frequency.value = 2200; | |||
| bp.Q.value = 0.9; | |||
| const gain = ctx.createGain(); | |||
| gain.gain.value = 0.6; | |||
| src.connect(bp); bp.connect(gain); gain.connect(ctx.destination); | |||
| src.start(); | |||
| } | |||
| function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) { | |||
| const [spread, setSpread] = useState(0); | |||
| const [flipping, setFlipping] = useState<'forward' | 'backward' | null>(null); | |||
| const audioRef = useRef<AudioContext | null>(null); | |||
| const dragStartX = useRef<number | null>(null); | |||
| const getCtx = () => { | |||
| if (!audioRef.current) { | |||
| const Ctx = (window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).AudioContext | |||
| ?? (window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).webkitAudioContext; | |||
| if (Ctx) audioRef.current = new Ctx(); | |||
| } | |||
| return audioRef.current; | |||
| }; | |||
| const totalSpreads = Math.max(1, Math.ceil(pages.length / 2)); | |||
| const getPage = (i: number) => (i >= 0 && i < pages.length ? pages[i] : ''); | |||
| @@ -471,16 +438,14 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| const goNext = useCallback(() => { | |||
| if (flipping !== null || spread >= totalSpreads - 1) return; | |||
| playFlipSound(getCtx()); | |||
| setFlipping('forward'); | |||
| window.setTimeout(() => { setSpread(s => s + 1); setFlipping(null); }, 750); | |||
| window.setTimeout(() => { setSpread(s => s + 1); setFlipping(null); }, 700); | |||
| }, [flipping, spread, totalSpreads]); | |||
| const goPrev = useCallback(() => { | |||
| if (flipping !== null || spread <= 0) return; | |||
| playFlipSound(getCtx()); | |||
| setFlipping('backward'); | |||
| window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 750); | |||
| window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 700); | |||
| }, [flipping, spread]); | |||
| useEffect(() => { | |||
| @@ -502,7 +467,6 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| }; | |||
| const onPointerCancel = () => { dragStartX.current = null; }; | |||
| // Pages visible underneath the flipping overlay | |||
| const visibleLeft = flipping === 'backward' ? getPage(prevLeftIdx) : getPage(leftIdx); | |||
| const visibleRight = flipping === 'forward' ? getPage(nextRightIdx) : getPage(rightIdx); | |||
| @@ -512,7 +476,6 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| ? `${currentPageNum} / ${pages.length}` | |||
| : `${currentPageNum}-${lastPageNum} / ${pages.length}`; | |||
| const pageBoxClass = 'absolute inset-0 bg-white overflow-hidden'; | |||
| const pageImgClass = 'w-full h-full object-contain'; | |||
| return ( | |||
| @@ -526,8 +489,6 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| <style dangerouslySetInnerHTML={{ __html: ` | |||
| @keyframes flipBookForward { from { transform: rotateY(0deg); } to { transform: rotateY(-180deg); } } | |||
| @keyframes flipBookBackward { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } } | |||
| @keyframes flipBookShadowIn { 0% { opacity: 0; } 100% { opacity: 1; } } | |||
| @keyframes flipBookShadowOut { 0% { opacity: 1; } 100% { opacity: 0; } } | |||
| `}} /> | |||
| <button | |||
| @@ -540,43 +501,26 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| <div | |||
| className="relative shadow-2xl" | |||
| style={{ | |||
| width: 'min(95vw, 1400px)', | |||
| height: 'min(85vh, 900px)', | |||
| // Two A4 pages side-by-side: aspect ratio ≈ 1.41:1 (2 × 0.707). | |||
| // Cap by 95vw and 90vh so the book never overflows. | |||
| width: 'min(95vw, calc(90vh * 1.41))', | |||
| aspectRatio: '1.41 / 1', | |||
| maxHeight: '90vh', | |||
| transformStyle: 'preserve-3d', | |||
| }} | |||
| > | |||
| {/* Static left page */} | |||
| {visibleLeft ? ( | |||
| <div className="absolute left-0 top-0 w-1/2 h-full bg-white overflow-hidden"> | |||
| {visibleLeft && ( | |||
| <div className="absolute left-0 top-0 w-1/2 h-full bg-white overflow-hidden flex items-center justify-center"> | |||
| <img src={visibleLeft} className={pageImgClass} alt="" draggable={false} /> | |||
| </div> | |||
| ) : ( | |||
| <div | |||
| className="absolute left-0 top-0 w-1/2 h-full overflow-hidden" | |||
| style={{ | |||
| background: 'linear-gradient(135deg, #4a3826 0%, #2e2114 55%, #1a1108 100%)', | |||
| boxShadow: 'inset 0 0 80px rgba(0,0,0,0.55)', | |||
| }} | |||
| aria-hidden | |||
| /> | |||
| )} | |||
| {/* Static right page */} | |||
| {visibleRight ? ( | |||
| <div className="absolute right-0 top-0 w-1/2 h-full bg-white overflow-hidden"> | |||
| {visibleRight && ( | |||
| <div className="absolute right-0 top-0 w-1/2 h-full bg-white overflow-hidden flex items-center justify-center"> | |||
| <img src={visibleRight} className={pageImgClass} alt="" draggable={false} /> | |||
| </div> | |||
| ) : ( | |||
| <div | |||
| className="absolute right-0 top-0 w-1/2 h-full overflow-hidden" | |||
| style={{ | |||
| background: 'linear-gradient(225deg, #4a3826 0%, #2e2114 55%, #1a1108 100%)', | |||
| boxShadow: 'inset 0 0 80px rgba(0,0,0,0.55)', | |||
| }} | |||
| aria-hidden | |||
| /> | |||
| )} | |||
| {/* Spine */} | |||
| <div className="absolute left-1/2 top-0 -translate-x-1/2 w-2 h-full bg-gradient-to-r from-black/40 via-black/20 to-black/40 z-10 pointer-events-none" /> | |||
| {/* Flipping overlay — forward (right page rotates left) */} | |||
| {flipping === 'forward' && ( | |||
| @@ -585,24 +529,15 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| style={{ | |||
| transformOrigin: 'left center', | |||
| transformStyle: 'preserve-3d', | |||
| animation: 'flipBookForward 750ms cubic-bezier(0.4, 0.0, 0.35, 1) forwards', | |||
| filter: 'drop-shadow(0 10px 18px rgba(0,0,0,0.55))', | |||
| animation: 'flipBookForward 700ms ease-in-out forwards', | |||
| zIndex: 20, | |||
| }} | |||
| > | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden' }}> | |||
| <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden' }}> | |||
| {getPage(rightIdx) && <img src={getPage(rightIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(90deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowIn 750ms ease-out forwards', | |||
| }} /> | |||
| </div> | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> | |||
| <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> | |||
| {getPage(nextLeftIdx) && <img src={getPage(nextLeftIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(270deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowOut 750ms ease-in forwards', | |||
| }} /> | |||
| </div> | |||
| </div> | |||
| )} | |||
| @@ -614,24 +549,15 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) | |||
| style={{ | |||
| transformOrigin: 'right center', | |||
| transformStyle: 'preserve-3d', | |||
| animation: 'flipBookBackward 750ms cubic-bezier(0.4, 0.0, 0.35, 1) forwards', | |||
| filter: 'drop-shadow(0 10px 18px rgba(0,0,0,0.55))', | |||
| animation: 'flipBookBackward 700ms ease-in-out forwards', | |||
| zIndex: 20, | |||
| }} | |||
| > | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden' }}> | |||
| <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden' }}> | |||
| {getPage(leftIdx) && <img src={getPage(leftIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(270deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowIn 750ms ease-out forwards', | |||
| }} /> | |||
| </div> | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> | |||
| <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> | |||
| {getPage(prevRightIdx) && <img src={getPage(prevRightIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(90deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowOut 750ms ease-in forwards', | |||
| }} /> | |||
| </div> | |||
| </div> | |||
| )} | |||
| @@ -0,0 +1,8 @@ | |||
| // Feature flags compilati nel bundle. Modifica e ricostruisci (npm run build) per applicare. | |||
| export const EXTERNAL_LINK_ENABLED = true; | |||
| // Font di default se il portale non ne ha impostato uno. | |||
| // Lascia stringa vuota per usare il font di sistema (Arial). | |||
| // Per usare un font, scrivi il nome esatto del file presente in data/fonts/ (es. "Geist-Variable.woff2"). | |||
| export const DEFAULT_FONT = ''; | |||
| @@ -6,11 +6,13 @@ 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 | |||
| // Helper to ensure files/folders 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, '[]'); } | |||
| const FONTS_DIR = path.join(DATA_DIR, 'fonts'); | |||
| try { await fs.access(FONTS_DIR); } catch { await fs.mkdir(FONTS_DIR, { recursive: true }); } | |||
| } | |||
| export async function getPortals(): Promise<Portal[]> { | |||
| @@ -23,12 +25,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; | |||
| @@ -7,8 +7,11 @@ | |||
| "": { | |||
| "name": "captive-portal-cms", | |||
| "version": "0.1.0", | |||
| "hasInstallScript": true, | |||
| "dependencies": { | |||
| "geist": "^1.4.2", | |||
| "next": "16.2.4", | |||
| "pdfjs-dist": "^4.7.76", | |||
| "react": "19.2.4", | |||
| "react-dom": "19.2.4" | |||
| }, | |||
| @@ -67,7 +70,6 @@ | |||
| "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "@babel/code-frame": "^7.29.0", | |||
| "@babel/generator": "^7.29.0", | |||
| @@ -1022,6 +1024,256 @@ | |||
| "@jridgewell/sourcemap-codec": "^1.4.14" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", | |||
| "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "workspaces": [ | |||
| "e2e/*" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| }, | |||
| "optionalDependencies": { | |||
| "@napi-rs/canvas-android-arm64": "0.1.100", | |||
| "@napi-rs/canvas-darwin-arm64": "0.1.100", | |||
| "@napi-rs/canvas-darwin-x64": "0.1.100", | |||
| "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", | |||
| "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", | |||
| "@napi-rs/canvas-linux-arm64-musl": "0.1.100", | |||
| "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", | |||
| "@napi-rs/canvas-linux-x64-gnu": "0.1.100", | |||
| "@napi-rs/canvas-linux-x64-musl": "0.1.100", | |||
| "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", | |||
| "@napi-rs/canvas-win32-x64-msvc": "0.1.100" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-android-arm64": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", | |||
| "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", | |||
| "cpu": [ | |||
| "arm64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "android" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-darwin-arm64": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", | |||
| "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", | |||
| "cpu": [ | |||
| "arm64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "darwin" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-darwin-x64": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", | |||
| "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", | |||
| "cpu": [ | |||
| "x64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "darwin" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", | |||
| "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", | |||
| "cpu": [ | |||
| "arm" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "linux" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-linux-arm64-gnu": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", | |||
| "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", | |||
| "cpu": [ | |||
| "arm64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "linux" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-linux-arm64-musl": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", | |||
| "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", | |||
| "cpu": [ | |||
| "arm64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "linux" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", | |||
| "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", | |||
| "cpu": [ | |||
| "riscv64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "linux" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-linux-x64-gnu": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", | |||
| "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", | |||
| "cpu": [ | |||
| "x64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "linux" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-linux-x64-musl": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", | |||
| "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", | |||
| "cpu": [ | |||
| "x64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "linux" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-win32-arm64-msvc": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", | |||
| "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", | |||
| "cpu": [ | |||
| "arm64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "win32" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/canvas-win32-x64-msvc": { | |||
| "version": "0.1.100", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", | |||
| "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", | |||
| "cpu": [ | |||
| "x64" | |||
| ], | |||
| "license": "MIT", | |||
| "optional": true, | |||
| "os": [ | |||
| "win32" | |||
| ], | |||
| "engines": { | |||
| "node": ">= 10" | |||
| }, | |||
| "funding": { | |||
| "type": "github", | |||
| "url": "https://github.com/sponsors/Brooooooklyn" | |||
| } | |||
| }, | |||
| "node_modules/@napi-rs/wasm-runtime": { | |||
| "version": "0.2.12", | |||
| "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", | |||
| @@ -1562,7 +1814,6 @@ | |||
| "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "csstype": "^3.2.2" | |||
| } | |||
| @@ -1622,7 +1873,6 @@ | |||
| "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "@typescript-eslint/scope-manager": "8.58.2", | |||
| "@typescript-eslint/types": "8.58.2", | |||
| @@ -2148,7 +2398,6 @@ | |||
| "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "bin": { | |||
| "acorn": "bin/acorn" | |||
| }, | |||
| @@ -2492,7 +2741,6 @@ | |||
| } | |||
| ], | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "baseline-browser-mapping": "^2.10.12", | |||
| "caniuse-lite": "^1.0.30001782", | |||
| @@ -3060,7 +3308,6 @@ | |||
| "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "@eslint-community/eslint-utils": "^4.8.0", | |||
| "@eslint-community/regexpp": "^4.12.1", | |||
| @@ -3643,6 +3890,15 @@ | |||
| "url": "https://github.com/sponsors/ljharb" | |||
| } | |||
| }, | |||
| "node_modules/geist": { | |||
| "version": "1.7.0", | |||
| "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.0.tgz", | |||
| "integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==", | |||
| "license": "SIL OPEN FONT LICENSE", | |||
| "peerDependencies": { | |||
| "next": ">=13.2.0" | |||
| } | |||
| }, | |||
| "node_modules/generator-function": { | |||
| "version": "2.0.1", | |||
| "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", | |||
| @@ -5303,6 +5559,18 @@ | |||
| "dev": true, | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/pdfjs-dist": { | |||
| "version": "4.10.38", | |||
| "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", | |||
| "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==", | |||
| "license": "Apache-2.0", | |||
| "engines": { | |||
| "node": ">=20" | |||
| }, | |||
| "optionalDependencies": { | |||
| "@napi-rs/canvas": "^0.1.65" | |||
| } | |||
| }, | |||
| "node_modules/picocolors": { | |||
| "version": "1.1.1", | |||
| "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |||
| @@ -5419,7 +5687,6 @@ | |||
| "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", | |||
| "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "engines": { | |||
| "node": ">=0.10.0" | |||
| } | |||
| @@ -5429,7 +5696,6 @@ | |||
| "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", | |||
| "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "scheduler": "^0.27.0" | |||
| }, | |||
| @@ -6121,7 +6387,6 @@ | |||
| "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "engines": { | |||
| "node": ">=12" | |||
| }, | |||
| @@ -6284,7 +6549,6 @@ | |||
| "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | |||
| "dev": true, | |||
| "license": "Apache-2.0", | |||
| "peer": true, | |||
| "bin": { | |||
| "tsc": "bin/tsc", | |||
| "tsserver": "bin/tsserver" | |||
| @@ -6560,7 +6824,6 @@ | |||
| "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "peer": true, | |||
| "funding": { | |||
| "url": "https://github.com/sponsors/colinhacks" | |||
| } | |||
| @@ -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" | |||
| }, | |||
| @@ -0,0 +1,50 @@ | |||
| $ErrorActionPreference = 'Stop' | |||
| $ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" | |||
| $fontsDir = Join-Path $PSScriptRoot "..\data\fonts" | |||
| $fontsDir = (Resolve-Path $fontsDir).Path | |||
| $families = @( | |||
| @{ name = 'Inter'; query = 'Inter' }, | |||
| @{ name = 'Lora'; query = 'Lora' }, | |||
| @{ name = 'Merriweather'; query = 'Merriweather' }, | |||
| @{ name = 'OpenSans'; query = 'Open+Sans' }, | |||
| @{ name = 'PlayfairDisplay'; query = 'Playfair+Display' }, | |||
| @{ name = 'Roboto'; query = 'Roboto' }, | |||
| @{ name = 'WorkSans'; query = 'Work+Sans' } | |||
| ) | |||
| foreach ($f in $families) { | |||
| $cssUrl = "https://fonts.googleapis.com/css2?family=$($f.query):ital,wght@0,400;0,700;1,400;1,700&display=swap" | |||
| Write-Host "=== $($f.name) ===" -ForegroundColor Cyan | |||
| Write-Host "CSS: $cssUrl" | |||
| $css = (Invoke-WebRequest -Uri $cssUrl -UserAgent $ua -UseBasicParsing).Content | |||
| $pattern = '/\*\s*latin\s*\*/\s*@font-face\s*\{([^}]+)\}' | |||
| $matches = [regex]::Matches($css, $pattern) | |||
| Write-Host "Found $($matches.Count) latin @font-face blocks" | |||
| foreach ($m in $matches) { | |||
| $block = $m.Groups[1].Value | |||
| $weight = [regex]::Match($block, 'font-weight:\s*(\d+)').Groups[1].Value | |||
| $style = [regex]::Match($block, 'font-style:\s*(\w+)').Groups[1].Value | |||
| $url = [regex]::Match($block, 'url\(([^)]+\.woff2)\)').Groups[1].Value | |||
| if (-not $url) { Write-Host " skip block: no woff2 url"; continue } | |||
| $suffix = '' | |||
| if ($style -eq 'italic' -and $weight -eq '700') { $suffix = '-BoldItalic' } | |||
| elseif ($style -eq 'italic') { $suffix = '-Italic' } | |||
| elseif ($weight -eq '700') { $suffix = '-Bold' } | |||
| $outFile = Join-Path $fontsDir "$($f.name)$suffix.woff2" | |||
| Invoke-WebRequest -Uri $url -OutFile $outFile -UserAgent $ua -UseBasicParsing | |||
| $size = (Get-Item $outFile).Length | |||
| Write-Host (" -> {0} ({1} bytes)" -f (Split-Path $outFile -Leaf), $size) | |||
| } | |||
| } | |||
| Write-Host "`nDone." -ForegroundColor Green | |||
| Write-Host "Files in ${fontsDir}:" | |||
| Get-ChildItem $fontsDir -Filter '*.woff2' | Sort-Object Name | ForEach-Object { Write-Host (" {0,-30} {1,8} bytes" -f $_.Name, $_.Length) } | |||
| @@ -2,8 +2,8 @@ export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVIC | |||
| export type MediaItem = { | |||
| url: string; | |||
| autoplay?: boolean; | |||
| muted?: boolean; | |||
| autoplay?: boolean; // videos only — start playing as soon as the slide is shown | |||
| muted?: boolean; // videos only — start muted (default: unmuted) | |||
| }; | |||
| export interface Card { | |||
| @@ -17,9 +17,9 @@ export interface Card { | |||
| cardType: CardType; | |||
| actionUrl?: string; | |||
| displayOrder: number; | |||
| autoFullscreen?: boolean; | |||
| skipPreview?: boolean; | |||
| redirectOnClick?: boolean; | |||
| 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 | |||
| redirectOnClick?: boolean; // EXTERNAL_LINK only — click card to open the URL directly without showing the modal | |||
| } | |||
| export interface Portal { | |||
| @@ -32,4 +32,6 @@ export interface Portal { | |||
| themeColor: string; | |||
| fadeHeroImage?: boolean; | |||
| maxGridColumns?: number; | |||
| } | |||
| externalLinkEnabled?: boolean; // se false, l'admin nasconde il tipo "External Link" nel dropdown | |||
| fontFamily?: string; // nome del file in data/fonts/ (es. "Geist-Variable.woff2"). "" o undefined = Sistema (Arial) | |||
| } | |||