| @@ -4,14 +4,14 @@ import { useState, useEffect, useRef } from 'react'; | |||||
| import { Card, Portal, MediaItem, CardType } from '@/types'; | import { Card, Portal, MediaItem, CardType } from '@/types'; | ||||
| import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; | import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; | ||||
| function CardTypeSelect({ | |||||
| function StyledSelect<T extends string>({ | |||||
| value, | value, | ||||
| onChange, | onChange, | ||||
| options, | 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 [open, setOpen] = useState(false); | ||||
| const ref = useRef<HTMLDivElement>(null); | 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 [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); | ||||
| const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | 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 [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. | // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config. | ||||
| const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT; | const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT; | ||||
| @@ -194,6 +195,7 @@ export default function AdminDashboard() { | |||||
| useEffect(() => { | useEffect(() => { | ||||
| fetch('/api/cards').then(res => res.json()).then(setCards); | fetch('/api/cards').then(res => res.json()).then(setCards); | ||||
| fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data)); | 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) => { | const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => { | ||||
| @@ -519,6 +521,21 @@ export default function AdminDashboard() { | |||||
| </div> | </div> | ||||
| </div> | </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> | ||||
| <div className="space-y-6"> | <div className="space-y-6"> | ||||
| @@ -614,7 +631,7 @@ export default function AdminDashboard() { | |||||
| </div> | </div> | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label> | <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} | value={(isEditing.cardType || 'INFO_PAGE') as CardType} | ||||
| onChange={(v) => setIsEditing({ ...isEditing, cardType: v })} | onChange={(v) => setIsEditing({ ...isEditing, cardType: v })} | ||||
| options={[ | 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); | 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 }); | return NextResponse.json(portals[0], { status: 200 }); | ||||
| } catch (error) { | } catch (error) { | ||||
| return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | ||||
| @@ -1,6 +1,10 @@ | |||||
| import type { Metadata } from "next"; | import type { Metadata } from "next"; | ||||
| import { GeistSans } from "geist/font/sans"; | import { GeistSans } from "geist/font/sans"; | ||||
| import { GeistMono } from "geist/font/mono"; | 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"; | import "./globals.css"; | ||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| @@ -8,16 +12,79 @@ export const metadata: Metadata = { | |||||
| description: "Welcome", | 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, | 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 ( | return ( | ||||
| <html | <html | ||||
| lang="it" | lang="it" | ||||
| className={`${GeistSans.variable} ${GeistMono.variable} h-full antialiased`} | 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> | <body className="min-h-full flex flex-col">{children}</body> | ||||
| </html> | </html> | ||||
| ); | ); | ||||
| @@ -1,3 +1,8 @@ | |||||
| // Feature flags compilati nel bundle. Modifica e ricostruisci (npm run build) per applicare. | // Feature flags compilati nel bundle. Modifica e ricostruisci (npm run build) per applicare. | ||||
| export const EXTERNAL_LINK_ENABLED = true; | 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 PORTALS_FILE = path.join(DATA_DIR, 'portals.txt'); | ||||
| const CARDS_FILE = path.join(DATA_DIR, 'cards.txt'); | const CARDS_FILE = path.join(DATA_DIR, 'cards.txt'); | ||||
| // Helper to ensure files exist | |||||
| // Helper to ensure files/folders exist | |||||
| async function ensureDb() { | async function ensureDb() { | ||||
| try { await fs.access(DATA_DIR); } catch { await fs.mkdir(DATA_DIR); } | 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(PORTALS_FILE); } catch { await fs.writeFile(PORTALS_FILE, '[]'); } | ||||
| try { await fs.access(CARDS_FILE); } catch { await fs.writeFile(CARDS_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[]> { | export async function getPortals(): Promise<Portal[]> { | ||||
| @@ -33,4 +33,5 @@ export interface Portal { | |||||
| fadeHeroImage?: boolean; | fadeHeroImage?: boolean; | ||||
| maxGridColumns?: number; | maxGridColumns?: number; | ||||
| externalLinkEnabled?: boolean; // se false, l'admin nasconde il tipo "External Link" nel dropdown | 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) | |||||
| } | } | ||||