| @@ -3,7 +3,7 @@ | |||
| import { useState, useEffect, useRef } from 'react'; | |||
| import { Card, Portal, MediaItem, CardType } from '@/types'; | |||
| import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; | |||
| import { CARD_LIMITS } from '@/lib/validation'; | |||
| import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation'; | |||
| type CharCounterProps = { value: string | undefined; limit: number }; | |||
| function CharCounter({ value, limit }: CharCounterProps) { | |||
| @@ -609,12 +609,14 @@ export default function AdminDashboard() { | |||
| <div className="space-y-6"> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label> | |||
| <input type="text" value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} /> | |||
| <input type="text" maxLength={PORTAL_LIMITS.title} value={portal.title || ''} onChange={e => setPortal({...portal, title: e.target.value})} className={inputClasses} /> | |||
| <CharCounter value={portal.title} limit={PORTAL_LIMITS.title} /> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label> | |||
| <textarea value={portal.welcomeText || ''} onChange={e => setPortal({...portal, welcomeText: e.target.value})} className={`${inputClasses} h-32 resize-none`} /> | |||
| <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} /> | |||
| </div> | |||
| <div className="flex gap-8"> | |||
| @@ -2,6 +2,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 { validatePortal } from '@/lib/validation'; | |||
| import { Portal } from '@/types'; | |||
| export const dynamic = 'force-dynamic'; | |||
| @@ -15,6 +16,11 @@ export async function POST(request: Request) { | |||
| try { | |||
| const incomingPortal: Portal = await request.json(); | |||
| const { valid, errors } = validatePortal(incomingPortal); | |||
| if (!valid) { | |||
| return NextResponse.json({ error: 'Validation failed', errors }, { status: 400 }); | |||
| } | |||
| // themeColor goes into a <style dangerouslySetInnerHTML> in PublicGrid, | |||
| // so reject anything that is not a strict #RRGGBB. | |||
| if (incomingPortal.themeColor !== undefined && !isValidHexColor(incomingPortal.themeColor)) { | |||
| @@ -6,3 +6,19 @@ export const EXTERNAL_LINK_ENABLED = true; | |||
| // 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 = ''; | |||
| // Limiti caratteri per tutti i campi testuali compilabili dall'admin. | |||
| // Validati lato server (app/api/cards/route.ts, app/api/portals/route.ts) e | |||
| // usati lato client come maxLength + contatore (app/admin/page.tsx). | |||
| export const TEXT_LIMITS = { | |||
| card: { | |||
| title: 200, | |||
| shortDescription: 500, | |||
| fullContent: 20_000, | |||
| actionUrl: 2000, | |||
| }, | |||
| portal: { | |||
| title: 200, | |||
| welcomeText: 1000, | |||
| }, | |||
| } as const; | |||
| @@ -1,11 +1,9 @@ | |||
| import type { Card, CardType } from '@/types'; | |||
| import type { Card, CardType, Portal } from '@/types'; | |||
| import { TEXT_LIMITS } from '@/lib/config'; | |||
| export const CARD_LIMITS = { | |||
| title: 200, | |||
| shortDescription: 500, | |||
| fullContent: 20000, | |||
| actionUrl: 2000, | |||
| } as const; | |||
| // Re-export per retro-compatibilità con il codice che importa CARD_LIMITS. | |||
| export const CARD_LIMITS = TEXT_LIMITS.card; | |||
| export const PORTAL_LIMITS = TEXT_LIMITS.portal; | |||
| const VALID_CARD_TYPES: readonly CardType[] = [ | |||
| 'INFO_PAGE', | |||
| @@ -77,3 +75,25 @@ export function validateCard(card: Partial<Card>): ValidationResult { | |||
| return { valid: errors.length === 0, errors }; | |||
| } | |||
| export function validatePortal(portal: Partial<Portal>): ValidationResult { | |||
| const errors: ValidationError[] = []; | |||
| if (portal.title !== undefined) { | |||
| if (typeof portal.title !== 'string') { | |||
| errors.push({ field: 'title', message: 'Tipo non valido' }); | |||
| } else if (portal.title.length > PORTAL_LIMITS.title) { | |||
| errors.push({ field: 'title', message: 'Titolo portale troppo lungo', limit: PORTAL_LIMITS.title, actual: portal.title.length }); | |||
| } | |||
| } | |||
| if (portal.welcomeText !== undefined) { | |||
| if (typeof portal.welcomeText !== 'string') { | |||
| errors.push({ field: 'welcomeText', message: 'Tipo non valido' }); | |||
| } else if (portal.welcomeText.length > PORTAL_LIMITS.welcomeText) { | |||
| errors.push({ field: 'welcomeText', message: 'Testo di benvenuto troppo lungo', limit: PORTAL_LIMITS.welcomeText, actual: portal.welcomeText.length }); | |||
| } | |||
| } | |||
| return { valid: errors.length === 0, errors }; | |||
| } | |||