Просмотр исходного кода

Traduzione progetto in inglese

main
Lorenzo Pollutri 3 недель назад
Родитель
Сommit
1e05b64307
4 измененных файлов: 72 добавлений и 72 удалений
  1. +11
    -11
      README.md
  2. +54
    -54
      app/admin/page.tsx
  3. +1
    -1
      components/FullscreenLock.tsx
  4. +6
    -6
      components/PublicGrid.tsx

+ 11
- 11
README.md Просмотреть файл

@@ -60,7 +60,7 @@ Tutte le impostazioni globali sono **flag build-time** nel file [`lib/config.ts`
| Variabile | Default | Descrizione | | Variabile | Default | Descrizione |
|---|---|---| |---|---|---|
| `EXTERNAL_LINK_ENABLED` | `true` | Mostra il tipo di card "External Link" nel menu dell'admin. Le card di quel tipo già esistenti restano comunque visibili e cliccabili anche se messo a `false`. | | `EXTERNAL_LINK_ENABLED` | `true` | Mostra il tipo di card "External Link" nel menu dell'admin. Le card di quel tipo già esistenti restano comunque visibili e cliccabili anche se messo a `false`. |
| `FACTORY_PRESET_SAVE_ENABLED` | `false` | Mostra il bottone "💾 Salva come Factory Preset" nell'admin (funzione developer per ricreare/aggiornare il preset). Il bottone "🏭 Factory Reset" e lo stato del preset sono **sempre visibili** indipendentemente da questo flag — vedi [Factory Preset](#factory-preset-developer). L'endpoint API `POST /api/admin/factory-preset` resta attivo a prescindere. |
| `FACTORY_PRESET_SAVE_ENABLED` | `false` | Mostra il bottone "💾 Save as Factory Preset (dev)" nell'admin (funzione developer per ricreare/aggiornare il preset). Il bottone "🏭 Factory Reset" e lo stato del preset sono **sempre visibili** indipendentemente da questo flag — vedi [Factory Preset](#factory-preset-developer). L'endpoint API `POST /api/admin/factory-preset` resta attivo a prescindere. |
| `DEFAULT_FONT` | `''` | Font di default se il portale non ne ha impostato uno. Stringa vuota = font di sistema (Arial). Altrimenti il nome esatto di un file in `data/fonts/` (es. `"Geist-Variable.woff2"`). | | `DEFAULT_FONT` | `''` | Font di default se il portale non ne ha impostato uno. Stringa vuota = font di sistema (Arial). Altrimenti il nome esatto di un file in `data/fonts/` (es. `"Geist-Variable.woff2"`). |
| `TEXT_LIMITS` | vedi sotto | Limiti caratteri di tutti i campi testuali. | | `TEXT_LIMITS` | vedi sotto | Limiti caratteri di tutti i campi testuali. |
| `UPLOAD_LIMITS` | vedi sotto | Dimensioni massime upload per famiglia di file. | | `UPLOAD_LIMITS` | vedi sotto | Dimensioni massime upload per famiglia di file. |
@@ -185,7 +185,7 @@ Copiare via questa cartella = backup completo. Sostituirla = ripristino completo
Lo stato applicativo (i dati) è **solo** il contenuto di `data/`: `cards.txt`, `portals.txt`, `uploads/`, `fonts/`. Tutto il resto — codice sorgente, `node_modules`, e gli asset di default in `public/` (es. `hero-bg.jpg`, `logo.png`) — **non** fa parte dello stato dati: arriva con il rilascio del software. "Azzerare" il CPC significa quindi sostituire `data/` con uno stato noto. Lo stato applicativo (i dati) è **solo** il contenuto di `data/`: `cards.txt`, `portals.txt`, `uploads/`, `fonts/`. Tutto il resto — codice sorgente, `node_modules`, e gli asset di default in `public/` (es. `hero-bg.jpg`, `logo.png`) — **non** fa parte dello stato dati: arriva con il rilascio del software. "Azzerare" il CPC significa quindi sostituire `data/` con uno stato noto.


### Contenuti vs codice (due archivi distinti) ### Contenuti vs codice (due archivi distinti)
- **Backup dei contenuti**: archivia solo `data/` (lo fanno il pulsante "Scarica backup" e il comando `zip` documentato sotto). È ciò che si conserva e si ripristina.
- **Backup dei contenuti**: archivia solo `data/` (lo fanno il pulsante "Save backup (ZIP)" e il comando `zip` documentato sotto). È ciò che si conserva e si ripristina.
- **Aggiornamento del codice**: si sostituisce tutto **tranne** `data/`. I contenuti restano al loro posto. - **Aggiornamento del codice**: si sostituisce tutto **tranne** `data/`. I contenuti restano al loro posto.
- Da CLI un reset conservativo è: `mv data data.old && <estrai-l-archivio-dei-contenuti>`. È l'equivalente del `tar zxf` citato dal QA — vedi nota su ZIP vs tar nella sezione [Backup](#backup-e-ripristino). - Da CLI un reset conservativo è: `mv data data.old && <estrai-l-archivio-dei-contenuti>`. È l'equivalente del `tar zxf` citato dal QA — vedi nota su ZIP vs tar nella sezione [Backup](#backup-e-ripristino).


@@ -202,8 +202,8 @@ Per avere uno stato di partenza noto su ogni macchina nuova, includere nel pacch
Disponibile dall'admin in **Settings → Backup & Restore**. Disponibile dall'admin in **Settings → Backup & Restore**.


### Dall'interfaccia ### Dall'interfaccia
- **⬇ Scarica backup ZIP** — scarica `interceptop-backup-<data>.zip` con card, configurazione, media e font (esclude i file temporanei).
- **⤴ Ripristina da ZIP…** — carica uno zip; dopo conferma sovrascrive lo stato attuale e ricarica la pagina. La cartella `data/` precedente viene conservata come `data.bak-<timestamp>/` come rete di sicurezza.
- **⬇ Save backup (ZIP)** — scarica `interceptop-backup-YYYYMMDD-hhmmss.zip` con card, configurazione, media e font (esclude i file temporanei).
- **⤴ Restore from ZIP** — carica uno zip; dopo conferma sovrascrive lo stato attuale e ricarica la pagina. La cartella `data/` precedente viene conservata come `data.bak-<timestamp>/` come rete di sicurezza.


### Da riga di comando (Linux) ### Da riga di comando (Linux)


@@ -218,11 +218,11 @@ zip -r ~/backup-$(date +%Y%m%d-%H%M%S).zip cards.txt portals.txt uploads fonts -
unzip -l ~/backup-*.zip unzip -l ~/backup-*.zip
``` ```


Lo zip così prodotto è caricabile direttamente dal pulsante "Ripristina da ZIP…".
Lo zip così prodotto è caricabile direttamente dal pulsante "Restore from ZIP".


> **Struttura obbligatoria:** i file devono stare alla radice dello zip. Uno zip con tutto dentro una cartella `data/` verrà rifiutato con "cards.txt assente". Deve essere uno **ZIP**, non un `.tar`. > **Struttura obbligatoria:** i file devono stare alla radice dello zip. Uno zip con tutto dentro una cartella `data/` verrà rifiutato con "cards.txt assente". Deve essere uno **ZIP**, non un `.tar`.


> **ZIP vs tar:** il CPC usa archivi **ZIP** (via `zip`/`unzip`), non `tar`. La finalità è la stessa di un `tar cf`/`tar zxf`: un singolo archivio dei soli contenuti, ripristinabile in un colpo. Il restore accetta lo ZIP prodotto dal pulsante "Scarica backup" o dal comando `zip` qui sopra.
> **ZIP vs tar:** il CPC usa archivi **ZIP** (via `zip`/`unzip`), non `tar`. La finalità è la stessa di un `tar cf`/`tar zxf`: un singolo archivio dei soli contenuti, ripristinabile in un colpo. Il restore accetta lo ZIP prodotto dal pulsante "Save backup (ZIP)" o dal comando `zip` qui sopra.


--- ---


@@ -234,9 +234,9 @@ Stato "di fabbrica" ripristinabile con un click. Pensato per preparare preset st


Il preset è un file fisso: **`factory/preset.zip`** alla radice del progetto (fuori da `data/`, quindi non viene toccato dai reset; fuori da `public/`, quindi non scaricabile via web). Il preset è un file fisso: **`factory/preset.zip`** alla radice del progetto (fuori da `data/`, quindi non viene toccato dai reset; fuori da `public/`, quindi non scaricabile via web).


### Dall'interfaccia (con flag attivo)
- **💾 Salva stato attuale come Factory Preset** — congela lo stato corrente in `factory/preset.zip`.
- **🏭 Factory Reset** — ripristina tutto al preset (disabilitato se il preset non esiste). La `data/` precedente resta come `data.bak-<timestamp>/`.
### Dall'interfaccia
- **🏭 Factory Reset** — sempre visibile. Ripristina tutto al preset (disabilitato se il preset non esiste). La `data/` precedente resta come `data.bak-<timestamp>/`.
- **💾 Save as Factory Preset (dev)** — visibile solo con `FACTORY_PRESET_SAVE_ENABLED = true`. Congela lo stato corrente in `factory/preset.zip`.


### Da riga di comando / API ### Da riga di comando / API
```bash ```bash
@@ -486,7 +486,7 @@ Sul server servono alcuni binari di sistema (richiamati direttamente, non via np
|---|---|---| |---|---|---|
| `ffmpeg` | Transcodifica video non compatibili | Upload video che richiedono ricodifica → `503` | | `ffmpeg` | Transcodifica video non compatibili | Upload video che richiedono ricodifica → `503` |
| `ffprobe` | Riconoscimento codec video | Come sopra | | `ffprobe` | Riconoscimento codec video | Come sopra |
| `zip` | Creazione backup / preset | Pulsante "Scarica backup" e "Salva preset" → `503` |
| `zip` | Creazione backup / preset | Pulsanti "Save backup (ZIP)" e "Save as Factory Preset (dev)" → `503` |
| `unzip` | Ripristino backup / factory reset | Pulsanti di ripristino → `503` | | `unzip` | Ripristino backup / factory reset | Pulsanti di ripristino → `503` |


Verifica su server: Verifica su server:
@@ -510,4 +510,4 @@ rm -rf .next && npm run build


**Recupero dopo un ripristino sbagliato**: lo stato precedente è in `data.bak-<timestamp>/`. Ferma il server, rinomina quella cartella in `data/` e riavvia. **Recupero dopo un ripristino sbagliato**: lo stato precedente è in `data.bak-<timestamp>/`. Ferma il server, rinomina quella cartella in `data/` e riavvia.


**Il bottone "Salva come Factory Preset" non compare**: è dietro il flag `FACTORY_PRESET_SAVE_ENABLED` in `lib/config.ts`. Mettilo a `true` e ricostruisci (di norma lo si tiene `false` in produzione). Il bottone "🏭 Factory Reset" e lo stato del preset restano comunque sempre visibili.
**Il bottone "Save as Factory Preset (dev)" non compare**: è dietro il flag `FACTORY_PRESET_SAVE_ENABLED` in `lib/config.ts`. Mettilo a `true` e ricostruisci (di norma lo si tiene `false` in produzione). Il bottone "🏭 Factory Reset" e lo stato del preset restano comunque sempre visibili.

+ 54
- 54
app/admin/page.tsx Просмотреть файл

@@ -56,13 +56,13 @@ function RichTextMini({ value, onChange, limit, className }: RichTextMiniProps)
type="button" type="button"
onClick={() => exec('bold')} onClick={() => exec('bold')}
className="font-bold w-8 h-8 border border-gray-300 rounded hover:bg-gray-100" className="font-bold w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
title="Grassetto"
title="Bold"
>B</button> >B</button>
<button <button
type="button" type="button"
onClick={() => exec('italic')} onClick={() => exec('italic')}
className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100" className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
title="Corsivo"
title="Italic"
>I</button> >I</button>
</div> </div>
<div <div
@@ -118,7 +118,7 @@ function StyledSelect<T extends string>({
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
className={`${inputBase} text-left flex items-center justify-between cursor-pointer`} 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={displayLabel ? '' : 'text-gray-400'} style={current?.style}>{displayLabel || 'Select…'}</span>
<span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span> <span className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}>▾</span>
</button> </button>
{open && ( {open && (
@@ -320,7 +320,7 @@ export default function AdminDashboard() {
imageUrl: prev.imageUrl === url ? '' : prev.imageUrl, imageUrl: prev.imageUrl === url ? '' : prev.imageUrl,
} : prev); } : prev);
const msg = data.status === 'failed' const msg = data.status === 'failed'
? `Trascodifica fallita${data.error ? `: ${String(data.error).split('\n')[0]}` : ''}`
? `Transcoding failed${data.error ? `: ${String(data.error).split('\n')[0]}` : ''}`
: 'Trascodifica annullata'; : 'Trascodifica annullata';
showToast(msg, 'error'); showToast(msg, 'error');
} else { } else {
@@ -379,9 +379,9 @@ export default function AdminDashboard() {
if (rejected.length > 0) { if (rejected.length > 0) {
const list = rejected.length <= 3 const list = rejected.length <= 3
? rejected.join(', ') ? rejected.join(', ')
: `${rejected.slice(0, 3).join(', ')} e altri ${rejected.length - 3}`;
: `${rejected.slice(0, 3).join(', ')} and ${rejected.length - 3} more`;
showToast( showToast(
`Formato non supportato! I formati supportati sono: ${PLAYBACK_SUPPORTED_LABEL}. File ignorati: ${list}`,
`Unsupported format! Supported formats: ${PLAYBACK_SUPPORTED_LABEL}. Skipped files: ${list}`,
'error' 'error'
); );
} }
@@ -504,7 +504,7 @@ export default function AdminDashboard() {
// External Link: URL obbligatorio (feedback immediato, ribadito anche lato server) // External Link: URL obbligatorio (feedback immediato, ribadito anche lato server)
if (isEditing.cardType === 'EXTERNAL_LINK' && !isEditing.actionUrl?.trim()) { if (isEditing.cardType === 'EXTERNAL_LINK' && !isEditing.actionUrl?.trim()) {
showToast("L'URL è obbligatorio per le card External Link", 'error');
showToast('URL is required for External Link cards', 'error');
return; return;
} }
@@ -516,7 +516,7 @@ export default function AdminDashboard() {
}); });
if (!res.ok) { if (!res.ok) {
let message = 'Errore di salvataggio';
let message = 'Save error';
try { try {
const body = await res.json(); const body = await res.json();
if (res.status === 400 && Array.isArray(body?.errors) && body.errors.length > 0) { if (res.status === 400 && Array.isArray(body?.errors) && body.errors.length > 0) {
@@ -595,7 +595,7 @@ export default function AdminDashboard() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ''; e.target.value = '';
if (!file) return; if (!file) return;
if (!window.confirm('Il ripristino sovrascriverà tutti i dati attuali (card, portale, media, font). Continuare?')) return;
if (!window.confirm('Restore will overwrite all current data (cards, portal, media, fonts). Continue?')) return;
setRestoring(true); setRestoring(true);
try { try {
@@ -604,13 +604,13 @@ export default function AdminDashboard() {
const res = await fetch(withBasePath('/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(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
showToast(data?.error || `Errore ripristino (${res.status})`, 'error');
showToast(data?.error || `Restore error (${res.status})`, 'error');
return; return;
} }
showToast(`Ripristino completato: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
showToast(`Restore completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
setTimeout(() => window.location.reload(), 1200); setTimeout(() => window.location.reload(), 1200);
} catch (err) { } catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally { } finally {
setRestoring(false); setRestoring(false);
} }
@@ -634,40 +634,40 @@ export default function AdminDashboard() {
const handleSaveFactoryPreset = async () => { const handleSaveFactoryPreset = async () => {
const msg = factoryPreset?.exists const msg = factoryPreset?.exists
? 'Sovrascrivere il factory preset esistente con lo stato attuale?'
: 'Salvare lo stato attuale come factory preset?';
? 'Overwrite the existing factory preset with the current state?'
: 'Save the current state as factory preset?';
if (!window.confirm(msg)) return; if (!window.confirm(msg)) return;
setSavingPreset(true); setSavingPreset(true);
try { try {
const res = await fetch(withBasePath('/api/admin/factory-preset'), { method: 'POST' }); const res = await fetch(withBasePath('/api/admin/factory-preset'), { method: 'POST' });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
showToast(data?.error || `Errore (${res.status})`, 'error');
showToast(data?.error || `Error (${res.status})`, 'error');
return; return;
} }
showToast('Factory preset aggiornato.');
showToast('Factory preset updated.');
await refreshFactoryPreset(); await refreshFactoryPreset();
} catch (err) { } catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally { } finally {
setSavingPreset(false); setSavingPreset(false);
} }
}; };
const handleFactoryReset = async () => { const handleFactoryReset = async () => {
if (!window.confirm('FACTORY RESET — tutti i dati attuali verranno sostituiti col factory preset. Continuare?')) return;
if (!window.confirm('FACTORY RESET — all current data will be replaced with the factory preset. Continue?')) return;
setFactoryResetting(true); setFactoryResetting(true);
try { try {
const res = await fetch(withBasePath('/api/admin/factory-reset'), { method: 'POST' }); const res = await fetch(withBasePath('/api/admin/factory-reset'), { method: 'POST' });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
showToast(data?.error || `Errore (${res.status})`, 'error');
showToast(data?.error || `Error (${res.status})`, 'error');
return; return;
} }
showToast(`Factory reset eseguito: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
showToast(`Factory reset completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
setTimeout(() => window.location.reload(), 1200); setTimeout(() => window.location.reload(), 1200);
} catch (err) { } catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally { } finally {
setFactoryResetting(false); setFactoryResetting(false);
} }
@@ -737,7 +737,7 @@ export default function AdminDashboard() {
<span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span> <span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span>
)} )}
{card.cardType === 'FULLSCREEN_LOCK' && ( {card.cardType === 'FULLSCREEN_LOCK' && (
<span className="ml-2 bg-red-100 text-red-700 px-2 py-0.5 rounded font-bold text-[10px] tracking-wider">LOCK ATTIVA</span>
<span className="ml-2 bg-red-100 text-red-700 px-2 py-0.5 rounded font-bold text-[10px] tracking-wider">LOCK ACTIVE</span>
)} )}
</span> </span>
</div> </div>
@@ -806,7 +806,7 @@ export default function AdminDashboard() {
</div> </div>
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Font del portale</label>
<label className="block text-sm font-semibold text-gray-700 mb-1">Portal font</label>
<style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => ` <style dangerouslySetInnerHTML={{ __html: availableFonts.map(f => `
@font-face { @font-face {
font-family: '${previewFontFamily(f)}'; font-family: '${previewFontFamily(f)}';
@@ -837,7 +837,7 @@ export default function AdminDashboard() {
{portal.logoUrl && ( {portal.logoUrl && (
<div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border"> <div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
<img src={withBasePath(portal.logoUrl)} className="h-16 object-contain" alt="Logo Preview" /> <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>
<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="Download" aria-label="Download 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> <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> </div>
)} )}
@@ -851,7 +851,7 @@ export default function AdminDashboard() {
{portal.heroImageUrl && ( {portal.heroImageUrl && (
<div className="mt-2 relative rounded shadow border inline-block w-full"> <div className="mt-2 relative rounded shadow border inline-block w-full">
<img src={withBasePath(portal.heroImageUrl)} className="h-32 w-full object-cover rounded" alt="Hero Preview" /> <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>
<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="Download" aria-label="Download 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> <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>
)} )}
@@ -873,8 +873,8 @@ export default function AdminDashboard() {
className="w-5 h-5 text-blue-600 rounded" className="w-5 h-5 text-blue-600 rounded"
/> />
<div> <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>
<span className="block text-sm font-semibold text-gray-900">Enable &ldquo;External Link&rdquo; type in the dropdown menu.</span>
<span className="block text-xs text-gray-600">Existing cards of this type will still remain visible and clickable, even if you disable the &ldquo;External Link&rdquo; type.</span>
</div> </div>
</label> </label>
</div> </div>
@@ -884,7 +884,7 @@ export default function AdminDashboard() {
<div className="mt-10 pt-6 border-t border-gray-200"> <div className="mt-10 pt-6 border-t border-gray-200">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Backup &amp; Restore</h3> <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Backup &amp; Restore</h3>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
Il backup contiene card, configurazione portale, media (immagini, video, PDF) e font caricati. Il ripristino sovrascrive lo stato attuale; la cartella precedente viene conservata come <code>data.bak-&lt;timestamp&gt;</code> per sicurezza.
The backup contains cards, portal configuration, media (images, videos, PDFs), and uploaded fonts. Restoring overwrites the current state. Clicking the &ldquo;Save backup (ZIP)&rdquo; button saves the Cards structure as <code>interceptop-backup-YYYYMMDD-hhmmss.zip</code>.
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
@@ -892,7 +892,7 @@ export default function AdminDashboard() {
onClick={handleBackupDownload} onClick={handleBackupDownload}
className="bg-gray-800 text-white px-5 py-2.5 rounded-lg hover:bg-gray-900 font-medium shadow-sm" className="bg-gray-800 text-white px-5 py-2.5 rounded-lg hover:bg-gray-900 font-medium shadow-sm"
> >
⬇ Scarica backup ZIP
⬇ Save backup (ZIP)
</button> </button>
<label className={`cursor-pointer inline-flex items-center bg-amber-600 text-white px-5 py-2.5 rounded-lg hover:bg-amber-700 font-medium shadow-sm ${restoring ? 'opacity-60 cursor-not-allowed' : ''}`}> <label className={`cursor-pointer inline-flex items-center bg-amber-600 text-white px-5 py-2.5 rounded-lg hover:bg-amber-700 font-medium shadow-sm ${restoring ? 'opacity-60 cursor-not-allowed' : ''}`}>
<input <input
@@ -902,7 +902,7 @@ export default function AdminDashboard() {
disabled={restoring} disabled={restoring}
hidden hidden
/> />
{restoring ? 'Ripristino in corso…' : '⤴ Ripristina da ZIP…'}
{restoring ? 'Restoring…' : '⤴ Restore from ZIP'}
</label> </label>
</div> </div>
</div> </div>
@@ -910,33 +910,33 @@ export default function AdminDashboard() {
<div className="mt-8 pt-6 border-t border-gray-200"> <div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset</h3> <h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset</h3>
<p className="text-xs text-gray-500 mb-2"> <p className="text-xs text-gray-500 mb-2">
Stato &ldquo;di fabbrica&rdquo; ripristinabile con un click. Il preset (<code>factory/preset.zip</code>) viene preparato sulla macchina di sviluppo e distribuito alle macchine MajorNet.
&ldquo;Factory&rdquo; state restorable with one click. The preset (<code>factory/preset.zip</code>) is prepared on the development machine and distributed to MajorNet machines.
</p> </p>
<p className="text-xs text-gray-700 mb-4"> <p className="text-xs text-gray-700 mb-4">
Preset attuale: {factoryPreset === null ? '…'
Current preset: {factoryPreset === null ? '…'
: factoryPreset.exists : factoryPreset.exists
? <span className="text-green-700 font-medium">presente · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('it-IT') : '?'}</span>
: <span className="text-gray-400 italic">nessun preset configurato</span>}
? <span className="text-green-700 font-medium">present · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('en-GB') : '?'}</span>
: <span className="text-gray-400 italic">no preset configured</span>}
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
type="button" type="button"
onClick={handleFactoryReset} onClick={handleFactoryReset}
disabled={factoryResetting || !factoryPreset?.exists} disabled={factoryResetting || !factoryPreset?.exists}
title={factoryPreset?.exists ? undefined : 'Nessun preset configurato'}
title={factoryPreset?.exists ? undefined : 'No preset configured'}
className="bg-red-700 text-white px-5 py-2.5 rounded-lg hover:bg-red-800 font-medium shadow-sm disabled:opacity-60 disabled:cursor-not-allowed" className="bg-red-700 text-white px-5 py-2.5 rounded-lg hover:bg-red-800 font-medium shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
> >
{factoryResetting ? 'Reset in corso…' : '🏭 Factory Reset'}
{factoryResetting ? 'Reset in progress…' : '🏭 Factory Reset'}
</button> </button>
{FACTORY_PRESET_SAVE_ENABLED && ( {FACTORY_PRESET_SAVE_ENABLED && (
<button <button
type="button" type="button"
onClick={handleSaveFactoryPreset} onClick={handleSaveFactoryPreset}
disabled={savingPreset} disabled={savingPreset}
title="Funzione developer: salva lo stato corrente come nuovo preset di fabbrica"
title="Developer function: save the current state as a new factory preset"
className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60" className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
> >
{savingPreset ? 'Salvataggio…' : '💾 Salva come Factory Preset (dev)'}
{savingPreset ? 'Saving…' : '💾 Save as Factory Preset (dev)'}
</button> </button>
)} )}
</div> </div>
@@ -997,10 +997,10 @@ export default function AdminDashboard() {
</div> </div>
{isEditing.cardType === 'FULLSCREEN_LOCK' && ( {isEditing.cardType === 'FULLSCREEN_LOCK' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4"> <div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm font-semibold text-red-800">⚠ Modalità Kiosk Lock</p>
<p className="text-sm font-semibold text-red-800">⚠ Kiosk Lock Mode</p>
<p className="text-xs text-red-700 mt-1"> <p className="text-xs text-red-700 mt-1">
Questa card prenderà il controllo totale del portale pubblico. Tutte le altre card saranno nascoste finché non rimuovi questa.
Carica un&apos;immagine o un video come &quot;Contenuto a schermo intero&quot; nella sezione a destra.
This card will take full control of the public portal. All other cards will be hidden until you remove this one.
Upload an image or video as &quot;Full-screen content&quot; in the section on the right.
</p> </p>
</div> </div>
)} )}
@@ -1014,22 +1014,22 @@ export default function AdminDashboard() {
value={isEditing.actionUrl || ''} value={isEditing.actionUrl || ''}
onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })} onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })}
className={inputClasses} className={inputClasses}
placeholder="https://esempio.it/pagina"
placeholder="https://example.com/page"
/> />
<CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} /> <CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} />
</div> </div>
<div> <div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label>
<label className="block text-sm font-semibold text-gray-800 mb-1">Link text</label>
<input <input
type="text" type="text"
maxLength={CARD_LIMITS.shortDescription} maxLength={CARD_LIMITS.shortDescription}
value={isEditing.shortDescription || ''} value={isEditing.shortDescription || ''}
onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })}
className={inputClasses} className={inputClasses}
placeholder="es. Visita il sito ufficiale"
placeholder="e.g. Visit the official site"
/> />
<CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} /> <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
<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>
<p className="text-xs text-gray-500 mt-1">Text displayed as a clickable link in the modal. If empty, the URL itself is shown.</p>
</div> </div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-200"> <div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
@@ -1087,7 +1087,7 @@ export default function AdminDashboard() {
<div> <div>
<label className="block text-sm font-semibold text-gray-800 mb-1"> <label className="block text-sm font-semibold text-gray-800 mb-1">
{isEditing.cardType === 'FULLSCREEN_LOCK' {isEditing.cardType === 'FULLSCREEN_LOCK'
? <>Contenuto a schermo intero <span className="text-gray-400 font-normal text-xs">(immagine o video)</span></>
? <>Full-screen content <span className="text-gray-400 font-normal text-xs">(image or video)</span></>
: <>Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span></>} : <>Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span></>}
</label> </label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors"> <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
@@ -1110,8 +1110,8 @@ export default function AdminDashboard() {
href={withBasePath(isEditing.imageUrl)} href={withBasePath(isEditing.imageUrl)}
download={extractFileName(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" 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"
title="Download"
aria-label="Download cover"
>⬇</a> >⬇</a>
<button <button
onClick={() => setIsEditing({...isEditing, imageUrl: ''})} onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
@@ -1199,23 +1199,23 @@ export default function AdminDashboard() {
<button <button
onClick={() => moveExtraMedia(i, 'up')} 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" 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"
title="Move up"
aria-label="Move up"
disabled={i === 0} disabled={i === 0}
>↑</button> >↑</button>
<button <button
onClick={() => moveExtraMedia(i, 'down')} 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" 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ù"
title="Move down"
aria-label="Move down"
disabled={i === (isEditing.extraMedia || []).length - 1} disabled={i === (isEditing.extraMedia || []).length - 1}
>↓</button> >↓</button>
<a <a
href={withBasePath(item.url)} href={withBasePath(item.url)}
download={extractFileName(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" 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"
title="Download"
aria-label="Download"
>⬇</a> >⬇</a>
<button <button
onClick={() => removeExtraMedia(i)} onClick={() => removeExtraMedia(i)}


+ 1
- 1
components/FullscreenLock.tsx Просмотреть файл

@@ -14,7 +14,7 @@ export default function FullscreenLock({ card }: { card: Card }) {
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{!url ? ( {!url ? (
<p className="text-white/70 text-lg">Lock card senza contenuto. Apri /admin per aggiungere immagine o video.</p>
<p className="text-white/70 text-lg">Empty lock card. Open /admin to add an image or video.</p>
) : isVideo ? ( ) : isVideo ? (
<video <video
src={url} src={url}


+ 6
- 6
components/PublicGrid.tsx Просмотреть файл

@@ -495,8 +495,8 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void })
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-4 right-4 bg-white/10 hover:bg-white/25 text-white w-11 h-11 flex items-center justify-center rounded-full text-xl z-30 transition-colors" className="absolute top-4 right-4 bg-white/10 hover:bg-white/25 text-white w-11 h-11 flex items-center justify-center rounded-full text-xl z-30 transition-colors"
title="Chiudi"
aria-label="Chiudi"
title="Close"
aria-label="Close"
>✕</button> >✕</button>
<div <div
@@ -568,15 +568,15 @@ function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void })
onClick={goPrev} onClick={goPrev}
disabled={spread === 0 || flipping !== null} disabled={spread === 0 || flipping !== null}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors"
title="Pagina precedente"
aria-label="Pagina precedente"
title="Previous page"
aria-label="Previous page"
>‹</button> >‹</button>
<button <button
onClick={goNext} onClick={goNext}
disabled={spread >= totalSpreads - 1 || flipping !== null} disabled={spread >= totalSpreads - 1 || flipping !== null}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors"
title="Pagina successiva"
aria-label="Pagina successiva"
title="Next page"
aria-label="Next page"
>›</button> >›</button>
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 bg-white/15 text-white px-4 py-1.5 rounded-full text-sm font-medium z-30"> <div className="absolute bottom-5 left-1/2 -translate-x-1/2 bg-white/15 text-white px-4 py-1.5 rounded-full text-sm font-medium z-30">


Загрузка…
Отмена
Сохранить