diff --git a/README.md b/README.md index e215bc4..208f3a5 100644 --- a/README.md +++ b/README.md @@ -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; \ No newline at end of file diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 53cdafa..8d12c6b 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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({ + 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(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 ( +
+ + {open && ( +
+ {options.map(o => ( + + ))} +
+ )} +
+ ); +} + +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)[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 { + 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 { + 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((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((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((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 { + 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([]); const [isEditing, setIsEditing] = useState | null>(null); - + // Portal State const [portal, setPortal] = useState>({}); const [savingPortal, setSavingPortal] = useState(false); const [uploading, setUploading] = useState<{ [key: string]: boolean }>({}); - + // NEW UI STATES: Toast and Confirm Dialog - const [toast, setToast] = useState(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([]); + + // 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, 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) => { + 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
- {card.imageUrl ? :
No Image
} + {(() => { + const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || ''; + if (!previewUrl) { + return
No Image
; + } + return isVideoUrl(previewUrl) + ?
@@ -222,6 +549,28 @@ export default function AdminDashboard() {
+ +
+ +