| @@ -3,7 +3,7 @@ | |||||
| import { useState, useEffect, useRef } from 'react'; | 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'; | ||||
| import { CARD_LIMITS } from '@/lib/validation'; | |||||
| import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation'; | |||||
| type CharCounterProps = { value: string | undefined; limit: number }; | type CharCounterProps = { value: string | undefined; limit: number }; | ||||
| function CharCounter({ value, limit }: CharCounterProps) { | function CharCounter({ value, limit }: CharCounterProps) { | ||||
| @@ -609,12 +609,14 @@ export default function AdminDashboard() { | |||||
| <div className="space-y-6"> | <div className="space-y-6"> | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Portal Title</label> | <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> | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-700 mb-1">Welcome Text</label> | <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> | ||||
| <div className="flex gap-8"> | <div className="flex gap-8"> | ||||
| @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; | |||||
| import { revalidatePath } from 'next/cache'; // ADD THIS | import { revalidatePath } from 'next/cache'; // ADD THIS | ||||
| import { getPortals, savePortals } from '@/lib/db'; | import { getPortals, savePortals } from '@/lib/db'; | ||||
| import { isValidHexColor } from '@/lib/sanitize'; | import { isValidHexColor } from '@/lib/sanitize'; | ||||
| import { validatePortal } from '@/lib/validation'; | |||||
| import { Portal } from '@/types'; | import { Portal } from '@/types'; | ||||
| export const dynamic = 'force-dynamic'; | export const dynamic = 'force-dynamic'; | ||||
| @@ -15,6 +16,11 @@ export async function POST(request: Request) { | |||||
| try { | try { | ||||
| const incomingPortal: Portal = await request.json(); | 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, | // themeColor goes into a <style dangerouslySetInnerHTML> in PublicGrid, | ||||
| // so reject anything that is not a strict #RRGGBB. | // so reject anything that is not a strict #RRGGBB. | ||||
| if (incomingPortal.themeColor !== undefined && !isValidHexColor(incomingPortal.themeColor)) { | 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). | // 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"). | // Per usare un font, scrivi il nome esatto del file presente in data/fonts/ (es. "Geist-Variable.woff2"). | ||||
| export const DEFAULT_FONT = ''; | 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[] = [ | const VALID_CARD_TYPES: readonly CardType[] = [ | ||||
| 'INFO_PAGE', | 'INFO_PAGE', | ||||
| @@ -77,3 +75,25 @@ export function validateCard(card: Partial<Card>): ValidationResult { | |||||
| return { valid: errors.length === 0, errors }; | 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 }; | |||||
| } | |||||