@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import { Card, Portal, MediaItem, CardType } from '@/types';
import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_RESET_ENABLED } from '@/lib/config';
import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation';
import { withBasePath } from '@/lib/url';
type CharCounterProps = { value: string | undefined; limit: number };
function CharCounter({ value, limit }: CharCounterProps) {
@@ -168,7 +169,7 @@ const extractFileName = (url: string): string => {
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 res = await fetch(withBasePath( '/api/upload') , { method: 'POST', body: formData });
const data = await res.json();
return data.url || null;
}
@@ -275,9 +276,9 @@ export default function AdminDashboard() {
};
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([]));
fetch(withBasePath( '/api/cards') ).then(res => res.json()).then(setCards);
fetch(withBasePath( '/api/portals') ).then(res => res.json()).then(data => data && setPortal(data));
fetch(withBasePath( '/api/fonts') ).then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([]));
}, []);
// Poll pending transcode jobs every 2s. On 'done' we drop the entry from the
@@ -294,7 +295,7 @@ export default function AdminDashboard() {
for (const [url, j] of pendingEntries) {
if (cancelled) return;
try {
const res = await fetch(`/api/transcode/${j.jobId}`);
const res = await fetch(withBasePath( `/api/transcode/${j.jobId}`) );
if (!res.ok) continue;
const data = await res.json();
if (cancelled) return;
@@ -341,7 +342,7 @@ export default function AdminDashboard() {
const formData = new FormData();
formData.append('file', e.target.files[0]);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const res = await fetch(withBasePath( '/api/upload') , { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
@@ -406,7 +407,7 @@ export default function AdminDashboard() {
} else {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const res = await fetch(withBasePath( '/api/upload') , { method: 'POST', body: formData });
const data = await res.json();
if (!data.url) continue;
@@ -501,7 +502,7 @@ export default function AdminDashboard() {
const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
const res = await fetch('/api/cards', {
const res = await fetch(withBasePath( '/api/cards') , {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
});
@@ -534,7 +535,7 @@ export default function AdminDashboard() {
setConfirmDialog({
message: 'Are you sure you want to delete this card? This action cannot be undone.',
onConfirm: async () => {
await fetch(`/api/cards?id=${id}`, { method: 'DELETE' });
await fetch(withBasePath( `/api/cards?id=${id}`) , { method: 'DELETE' });
setCards(prev => prev.filter(c => c.id !== id));
setConfirmDialog(null);
showToast('Card successfully deleted.');
@@ -560,7 +561,7 @@ export default function AdminDashboard() {
setCards(updatedCards);
// Persist the new order to the backend
await fetch('/api/cards', {
await fetch(withBasePath( '/api/cards') , {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedCards)
@@ -569,7 +570,7 @@ export default function AdminDashboard() {
const handleSavePortal = async () => {
setSavingPortal(true);
await fetch('/api/portals', {
await fetch(withBasePath( '/api/portals') , {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
});
setSavingPortal(false);
@@ -577,7 +578,7 @@ export default function AdminDashboard() {
};
const handleBackupDownload = () => {
window.location.href = '/api/admin/backup';
window.location.href = withBasePath( '/api/admin/backup') ;
};
const [restoring, setRestoring] = useState(false);
@@ -591,7 +592,7 @@ export default function AdminDashboard() {
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/restore', { method: 'POST', body: fd });
const res = await fetch(withBasePath( '/api/admin/restore') , { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore ripristino (${res.status})`, 'error');
@@ -613,7 +614,7 @@ export default function AdminDashboard() {
const refreshFactoryPreset = async () => {
try {
const res = await fetch('/api/admin/factory-preset');
const res = await fetch(withBasePath( '/api/admin/factory-preset') );
if (res.ok) setFactoryPreset(await res.json());
} catch { /* ignore */ }
};
@@ -628,7 +629,7 @@ export default function AdminDashboard() {
if (!window.confirm(msg)) return;
setSavingPreset(true);
try {
const res = await fetch('/api/admin/factory-preset', { method: 'POST' });
const res = await fetch(withBasePath( '/api/admin/factory-preset') , { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore (${res.status})`, 'error');
@@ -647,7 +648,7 @@ export default function AdminDashboard() {
if (!window.confirm('FACTORY RESET — tutti i dati attuali verranno sostituiti col factory preset. Continuare?')) return;
setFactoryResetting(true);
try {
const res = await fetch('/api/admin/factory-reset', { method: 'POST' });
const res = await fetch(withBasePath( '/api/admin/factory-reset') , { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore (${res.status})`, 'error');
@@ -674,7 +675,7 @@ export default function AdminDashboard() {
<h1 className="text-2xl font-bold">Captive Portal CMS</h1>
<p className="text-sm text-blue-200">Local Administration</p>
</div>
<a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
<a href={withBasePath('/')} target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
View Live Portal ↗
</a>
</div>
@@ -715,8 +716,8 @@ export default function AdminDashboard() {
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="" />;
? <video src={withBasePath( previewUrl) } className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
: <img src={withBasePath( 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>
@@ -799,7 +800,7 @@ export default function AdminDashboard() {
<style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => `
@font-face {
font-family: '${previewFontFamily(f)}';
src: url('/api/fonts?name=${encodeURIComponent(f )}') format('${fontFormatFromName(f)}');
src: url('${withBasePath('/api/fonts?name=' + encodeURIComponent(f) )}') format('${fontFormatFromName(f)}');
font-display: swap;
}`).join('') }} />
<StyledSelect<string>
@@ -825,8 +826,8 @@ export default function AdminDashboard() {
{uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
{portal.logoUrl && (
<div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
<img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
<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>
<img src={withBasePath( portal.logoUrl) } className="h-16 object-contain" alt="Logo Preview" />
<a href={withBasePath( 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>
)}
@@ -839,8 +840,8 @@ export default function AdminDashboard() {
{uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
{portal.heroImageUrl && (
<div className="mt-2 relative rounded shadow border inline-block w-full">
<img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
<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>
<img src={withBasePath( portal.heroImageUrl) } className="h-32 w-full object-cover rounded" alt="Hero Preview" />
<a href={withBasePath( 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>
)}
@@ -1091,12 +1092,12 @@ export default function AdminDashboard() {
{isEditing.imageUrl && (
<div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
{isVideoUrl(isEditing.imageUrl) ? (
<video src={isEditing.imageUrl} className="w-full h-32 object-cover" muted playsInline />
<video src={withBasePath( isEditing.imageUrl) } className="w-full h-32 object-cover" muted playsInline />
) : (
<img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover preview" />
<img src={withBasePath( isEditing.imageUrl) } className="w-full h-32 object-cover" alt="Cover preview" />
)}
<a
href={isEditing.imageUrl}
href={withBasePath( 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"
@@ -1144,11 +1145,11 @@ export default function AdminDashboard() {
<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" />
<video src={withBasePath( 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="" />
<img src={withBasePath( item.url) } className="w-full h-full object-cover" alt="" />
)}
{isTranscoding && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/75 text-white text-[10px] font-semibold leading-tight gap-0.5">
@@ -1200,7 +1201,7 @@ export default function AdminDashboard() {
disabled={i === (isEditing.extraMedia || []).length - 1}
>↓</button>
<a
href={item.url}
href={withBasePath( 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"