Quellcode durchsuchen

Implementate: backup, edit del welcome text, card kiosk-mode

main
Lorenzo Pollutri vor 1 Monat
Ursprung
Commit
47008a7ca1
11 geänderte Dateien mit 413 neuen und 17 gelöschten Zeilen
  1. +151
    -11
      app/admin/page.tsx
  2. +59
    -0
      app/api/admin/backup/route.ts
  3. +112
    -0
      app/api/admin/restore/route.ts
  4. +7
    -1
      app/api/portals/route.ts
  5. +9
    -1
      app/page.tsx
  6. +36
    -0
      components/FullscreenLock.tsx
  7. +6
    -3
      components/HeroBanner.tsx
  8. +12
    -0
      lib/sanitize.ts
  9. +19
    -0
      lib/system-bins.ts
  10. +1
    -0
      lib/validation.ts
  11. +1
    -1
      types/index.ts

+ 151
- 11
app/admin/page.tsx Datei anzeigen

@@ -17,6 +17,63 @@ function CharCounter({ value, limit }: CharCounterProps) {
);
}
function stripTags(html: string): string {
if (typeof window === 'undefined' || !html) return '';
return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
}
type RichTextMiniProps = {
value: string;
onChange: (html: string) => void;
limit: number;
className?: string;
};
function RichTextMini({ value, onChange, limit, className }: RichTextMiniProps) {
const ref = useRef<HTMLDivElement>(null);
// Sync iniziale soltanto. Aggiornare innerHTML durante l'editing perderebbe la
// posizione del cursore, quindi confidiamo che onInput tenga value e DOM allineati.
useEffect(() => {
if (ref.current && ref.current.innerHTML !== value) {
ref.current.innerHTML = value || '';
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const exec = (cmd: 'bold' | 'italic') => {
ref.current?.focus();
document.execCommand(cmd);
onChange(ref.current?.innerHTML || '');
};
return (
<div>
<div className="flex gap-1 mb-1">
<button
type="button"
onClick={() => exec('bold')}
className="font-bold w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
title="Grassetto"
>B</button>
<button
type="button"
onClick={() => exec('italic')}
className="italic w-8 h-8 border border-gray-300 rounded hover:bg-gray-100"
title="Corsivo"
>I</button>
</div>
<div
ref={ref}
contentEditable
suppressContentEditableWarning
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
className={className ?? 'w-full border border-gray-300 rounded-lg p-2.5 min-h-[8rem] bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500'}
/>
<CharCounter value={stripTags(value)} limit={limit} />
</div>
);
}
function StyledSelect<T extends string>({
value,
onChange,
@@ -519,6 +576,36 @@ export default function AdminDashboard() {
showToast('Portal settings saved successfully!'); // Replaced window.alert
};
const handleBackupDownload = () => {
window.location.href = '/api/admin/backup';
};
const [restoring, setRestoring] = useState(false);
const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (!window.confirm('Il ripristino sovrascriverà tutti i dati attuali (card, portale, media, font). Continuare?')) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/restore', { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore ripristino (${res.status})`, 'error');
return;
}
showToast(`Ripristino completato: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
} finally {
setRestoring(false);
}
};
// Shared Input Classes for high contrast
const inputClasses = "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 placeholder-gray-400";
@@ -582,6 +669,9 @@ export default function AdminDashboard() {
{card.extraMedia && card.extraMedia.length > 0 && (
<span className="text-gray-400 normal-case tracking-normal ml-2">[{card.extraMedia.length}]</span>
)}
{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>
</div>
</div>
@@ -615,8 +705,11 @@ export default function AdminDashboard() {
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label>
<textarea maxLength={PORTAL_LIMITS.welcomeText} value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} />
<CharCounter value={portal.welcomeText} limit={PORTAL_LIMITS.welcomeText} />
<RichTextMini
value={portal.welcomeText || ''}
onChange={html => setPortal({ ...portal, welcomeText: html })}
limit={PORTAL_LIMITS.welcomeText}
/>
</div>
<div className="flex gap-8">
@@ -721,6 +814,32 @@ export default function AdminDashboard() {
</div>
</div>
<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>
<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.
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleBackupDownload}
className="bg-gray-800 text-white px-5 py-2.5 rounded-lg hover:bg-gray-900 font-medium shadow-sm"
>
⬇ Scarica backup ZIP
</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' : ''}`}>
<input
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={handleRestoreUpload}
disabled={restoring}
hidden
/>
{restoring ? 'Ripristino in corso…' : '⤴ Ripristina da ZIP…'}
</label>
</div>
</div>
<div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
<button onClick={handleSavePortal} disabled={savingPortal} className="bg-blue-600 text-white px-10 py-3 rounded-lg hover:bg-blue-700 font-bold shadow disabled:opacity-50 transition-colors">
{savingPortal ? 'Saving...' : 'Save Portal Settings'}
@@ -769,11 +888,21 @@ export default function AdminDashboard() {
{ value: 'INFO_PAGE', label: 'Info Page' },
{ value: 'IMAGE_GALLERY', label: 'Image Gallery' },
{ value: 'BOOK', label: 'Flip-Book' },
{ value: 'FULLSCREEN_LOCK', label: 'Fullscreen Lock (kiosk)' },
...(externalLinksOn ? [{ value: 'EXTERNAL_LINK' as CardType, label: 'External Link' }] : []),
]}
/>
</div>
{isEditing.cardType === 'EXTERNAL_LINK' ? (
{isEditing.cardType === 'FULLSCREEN_LOCK' && (
<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-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.
</p>
</div>
)}
{isEditing.cardType !== 'FULLSCREEN_LOCK' && (isEditing.cardType === 'EXTERNAL_LINK' ? (
<>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">URL</label>
@@ -821,8 +950,8 @@ export default function AdminDashboard() {
<textarea maxLength={CARD_LIMITS.shortDescription} value={isEditing.shortDescription || ''} onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." />
<CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} />
</div>
)}
{isEditing.cardType !== 'BOOK' && (
))}
{isEditing.cardType !== 'BOOK' && isEditing.cardType !== 'FULLSCREEN_LOCK' && (
<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
@@ -852,18 +981,29 @@ export default function AdminDashboard() {
)}
</div>
<div className="space-y-5">
{/* Cover Image */}
{/* Cover Image — per FULLSCREEN_LOCK è il contenuto kiosk a tutto schermo e accetta anche video */}
<div>
<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>
{isEditing.cardType === 'FULLSCREEN_LOCK'
? <>Contenuto a schermo intero <span className="text-gray-400 font-normal text-xs">(immagine o video)</span></>
: <>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" />
<input
type="file"
accept={isEditing.cardType === 'FULLSCREEN_LOCK' ? 'image/*,video/mp4,video/webm,.mp4,.webm,.mov,.m4v' : '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...</p>}
</div>
{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" />
{isVideoUrl(isEditing.imageUrl) ? (
<video src={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" />
)}
<a
href={isEditing.imageUrl}
download={extractFileName(isEditing.imageUrl)}
@@ -880,8 +1020,8 @@ export default function AdminDashboard() {
)}
</div>
{/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) */}
{isEditing.cardType !== 'INFO_PAGE' && (
{/* Gallery Media (images + videos + PDFs) — nascosta per INFO_PAGE (solo cover ammessa) e FULLSCREEN_LOCK (solo contenuto kiosk) */}
{isEditing.cardType !== 'INFO_PAGE' && isEditing.cardType !== 'FULLSCREEN_LOCK' && (
<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>


+ 59
- 0
app/api/admin/backup/route.ts Datei anzeigen

@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { checkSystemBin } from '@/lib/system-bins';

export const dynamic = 'force-dynamic';
export const maxDuration = 600;

const DATA_DIR = path.join(process.cwd(), 'data');

function timestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}

export async function GET() {
if (!(await checkSystemBin('zip', '-v'))) {
return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 });
}

// -r ricorsivo, - stdout, -x esclude pattern relativi al cwd.
// Stato runtime escluso: .tmp/, transcode-jobs.json.
const child = spawn(
'zip',
[
'-r', '-',
'cards.txt', 'portals.txt', 'uploads', 'fonts',
'-x', 'uploads/.tmp/*',
],
{ cwd: DATA_DIR },
);

// Adatta stdout di Node a Web ReadableStream per la Response di Next.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
child.stdout.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
child.stdout.on('end', () => controller.close());
child.on('error', (err) => controller.error(err));
child.on('exit', code => {
if (code !== 0 && code !== null) {
// 12 = "nothing to do" (cartella vuota). Tutto il resto è errore vero.
if (code !== 12) controller.error(new Error(`zip exited with code ${code}`));
}
});
},
cancel() {
try { child.kill('SIGTERM'); } catch { /* ignore */ }
},
});

return new Response(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="interceptop-backup-${timestamp()}.zip"`,
'Cache-Control': 'no-store',
},
});
}

+ 112
- 0
app/api/admin/restore/route.ts Datei anzeigen

@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server';
import { spawn } from 'node:child_process';
import { mkdir, writeFile, readFile, rename, rm } from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { checkSystemBin } from '@/lib/system-bins';

export const dynamic = 'force-dynamic';
export const maxDuration = 600;

const PROJECT_ROOT = process.cwd();
const DATA_DIR = path.join(PROJECT_ROOT, 'data');
const RESTORE_STAGING = path.join(PROJECT_ROOT, '.restore-staging');

function runUnzip(zipPath: string, destDir: string): Promise<{ code: number; stderr: string }> {
return new Promise(resolve => {
const child = spawn('unzip', ['-o', '-q', zipPath, '-d', destDir]);
let stderr = '';
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', () => resolve({ code: -1, stderr: stderr || 'unzip non avviato' }));
child.on('exit', code => resolve({ code: code ?? -1, stderr }));
});
}

export async function POST(request: Request) {
if (!(await checkSystemBin('unzip', '-v'))) {
return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 });
}

const sessionId = crypto.randomUUID();
const sessionDir = path.join(RESTORE_STAGING, sessionId);
const zipPath = path.join(sessionDir, 'backup.zip');
const extractDir = path.join(sessionDir, 'extract');

try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Nessun file ricevuto.' }, { status: 400 });
}

await mkdir(extractDir, { recursive: true });
const buf = Buffer.from(await file.arrayBuffer());
await writeFile(zipPath, buf);

const { code, stderr } = await runUnzip(zipPath, extractDir);
if (code !== 0) {
return NextResponse.json(
{ error: 'Impossibile estrarre lo ZIP.', detail: stderr.split('\n').slice(0, 3).join(' ') },
{ status: 400 },
);
}

// Validazione struttura: cards.txt e portals.txt devono esistere E essere JSON validi.
let cardsCount = 0;
let portalsCount = 0;
try {
const cardsRaw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8');
const cardsParsed = JSON.parse(cardsRaw || '[]');
if (!Array.isArray(cardsParsed)) throw new Error('cards.txt non è un array JSON');
cardsCount = cardsParsed.length;
} catch (e) {
return NextResponse.json(
{ error: `Backup non valido: cards.txt assente o malformato (${(e as Error).message}).` },
{ status: 400 },
);
}
try {
const portalsRaw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8');
const portalsParsed = JSON.parse(portalsRaw || '[]');
if (!Array.isArray(portalsParsed)) throw new Error('portals.txt non è un array JSON');
portalsCount = portalsParsed.length;
} catch (e) {
return NextResponse.json(
{ error: `Backup non valido: portals.txt assente o malformato (${(e as Error).message}).` },
{ status: 400 },
);
}

// Atomic swap: data → data.bak-<ts>, extract → data.
const ts = Date.now();
const backupOldDir = path.join(PROJECT_ROOT, `data.bak-${ts}`);
try {
await rename(DATA_DIR, backupOldDir);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ENOENT') throw err;
// data/ non esisteva: ok, andiamo avanti
}
try {
await rename(extractDir, DATA_DIR);
} catch (err) {
// Revert: rimettiamo data/ al suo posto.
try { await rename(backupOldDir, DATA_DIR); } catch { /* ignore */ }
throw err;
}

// Cleanup zip + cartella staging della sessione (l'extract è già stato spostato).
try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }

return NextResponse.json({
ok: true,
restored: { cards: cardsCount, portals: portalsCount },
previousDataBackup: path.basename(backupOldDir),
});
} catch (error) {
console.error('Restore error:', error);
// Cleanup staging in caso di errore non gestito sopra.
try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
return NextResponse.json({ error: 'Errore durante il ripristino.' }, { status: 500 });
}
}

+ 7
- 1
app/api/portals/route.ts Datei anzeigen

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache'; // ADD THIS
import { getPortals, savePortals } from '@/lib/db';
import { isValidHexColor } from '@/lib/sanitize';
import { isValidHexColor, sanitizeWelcomeText } from '@/lib/sanitize';
import { validatePortal } from '@/lib/validation';
import { Portal } from '@/types';
@@ -16,6 +16,12 @@ export async function POST(request: Request) {
try {
const incomingPortal: Portal = await request.json();
// Sanifica l'HTML del welcome text prima di validare la lunghezza, così il counter
// riflette il contenuto effettivamente persistito.
if (typeof incomingPortal.welcomeText === 'string') {
incomingPortal.welcomeText = sanitizeWelcomeText(incomingPortal.welcomeText);
}
const { valid, errors } = validatePortal(incomingPortal);
if (!valid) {
return NextResponse.json({ error: 'Validation failed', errors }, { status: 400 });


+ 9
- 1
app/page.tsx Datei anzeigen

@@ -1,14 +1,22 @@
import { getCards, getPortals } from '@/lib/db';
import PublicGrid from '@/components/PublicGrid';
import HeroBanner from '@/components/HeroBanner';
import FullscreenLock from '@/components/FullscreenLock';

export const dynamic = 'force-dynamic';
export const dynamic = 'force-dynamic';

export default async function PublicHomePage() {
const portals = await getPortals();
const cards = await getCards();
const portal = portals[0] || {};

// Kiosk takeover: una card FULLSCREEN_LOCK fa sparire grid, hero e tutto il resto.
const sortedCards = [...cards].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
const lockCard = sortedCards.find(c => c.cardType === 'FULLSCREEN_LOCK');
if (lockCard) {
return <FullscreenLock card={lockCard} />;
}

return (
<main className="min-h-screen bg-gray-100 font-sans">
<HeroBanner portal={portal} />


+ 36
- 0
components/FullscreenLock.tsx Datei anzeigen

@@ -0,0 +1,36 @@
'use client';
import { Card } from '@/types';

const VIDEO_RE = /\.(mp4|m4v|webm|mov|qt|ogv|ogg)(\?|$)/i;

export default function FullscreenLock({ card }: { card: Card }) {
const url = card.imageUrl;
const isVideo = !!url && VIDEO_RE.test(url);

return (
<div
className="fixed inset-0 z-[9999] bg-black flex items-center justify-center select-none"
onContextMenu={(e) => e.preventDefault()}
>
{!url ? (
<p className="text-white/70 text-lg">Lock card senza contenuto. Apri /admin per aggiungere immagine o video.</p>
) : isVideo ? (
<video
src={url}
className="w-full h-full object-contain"
autoPlay
loop
muted
playsInline
/>
) : (
<img
src={url}
alt=""
className="w-full h-full object-contain"
draggable={false}
/>
)}
</div>
);
}

+ 6
- 3
components/HeroBanner.tsx Datei anzeigen

@@ -34,9 +34,12 @@ export default function HeroBanner({ portal }: { portal: Partial<Portal> }) {
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 drop-shadow-md">
{portal?.title || 'Welcome to the Network'}
</h1>
<p className="text-lg md:text-2xl drop-shadow-md font-light">
{portal?.welcomeText || 'Please contact administration to set up.'}
</p>
<div
className="text-lg md:text-2xl drop-shadow-md font-light"
dangerouslySetInnerHTML={{
__html: portal?.welcomeText || 'Please contact administration to set up.',
}}
/>
</div>
</div>
);

+ 12
- 0
lib/sanitize.ts Datei anzeigen

@@ -27,6 +27,18 @@ export function sanitizeCardHtml(input: string | null | undefined): string {
return sanitizeHtml(input, CARD_HTML_CONFIG);
}

// Welcome text: solo formattazione inline base + a-capo. Niente link, niente liste.
const WELCOME_TEXT_CONFIG: sanitizeHtml.IOptions = {
allowedTags: ['b', 'i', 'strong', 'em', 'br', 'p', 'div', 'span'],
allowedAttributes: {},
disallowedTagsMode: 'discard',
};

export function sanitizeWelcomeText(input: string | null | undefined): string {
if (!input) return '';
return sanitizeHtml(input, WELCOME_TEXT_CONFIG);
}

const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
export function isValidHexColor(value: unknown): value is string {
return typeof value === 'string' && HEX_COLOR_RE.test(value);


+ 19
- 0
lib/system-bins.ts Datei anzeigen

@@ -0,0 +1,19 @@
// Verifica disponibilità di binari di sistema invocati via child_process.
// Cache in-memory per processo: i binari non vengono installati/disinstallati durante un run.

import { spawn } from 'node:child_process';

const cache = new Map<string, boolean>();

export async function checkSystemBin(name: string, versionArg = '-version'): Promise<boolean> {
const hit = cache.get(name);
if (hit !== undefined) return hit;
const ok = await new Promise<boolean>(resolve => {
const p = spawn(name, [versionArg]);
p.on('error', () => resolve(false));
p.on('exit', code => resolve(code === 0));
});
if (!ok) console.warn(`[system-bins] '${name}' non trovato su PATH`);
cache.set(name, ok);
return ok;
}

+ 1
- 0
lib/validation.ts Datei anzeigen

@@ -11,6 +11,7 @@ const VALID_CARD_TYPES: readonly CardType[] = [
'IMAGE_GALLERY',
'SERVICE_REQUEST',
'BOOK',
'FULLSCREEN_LOCK',
] as const;

const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);


+ 1
- 1
types/index.ts Datei anzeigen

@@ -1,4 +1,4 @@
export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST' | 'BOOK';
export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST' | 'BOOK' | 'FULLSCREEN_LOCK';

export type MediaItem = {
url: string;


Laden…
Abbrechen
Speichern