| @@ -4,14 +4,14 @@ 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 CardTypeSelect({ | |||
| function StyledSelect<T extends string>({ | |||
| value, | |||
| onChange, | |||
| options, | |||
| }: { | |||
| value: CardType; | |||
| onChange: (v: CardType) => void; | |||
| options: { value: CardType; label: string }[]; | |||
| value: T; | |||
| onChange: (v: T) => void; | |||
| options: { value: T; label: string }[]; | |||
| }) { | |||
| const [open, setOpen] = useState(false); | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| @@ -181,6 +181,7 @@ export default function AdminDashboard() { | |||
| 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; | |||
| @@ -194,6 +195,7 @@ 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([])); | |||
| }, []); | |||
| const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => { | |||
| @@ -519,6 +521,21 @@ export default function AdminDashboard() { | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Font del portale</label> | |||
| <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, '') })), | |||
| ]} | |||
| /> | |||
| <p className="text-xs text-gray-500 mt-1"> | |||
| Per aggiungere altri font, copia file <code className="bg-gray-100 px-1 rounded">.woff2</code>, <code className="bg-gray-100 px-1 rounded">.woff</code>, <code className="bg-gray-100 px-1 rounded">.ttf</code> o <code className="bg-gray-100 px-1 rounded">.otf</code> in <code className="bg-gray-100 px-1 rounded">data/fonts/</code> e ricarica questa pagina. | |||
| </p> | |||
| </div> | |||
| </div> | |||
| <div className="space-y-6"> | |||
| @@ -614,7 +631,7 @@ export default function AdminDashboard() { | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label> | |||
| <CardTypeSelect | |||
| <StyledSelect<CardType> | |||
| value={(isEditing.cardType || 'INFO_PAGE') as CardType} | |||
| onChange={(v) => setIsEditing({ ...isEditing, cardType: v })} | |||
| options={[ | |||
| @@ -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/i.test(f)) | |||
| .sort(); | |||
| return NextResponse.json(list); | |||
| } catch { | |||
| return NextResponse.json([]); | |||
| } | |||
| } | |||
| @@ -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 }); | |||
| @@ -1,6 +1,10 @@ | |||
| import type { Metadata } from "next"; | |||
| 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"; | |||
| export const metadata: Metadata = { | |||
| @@ -8,16 +12,79 @@ export const metadata: Metadata = { | |||
| 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 findItalicSibling(regularFilename: string): Promise<string | null> { | |||
| const m = regularFilename.match(/^(.*)(\.(?:woff2?|ttf|otf))$/i); | |||
| if (!m) return null; | |||
| const [, base, ext] = m; | |||
| const candidates = [ | |||
| `${base}-Italic${ext}`, `${base}-italic${ext}`, | |||
| `${base}_Italic${ext}`, `${base}_italic${ext}`, | |||
| `${base} Italic${ext}`, `${base} italic${ext}`, | |||
| `${base}Italic${ext}`, `${base}italic${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 regularExt = path.extname(chosenFont); | |||
| const italicFile = await findItalicSibling(chosenFont); | |||
| const italicExt = italicFile ? path.extname(italicFile) : ""; | |||
| const regularUrl = `/api/fonts?name=${encodeURIComponent(chosenFont)}`; | |||
| const italicUrl = italicFile ? `/api/fonts?name=${encodeURIComponent(italicFile)}` : ""; | |||
| fontStyleCss = ` | |||
| @font-face { | |||
| font-family: 'PortalFont'; | |||
| src: url('${regularUrl}') format('${fontFormat(regularExt)}'); | |||
| font-style: normal; | |||
| font-display: swap; | |||
| }${italicFile ? ` | |||
| @font-face { | |||
| font-family: 'PortalFont'; | |||
| src: url('${italicUrl}') format('${fontFormat(italicExt)}'); | |||
| font-style: italic; | |||
| font-display: swap; | |||
| }` : ""} | |||
| body { font-family: 'PortalFont', Arial, Helvetica, sans-serif; } | |||
| `; | |||
| } | |||
| return ( | |||
| <html | |||
| lang="it" | |||
| className={`${GeistSans.variable} ${GeistMono.variable} h-full antialiased`} | |||
| > | |||
| <head> | |||
| {fontStyleCss && <style dangerouslySetInnerHTML={{ __html: fontStyleCss }} />} | |||
| </head> | |||
| <body className="min-h-full flex flex-col">{children}</body> | |||
| </html> | |||
| ); | |||
| @@ -1,3 +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 = ''; | |||
| @@ -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[]> { | |||
| @@ -33,4 +33,5 @@ export interface Portal { | |||
| 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) | |||
| } | |||