Quellcode durchsuchen

Merge branch 'Sviluppo_Carrello_Immagini'

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
Lorenzo Pollutri vor 1 Monat
Ursprung
Commit
9673e08d31
43 geänderte Dateien mit 1204 neuen und 213 gelöschten Zeilen
  1. +5
    -0
      README.md
  2. +586
    -38
      app/admin/page.tsx
  3. +84
    -35
      app/api/files/route.ts
  4. +58
    -0
      app/api/fonts/route.ts
  5. +4
    -4
      app/api/portals/route.ts
  6. +11
    -2
      app/globals.css
  7. +79
    -20
      app/layout.tsx
  8. +17
    -91
      components/PublicGrid.tsx
  9. BIN
      data/fonts/Inter-Bold.woff2
  10. BIN
      data/fonts/Inter-BoldItalic.woff2
  11. BIN
      data/fonts/Inter-Italic.woff2
  12. BIN
      data/fonts/Inter.woff2
  13. BIN
      data/fonts/Lora-Bold.woff2
  14. BIN
      data/fonts/Lora-BoldItalic.woff2
  15. BIN
      data/fonts/Lora-Italic.woff2
  16. BIN
      data/fonts/Lora.woff2
  17. BIN
      data/fonts/Merriweather-Bold.woff2
  18. BIN
      data/fonts/Merriweather-BoldItalic.woff2
  19. BIN
      data/fonts/Merriweather-Italic.woff2
  20. BIN
      data/fonts/Merriweather.woff2
  21. BIN
      data/fonts/OpenSans-Bold.woff2
  22. BIN
      data/fonts/OpenSans-BoldItalic.woff2
  23. BIN
      data/fonts/OpenSans-Italic.woff2
  24. BIN
      data/fonts/OpenSans.woff2
  25. BIN
      data/fonts/PlayfairDisplay-Bold.woff2
  26. BIN
      data/fonts/PlayfairDisplay-BoldItalic.woff2
  27. BIN
      data/fonts/PlayfairDisplay-Italic.woff2
  28. BIN
      data/fonts/PlayfairDisplay.woff2
  29. BIN
      data/fonts/Roboto-Bold.woff2
  30. BIN
      data/fonts/Roboto-BoldItalic.woff2
  31. BIN
      data/fonts/Roboto-Italic.woff2
  32. BIN
      data/fonts/Roboto.woff2
  33. BIN
      data/fonts/WorkSans-Bold.woff2
  34. BIN
      data/fonts/WorkSans-BoldItalic.woff2
  35. BIN
      data/fonts/WorkSans-Italic.woff2
  36. BIN
      data/fonts/WorkSans.woff2
  37. +8
    -0
      lib/config.ts
  38. +15
    -4
      lib/db.ts
  39. +1
    -1
      next.config.ts
  40. +274
    -11
      package-lock.json
  41. +4
    -1
      package.json
  42. +50
    -0
      scripts/download-fonts.ps1
  43. +8
    -6
      types/index.ts

+ 5
- 0
README.md Datei anzeigen

@@ -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;

+ 586
- 38
app/admin/page.tsx Datei anzeigen

@@ -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 &ldquo;External Link&rdquo;</span>
<span className="block text-xs text-gray-600">Mostra il tipo &ldquo;External Link&rdquo; 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&rsquo;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&rsquo;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 &ldquo;Auto fullscreen&rdquo; 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 &ldquo;{pdfProgress.name}&rdquo;: 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>
)}


+ 84
- 35
app/api/files/route.ts Datei anzeigen

@@ -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',
},
});
}

+ 58
- 0
app/api/fonts/route.ts Datei anzeigen

@@ -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([]);
}
}

+ 4
- 4
app/api/portals/route.ts Datei anzeigen

@@ -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 });


+ 11
- 2
app/globals.css Datei anzeigen

@@ -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;
}

+ 79
- 20
app/layout.tsx Datei anzeigen

@@ -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>
);
}

+ 17
- 91
components/PublicGrid.tsx Datei anzeigen

@@ -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>
)}


BIN
data/fonts/Inter-Bold.woff2 Datei anzeigen


BIN
data/fonts/Inter-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/Inter-Italic.woff2 Datei anzeigen


BIN
data/fonts/Inter.woff2 Datei anzeigen


BIN
data/fonts/Lora-Bold.woff2 Datei anzeigen


BIN
data/fonts/Lora-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/Lora-Italic.woff2 Datei anzeigen


BIN
data/fonts/Lora.woff2 Datei anzeigen


BIN
data/fonts/Merriweather-Bold.woff2 Datei anzeigen


BIN
data/fonts/Merriweather-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/Merriweather-Italic.woff2 Datei anzeigen


BIN
data/fonts/Merriweather.woff2 Datei anzeigen


BIN
data/fonts/OpenSans-Bold.woff2 Datei anzeigen


BIN
data/fonts/OpenSans-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/OpenSans-Italic.woff2 Datei anzeigen


BIN
data/fonts/OpenSans.woff2 Datei anzeigen


BIN
data/fonts/PlayfairDisplay-Bold.woff2 Datei anzeigen


BIN
data/fonts/PlayfairDisplay-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/PlayfairDisplay-Italic.woff2 Datei anzeigen


BIN
data/fonts/PlayfairDisplay.woff2 Datei anzeigen


BIN
data/fonts/Roboto-Bold.woff2 Datei anzeigen


BIN
data/fonts/Roboto-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/Roboto-Italic.woff2 Datei anzeigen


BIN
data/fonts/Roboto.woff2 Datei anzeigen


BIN
data/fonts/WorkSans-Bold.woff2 Datei anzeigen


BIN
data/fonts/WorkSans-BoldItalic.woff2 Datei anzeigen


BIN
data/fonts/WorkSans-Italic.woff2 Datei anzeigen


BIN
data/fonts/WorkSans.woff2 Datei anzeigen


+ 8
- 0
lib/config.ts Datei anzeigen

@@ -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 = '';

+ 15
- 4
lib/db.ts Datei anzeigen

@@ -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
- 1
next.config.ts Datei anzeigen

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ['10.210.1.225'],
};

export default nextConfig;

+ 274
- 11
package-lock.json Datei anzeigen

@@ -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"
}


+ 4
- 1
package.json Datei anzeigen

@@ -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"
},


+ 50
- 0
scripts/download-fonts.ps1 Datei anzeigen

@@ -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) }

+ 8
- 6
types/index.ts Datei anzeigen

@@ -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)
}

Laden…
Abbrechen
Speichern