Explorar el Código

Primo commit

main
root hace 3 horas
padre
commit
060913b397
Se han modificado 54 ficheros con 881 adiciones y 62 borrados
  1. +388
    -0
      app/admin/page.tsx
  2. +63
    -0
      app/api/cards/route.ts
  3. +35
    -0
      app/api/files/route.ts
  4. +33
    -0
      app/api/portals/route.ts
  5. +30
    -0
      app/api/upload/route.ts
  6. +23
    -62
      app/page.tsx
  7. +43
    -0
      components/HeroBanner.tsx
  8. +88
    -0
      components/PublicGrid.tsx
  9. +50
    -0
      data/cards.txt
  10. +13
    -0
      data/portals.txt
  11. BIN
      data/uploads/1775387317052-02_Una_storia_di_cultura.jpg
  12. BIN
      data/uploads/1775387361745-99_header.jpg
  13. BIN
      data/uploads/1775387379798-04_Maestre_e_Maestri.jpg
  14. BIN
      data/uploads/1775387401522-06_La_ristrutturazione.jpg
  15. BIN
      data/uploads/1775387428350-10_Campobasso.jpg
  16. BIN
      data/uploads/1775387439649-12_Il_centro_storico.jpg
  17. BIN
      data/uploads/1775387454549-13_Le_Istituzioni.jpg
  18. BIN
      data/uploads/1775387470269-16_Castel_Monforte.jpg
  19. BIN
      data/uploads/1775387488261-22_Le_tradizioni_popolari.jpg
  20. BIN
      data/uploads/1775387501262-24_I_Misteri.jpg
  21. BIN
      data/uploads/1775387514884-26_L_Infiorata.jpg
  22. BIN
      data/uploads/1775387531381-28_I_Fuochi_di_Sant_Antonio.jpg
  23. BIN
      data/uploads/1775387570775-42_Antonio_Pettinicchi.JPG
  24. BIN
      data/uploads/1775387588735-44_Marcello_Scarano.jpg
  25. BIN
      data/uploads/1775387621662-48_GIuseppe_Eliseo.jpg
  26. BIN
      data/uploads/1775387634366-48_I_Murales.jpg
  27. BIN
      data/uploads/1775403395796-10_Campobasso.jpg
  28. BIN
      data/uploads/1777900605330-logo04.jpg
  29. BIN
      data/uploads/1777902340532-vecteezy_ai-generated-futuristic-data-cable-network_38464261.jpeg
  30. BIN
      data/uploads/1777902363931-logo-afasystems-bianco-orizzontale.png
  31. BIN
      data/uploads/1777902386600-logo-afasystems-blu-orizzontale.png
  32. BIN
      data/uploads/1777903077110-fibra-ottica-blu-con-cavo-ethernet.jpg
  33. BIN
      data/uploads/1777903155955-ai-concetto-di-dispositivo-alimentato.jpg
  34. BIN
      data/uploads/1777903241584-vecteezy_ai-generated-aerial-top-down-drone-view-autonomous-self_35872738.jpg
  35. BIN
      data/uploads/1777903347085-majornet-cloud-privato-on-premises-cybersecurity-01.jpg
  36. BIN
      data/uploads/1777903640444-sistemi-di-ia-1536x861.webp
  37. BIN
      data/uploads/1777903785702-vecteezy_modern-black-telephone-on-a-dark-surface-showcasing-its_55270514.jpeg
  38. +80
    -0
      lib/db.ts
  39. BIN
      public/uploads/1775324170996-Screenshot-2025-05-23-112230.png
  40. BIN
      public/uploads/1775324206182-Screenshot-2025-05-23-112230.png
  41. BIN
      public/uploads/1775324250297-Screenshot-2025-06-01-131252.png
  42. BIN
      public/uploads/1775325596766-Screenshot-2025-05-27-160115.png
  43. BIN
      public/uploads/1775325874210-Screenshot-2025-05-11-162628.png
  44. BIN
      public/uploads/1775326516712-Screenshot-2025-03-10-151935.png
  45. BIN
      public/uploads/1775326578909-Screenshot-2025-03-30-122754.png
  46. BIN
      public/uploads/1775326604288-Screenshot-2025-06-14-150740.png
  47. BIN
      public/uploads/1775327058687-Screenshot-2025-06-17-002347.png
  48. BIN
      public/uploads/1775385385658-10-Campobasso.jpg
  49. BIN
      public/uploads/1775385414456-10-Campobasso.jpg
  50. BIN
      public/uploads/1775385433443-10-Campobasso.jpg
  51. BIN
      public/uploads/1775385457017-10_Campobasso.jpg
  52. BIN
      public/uploads/1775385460830-10_Campobasso.jpg
  53. BIN
      public/uploads/1775385607750-01-La-Casa-della-Scuola.jpg
  54. +35
    -0
      types/index.ts

+ 388
- 0
app/admin/page.tsx Ver fichero

@@ -0,0 +1,388 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, Portal } from '@/types';
export default function AdminDashboard() {
const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards');
// Card State
const [cards, setCards] = useState<Card[]>([]);
const [isEditing, setIsEditing] = useState<Partial<Card> | null>(null);
// Portal State
const [portal, setPortal] = useState<Partial<Portal>>({});
const [savingPortal, setSavingPortal] = useState(false);
const [uploading, setUploading] = useState<{ [key: string]: boolean }>({});
// NEW UI STATES: Toast and Confirm Dialog
const [toast, setToast] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null);
// Helper to show auto-dismissing toast
const showToast = (message: string) => {
setToast(message);
setTimeout(() => setToast(null), 3000);
};
useEffect(() => {
fetch('/api/cards').then(res => res.json()).then(setCards);
fetch('/api/portals').then(res => res.json()).then(data => data && setPortal(data));
}, []);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => {
if (!e.target.files?.[0]) return;
setUploading(prev => ({ ...prev, [field]: true }));
const formData = new FormData();
formData.append('file', e.target.files[0]);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
if (isPortal) {
setPortal(prev => ({ ...prev, [field]: data.url }));
} else {
setIsEditing(prev => ({ ...prev, [field]: data.url }));
}
}
setUploading(prev => ({ ...prev, [field]: false }));
};
const handleSaveCard = async () => {
if (!isEditing) return;
const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card;
await fetch('/api/cards', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard)
});
setCards(prev => {
const exists = prev.find(c => c.id === newCard.id);
return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard];
});
setIsEditing(null);
};
const handleDeleteCard = (id: string) => {
// Replace window.confirm with our custom dialog
setConfirmDialog({
message: 'Are you sure you want to delete this card? This action cannot be undone.',
onConfirm: async () => {
await fetch(`/api/cards?id=${id}`, { method: 'DELETE' });
setCards(prev => prev.filter(c => c.id !== id));
setConfirmDialog(null);
showToast('Card successfully deleted.');
}
});
};
const moveCard = async (index: number, direction: 'up' | 'down') => {
const newCards = [...cards];
if (direction === 'up' && index > 0) {
[newCards[index - 1], newCards[index]] = [newCards[index], newCards[index - 1]];
} else if (direction === 'down' && index < newCards.length - 1) {
[newCards[index + 1], newCards[index]] = [newCards[index], newCards[index + 1]];
} else {
return; // Do nothing if trying to move out of bounds
}
// Recalculate displayOrder for the whole array
const updatedCards = newCards.map((c, i) => ({ ...c, displayOrder: i }));
// Optimistically update the UI
setCards(updatedCards);
// Persist the new order to the backend
await fetch('/api/cards', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedCards)
});
};
const handleSavePortal = async () => {
setSavingPortal(true);
await fetch('/api/portals', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(portal)
});
setSavingPortal(false);
showToast('Portal settings saved successfully!'); // Replaced window.alert
};
// 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";
return (
<div className="min-h-screen bg-gray-50 font-sans pb-12">
{/* Top Header */}
<div className="bg-blue-900 text-white shadow-md py-6 px-4">
<div className="max-w-5xl mx-auto flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Captive Portal CMS</h1>
<p className="text-sm text-blue-200">Local Administration</p>
</div>
<a href="/" target="_blank" className="bg-blue-800 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm transition-colors">
View Live Portal ↗
</a>
</div>
</div>
<div className="max-w-5xl mx-auto mt-8 px-4">
{/* Tab Navigation */}
<div className="flex space-x-2 mb-6">
<button onClick={() => setActiveTab('cards')} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'cards' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}>
Manage Cards
</button>
<button onClick={() => setActiveTab('settings')} className={`px-6 py-3 rounded-t-lg font-bold transition-colors ${activeTab === 'settings' ? 'bg-white text-blue-700 border-t-4 border-blue-600 shadow-sm' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}>
Portal Settings
</button>
</div>
<div className="bg-white rounded-b-xl rounded-tr-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px]">
{/* TAB: CARDS */}
{activeTab === 'cards' && (
<div className="p-6 md:p-8">
<div className="flex justify-between items-center mb-8 border-b pb-4">
<h2 className="text-xl font-bold text-gray-800">Card Grid</h2>
<button onClick={() => setIsEditing({ title: '', cardType: 'INFO_PAGE', displayOrder: cards.length })} className="bg-blue-600 text-white px-5 py-2.5 rounded-lg shadow-sm hover:bg-blue-700 font-medium">
+ Add New Card
</button>
</div>
<div className="space-y-3 mb-8">
{cards.length === 0 && <p className="text-gray-500 italic text-center py-8">No cards available. Create one to get started.</p>}
{cards.map((card, idx) => (
// CHANGED: flex-col on mobile, flex-row on sm+, added gap-4 for mobile spacing
<div key={card.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors gap-4">
<div className="flex items-center gap-4">
{card.imageUrl ? <img src={card.imageUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" /> : <div className="w-16 h-16 bg-gray-200 rounded-md shadow-sm flex items-center justify-center text-gray-400 text-xs shrink-0">No Image</div>}
<div>
<span className="font-semibold text-gray-800 block">{card.title}</span>
<span className="text-xs text-gray-500 uppercase tracking-wider">{card.cardType}</span>
</div>
</div>
{/* CHANGED: flex-wrap to ensure buttons don't overflow on small screens, w-full on mobile */}
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-end">
<button onClick={() => moveCard(idx, 'up')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Up">↑</button>
<button onClick={() => moveCard(idx, 'down')} className="p-2 text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded" title="Move Down">↓</button>
<div className="w-px h-6 bg-gray-300 mx-1 hidden sm:block"></div>
<button onClick={() => setIsEditing(card)} className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded font-medium">Edit</button>
<button onClick={() => handleDeleteCard(card.id)} className="px-4 py-2 text-red-600 hover:bg-red-50 rounded font-medium">Delete</button>
</div>
</div>
))}
</div>
</div>
)}
{/* TAB: SETTINGS */}
{activeTab === 'settings' && (
<div className="p-6 md:p-8">
<h2 className="text-xl font-bold text-gray-800 mb-8 border-b pb-4">Global Portal Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
<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} />
</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`} />
</div>
<div className="flex gap-8">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Theme Color</label>
<div className="flex items-center gap-4">
<input type="color" value={portal.themeColor || '#1e3a8a'} onChange={e => setPortal({...portal, themeColor: e.target.value})} className="h-12 w-12 rounded cursor-pointer border-0 p-0" />
<span className="text-gray-900 font-mono font-medium">{portal.themeColor || '#1e3a8a'}</span>
</div>
</div>
{/* NEW: Max Columns Setting updated for 3 */}
<div className="flex-1">
<label className="block text-sm font-semibold text-gray-700 mb-1">Grid Max Columns: {portal.maxGridColumns || 5}</label>
<input
type="range"
min="3"
max="8"
value={portal.maxGridColumns || 5}
onChange={e => setPortal({...portal, maxGridColumns: parseInt(e.target.value)})}
className="w-full mt-3 accent-blue-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span>
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* Logo Upload with Remove Button */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Logo Image</label>
<input type="file" accept="image/*" onChange={e => handleUpload(e, 'logoUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" />
{uploading['logoUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
{portal.logoUrl && (
<div className="mt-2 bg-gray-100 p-4 rounded inline-block relative border">
<img src={portal.logoUrl} className="h-16 object-contain" alt="Logo Preview" />
<button onClick={() => setPortal({...portal, logoUrl: ''})} className="absolute -top-2 -right-2 bg-red-500 text-white w-6 h-6 rounded-full text-xs font-bold hover:bg-red-600 shadow">✕</button>
</div>
)}
</div>
{/* Hero Upload with Remove Button */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1">Hero Background Image</label>
<input type="file" accept="image/*" onChange={e => handleUpload(e, 'heroImageUrl', true)} className="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-gray-100 cursor-pointer" />
{uploading['heroImageUrl'] && <span className="text-xs text-blue-500">Uploading...</span>}
{portal.heroImageUrl && (
<div className="mt-2 relative rounded shadow border inline-block w-full">
<img src={portal.heroImageUrl} className="h-32 w-full object-cover rounded" alt="Hero Preview" />
<button onClick={() => setPortal({...portal, heroImageUrl: ''})} className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold hover:bg-red-600 shadow-lg">✕</button>
</div>
)}
</div>
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={!!portal.fadeHeroImage} onChange={e => setPortal({...portal, fadeHeroImage: e.target.checked})} className="w-5 h-5 text-blue-600 rounded" />
<div>
<span className="block text-sm font-semibold text-gray-900">Fade Image into Background Color</span>
<span className="block text-xs text-gray-600">Creates a smooth gradient from the top of the image into the solid theme color at the bottom.</span>
</div>
</label>
</div>
</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'}
</button>
</div>
</div>
)}
</div>
</div>
{/* MODAL FOR EDITING/CREATING CARDS */}
{isEditing && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 transition-opacity"
onClick={() => setIsEditing(null)} // Click outside to close
>
<div
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl p-6 md:p-8 relative animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()} // Prevent inside clicks from closing
>
<button
onClick={() => setIsEditing(null)}
className="absolute top-6 right-6 text-gray-400 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center transition-colors"
>
</button>
<h3 className="text-2xl font-bold mb-6 text-gray-900 border-b pb-4">
{isEditing.id ? 'Edit Card' : 'Create New Card'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Title</label>
<input type="text" value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" />
</div>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label>
<select value={isEditing.cardType || 'INFO_PAGE'} onChange={e => setIsEditing({...isEditing, cardType: e.target.value as any})} className={inputClasses}>
<option value="INFO_PAGE">Info Page</option>
<option value="IMAGE_GALLERY">Image Gallery</option>
<option value="EXTERNAL_LINK">External Link</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label>
<textarea value={isEditing.shortDescription || ''} onChange={e => setIsEditing({...isEditing, shortDescription: e.target.value})} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." />
</div>
</div>
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Cover Image</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center 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" />
{uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading image...</p>}
</div>
{isEditing.imageUrl && (
<div className="mt-4 relative rounded-lg overflow-hidden border border-gray-200 group">
<img src={isEditing.imageUrl} className="w-full h-40 object-cover" alt="Preview" />
<button
onClick={() => setIsEditing({...isEditing, imageUrl: ''})}
className="absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full text-sm font-bold shadow opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
title="Remove Image"
>
</button>
</div>
)}
</div>
</div>
</div>
<div className="flex gap-3 pt-8 mt-6 border-t border-gray-200 justify-end">
<button onClick={() => setIsEditing(null)} className="px-5 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors">
Cancel
</button>
<button onClick={handleSaveCard} className="bg-green-600 text-white px-8 py-2.5 rounded-lg hover:bg-green-700 font-medium shadow-sm transition-colors">
Save Card
</button>
</div>
</div>
</div>
)}
{/* CUSTOM CONFIRM DIALOG */}
{confirmDialog && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in zoom-in-95">
<h3 className="text-xl font-bold text-gray-900 mb-2">Confirm Action</h3>
<p className="text-gray-600 mb-6 leading-relaxed">{confirmDialog.message}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setConfirmDialog(null)}
className="px-4 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
onClick={confirmDialog.onConfirm}
className="px-6 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors shadow-sm"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* CUSTOM TOAST NOTIFICATION */}
{toast && (
<div className="fixed bottom-6 right-6 z-[70] bg-gray-900 text-white px-6 py-4 rounded-lg shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 fade-in duration-300">
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-gray-900 font-bold text-sm">
</div>
<span className="font-medium">{toast}</span>
</div>
)}
</div>
);
}

+ 63
- 0
app/api/cards/route.ts Ver fichero

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { getCards, saveCards } from '@/lib/db';
import { Card } from '@/types';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const portalId = searchParams.get('portalId');
const cards = await getCards(portalId || undefined);
return NextResponse.json(cards);
}
export async function POST(request: Request) {
try {
const incomingCard: Card = await request.json();
const cards = await getCards();
const existingIndex = cards.findIndex(c => c.id === incomingCard.id);
if (existingIndex >= 0) {
cards[existingIndex] = incomingCard;
} else {
cards.push(incomingCard);
}
await saveCards(cards);
revalidatePath('/'); // Force public portal to update instantly
return NextResponse.json(incomingCard, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to save card' }, { status: 500 });
}
}
// NEW: Bulk update for saving card reordering
export async function PUT(request: Request) {
try {
const updatedCards: Card[] = await request.json();
await saveCards(updatedCards);
revalidatePath('/'); // Force public portal to update instantly
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to reorder cards' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'Card ID required' }, { status: 400 });
const cards = await getCards();
const filteredCards = cards.filter(c => c.id !== id);
await saveCards(filteredCards);
revalidatePath('/'); // Force public portal to update instantly
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to delete card' }, { status: 500 });
}
}

+ 35
- 0
app/api/files/route.ts Ver fichero

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const name = searchParams.get('name');
if (!name) return new NextResponse('File name required', { status: 400 });
const filePath = path.join(process.cwd(), 'data', 'uploads', name);
try {
const fileBuffer = fs.readFileSync(filePath);
// Determine basic mime types
const ext = path.extname(name).toLowerCase();
let mimeType = 'image/jpeg';
if (ext === '.png') mimeType = 'image/png';
if (ext === '.gif') mimeType = 'image/gif';
if (ext === '.svg') mimeType = 'image/svg+xml';
if (ext === '.webp') mimeType = 'image/webp';
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': mimeType,
'Cache-Control': 'public, max-age=86400', // Cache in browser for 1 day
},
});
} catch (error) {
return new NextResponse('Image not found', { status: 404 });
}
}

+ 33
- 0
app/api/portals/route.ts Ver fichero

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache'; // ADD THIS
import { getPortals, savePortals } from '@/lib/db';
import { Portal } from '@/types';
export const dynamic = 'force-dynamic';
export async function GET() {
const portals = await getPortals();
return NextResponse.json(portals[0] || null);
}
export async function POST(request: Request) {
try {
const incomingPortal: Portal = await request.json();
const portals = await getPortals();
if (portals.length > 0) {
portals[0] = { ...portals[0], ...incomingPortal };
} else {
portals.push({ ...incomingPortal, id: 'default-portal', tenantId: 'default' });
}
await savePortals(portals);
// ADD THIS LINE
revalidatePath('/');
return NextResponse.json(portals[0], { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 });
}
}

+ 30
- 0
app/api/upload/route.ts Ver fichero

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file received.' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
// Strip special characters to prevent URL breaking
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
const filename = `${Date.now()}-${safeName}`;
// Save to data/uploads instead of public/uploads
const uploadDir = path.join(process.cwd(), 'data', 'uploads');
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, filename), buffer);
// Return a dynamic API route URL instead of a static path
return NextResponse.json({ url: `/api/files?name=${filename}` }, { status: 201 });
} catch (error) {
console.error('Upload Error:', error);
return NextResponse.json({ error: 'Failed to upload image.' }, { status: 500 });
}
}

+ 23
- 62
app/page.tsx Ver fichero

@@ -1,65 +1,26 @@
import Image from "next/image";
import { getCards, getPortals } from '@/lib/db';
import PublicGrid from '@/components/PublicGrid';
import HeroBanner from '@/components/HeroBanner';

export const dynamic = 'force-dynamic';

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


export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<main className="min-h-screen bg-gray-100 font-sans">
<HeroBanner portal={portal} />
<div className="max-w-[1600px] mx-auto py-12 px-4">
{cards.length > 0 ? (
<PublicGrid cards={cards} maxCols={portal.maxGridColumns || 5} />
) : (
<div className="text-center text-gray-500 py-20">
<p>No cards have been added yet.</p>
</div>
)}
</div>
</main>
); );
}
}

+ 43
- 0
components/HeroBanner.tsx Ver fichero

@@ -0,0 +1,43 @@
import { Portal } from '@/types';
export default function HeroBanner({ portal }: { portal: Partial<Portal> }) {
const themeColor = portal?.themeColor || '#1e3a8a';
return (
<div
className="relative text-white text-center py-20 px-4 min-h-[40vh] flex flex-col justify-center"
style={{
backgroundImage: portal?.heroImageUrl ? `url(${portal.heroImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: themeColor
}}
>
{/* Dynamic Overlay: Either a solid dark tint, or a fade into the theme color */}
<div
className="absolute inset-0"
style={{
background: portal?.fadeHeroImage
? `linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, ${themeColor} 100%)`
: 'rgba(0,0,0,0.6)'
}}
></div>
<div className="relative z-10 max-w-4xl mx-auto flex flex-col items-center">
{portal?.logoUrl && (
<img
src={portal.logoUrl}
alt="Institution Logo"
className="h-24 mb-6 object-contain bg-white/90 p-2 rounded-xl shadow-lg"
/>
)}
<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>
</div>
);
}

+ 88
- 0
components/PublicGrid.tsx Ver fichero

@@ -0,0 +1,88 @@
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/types';
export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
const [activeCard, setActiveCard] = useState<Card | null>(null);
// Prevent background scrolling when modal is open
useEffect(() => {
if (activeCard) document.body.style.overflow = 'hidden';
else document.body.style.overflow = 'unset';
return () => { document.body.style.overflow = 'unset'; }
}, [activeCard]);
// Tailwind classes mapping based on the admin's chosen max columns
const gridClasses: Record<number, string> = {
3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', // ADDED THIS LINE
4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
};
const activeGridClass = gridClasses[maxCols] || gridClasses[5];
return (
<>
<div className={`grid gap-4 ${activeGridClass}`}>
{cards.map((card) => (
<div
key={card.id}
onClick={() => setActiveCard(card)}
className="group relative cursor-pointer overflow-hidden rounded-xl shadow-md aspect-square bg-gray-200 transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
>
{card.imageUrl ? (
<img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white">
<h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
<p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
{card.shortDescription}
</p>
</div>
</div>
))}
</div>
{/* Improved Modal Pop-up */}
{activeCard && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 transition-opacity"
onClick={() => setActiveCard(null)} // Click outside to close
>
<div
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()} // Prevent clicks inside modal from closing it
>
<div className="relative h-72 w-full bg-gray-100">
{activeCard.imageUrl && (
<img src={activeCard.imageUrl} className="w-full h-full object-cover rounded-t-2xl" alt="" />
)}
{/* Improved Close Button */}
<button
onClick={() => setActiveCard(null)}
className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg"
title="Close"
>
</button>
</div>
<div className="p-8">
<div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
<h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
{activeCard.fullContent ? (
<div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
) : (
<p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
)}
</div>
</div>
</div>
)}
</>
);
}

+ 50
- 0
data/cards.txt Ver fichero

@@ -0,0 +1,50 @@
[
{
"title": "La Casa Della Scuola",
"cardType": "EXTERNAL_LINK",
"displayOrder": 0,
"shortDescription": "https://mail.afasystems.it/frontend/mail/?_task=mail&_mbox=INBOX",
"imageUrl": "/uploads/1775385607750-01-La-Casa-della-Scuola.jpg",
"id": "card-mnlmr330z43ga1fzbz"
},
{
"title": "Una storia di cultura",
"cardType": "EXTERNAL_LINK",
"displayOrder": 1,
"shortDescription": "I sistemi di videosorveglianza avanzata che tracciano in tempo reale oggetti e soggetti, sfruttando algoritmi e machine learning per gestire sicurezza, traffico, accessi, spostamenti, informazioni e molto altro ancora",
"imageUrl": "/api/files?name=1777903155955-ai-concetto-di-dispositivo-alimentato.jpg",
"id": "card-mnlnrsf4u79zqctk1y"
},
{
"title": "Eco Smart City",
"cardType": "EXTERNAL_LINK",
"displayOrder": 2,
"imageUrl": "/api/files?name=1777903241584-vecteezy_ai-generated-aerial-top-down-drone-view-autonomous-self_35872738.jpg",
"shortDescription": "Unendo la potenza della fibra ottica a soluzioni wireless evolute, affianchiamo amministrazioni pubbliche e investitori privati nella costruzione di reti intelligenti su scala locale e sovralocale, realizzando un’infrastruttura permanente, efficiente, strategica.",
"id": "card-mnlnt57lc41br0howcg"
},
{
"title": "MajorNet",
"cardType": "EXTERNAL_LINK",
"displayOrder": 3,
"shortDescription": "La piattaforma sviluppata da AFA Systems che integra in un ambiente digitale avanzato e sicuro il tuo cloud privato, gestisce la comunicazione unificata e garantisce conformità normativa (GDPR) senza compromessi",
"imageUrl": "/api/files?name=1777903347085-majornet-cloud-privato-on-premises-cybersecurity-01.jpg",
"id": "card-mnlntjvb8ximmhzdky4"
},
{
"title": "Notifica Massiva",
"cardType": "INFO_PAGE",
"displayOrder": 4,
"shortDescription": "Invio rapido e tracciabile di messaggi a grandi gruppi di destinatari.",
"imageUrl": "/api/files?name=1777903640444-sistemi-di-ia-1536x861.webp",
"id": "card-mnlnu47vi20a86uaz1l"
},
{
"title": "Whistleblowing",
"cardType": "EXTERNAL_LINK",
"displayOrder": 5,
"shortDescription": "Il servizio per i canali interni di segnalazione Whistleblowing. Semplice, sicuro, certificato.",
"imageUrl": "/api/files?name=1777903785702-vecteezy_modern-black-telephone-on-a-dark-surface-showcasing-its_55270514.jpeg",
"id": "card-mnlnucp3zhaqkurtxpp"
}
]

+ 13
- 0
data/portals.txt Ver fichero

@@ -0,0 +1,13 @@
[
{
"title": "AFA Systems",
"welcomeText": "Benvenuto in AFA Systems",
"themeColor": "#004263",
"fadeHeroImage": true,
"id": "default-portal",
"tenantId": "default",
"logoUrl": "/api/files?name=1777902386600-logo-afasystems-blu-orizzontale.png",
"heroImageUrl": "/api/files?name=1777902340532-vecteezy_ai-generated-futuristic-data-cable-network_38464261.jpeg",
"maxGridColumns": 3
}
]

BIN
data/uploads/1775387317052-02_Una_storia_di_cultura.jpg Ver fichero

Antes Después
Anchura: 640  |  Altura: 480  |  Tamaño: 59 KiB

BIN
data/uploads/1775387361745-99_header.jpg Ver fichero

Antes Después
Anchura: 600  |  Altura: 400  |  Tamaño: 30 KiB

BIN
data/uploads/1775387379798-04_Maestre_e_Maestri.jpg Ver fichero


BIN
data/uploads/1775387401522-06_La_ristrutturazione.jpg Ver fichero

Antes Después
Anchura: 640  |  Altura: 853  |  Tamaño: 96 KiB

BIN
data/uploads/1775387428350-10_Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
data/uploads/1775387439649-12_Il_centro_storico.jpg Ver fichero

Antes Después
Anchura: 768  |  Altura: 525  |  Tamaño: 70 KiB

BIN
data/uploads/1775387454549-13_Le_Istituzioni.jpg Ver fichero

Antes Después
Anchura: 1200  |  Altura: 1200  |  Tamaño: 241 KiB

BIN
data/uploads/1775387470269-16_Castel_Monforte.jpg Ver fichero


BIN
data/uploads/1775387488261-22_Le_tradizioni_popolari.jpg Ver fichero

Antes Después
Anchura: 900  |  Altura: 567  |  Tamaño: 303 KiB

BIN
data/uploads/1775387501262-24_I_Misteri.jpg Ver fichero

Antes Después
Anchura: 1280  |  Altura: 720  |  Tamaño: 228 KiB

BIN
data/uploads/1775387514884-26_L_Infiorata.jpg Ver fichero

Antes Después
Anchura: 1000  |  Altura: 718  |  Tamaño: 109 KiB

BIN
data/uploads/1775387531381-28_I_Fuochi_di_Sant_Antonio.jpg Ver fichero

Antes Después
Anchura: 2048  |  Altura: 1365  |  Tamaño: 577 KiB

BIN
data/uploads/1775387570775-42_Antonio_Pettinicchi.JPG Ver fichero

Antes Después
Anchura: 1600  |  Altura: 1200  |  Tamaño: 194 KiB

BIN
data/uploads/1775387588735-44_Marcello_Scarano.jpg Ver fichero

Antes Después
Anchura: 736  |  Altura: 570  |  Tamaño: 92 KiB

BIN
data/uploads/1775387621662-48_GIuseppe_Eliseo.jpg Ver fichero

Antes Después
Anchura: 735  |  Altura: 553  |  Tamaño: 139 KiB

BIN
data/uploads/1775387634366-48_I_Murales.jpg Ver fichero

Antes Después
Anchura: 1024  |  Altura: 768  |  Tamaño: 135 KiB

BIN
data/uploads/1775403395796-10_Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
data/uploads/1777900605330-logo04.jpg Ver fichero

Antes Después
Anchura: 540  |  Altura: 333  |  Tamaño: 26 KiB

BIN
data/uploads/1777902340532-vecteezy_ai-generated-futuristic-data-cable-network_38464261.jpeg Ver fichero

Antes Después
Anchura: 5824  |  Altura: 3264  |  Tamaño: 7.5 MiB

BIN
data/uploads/1777902363931-logo-afasystems-bianco-orizzontale.png Ver fichero

Antes Después
Anchura: 2667  |  Altura: 1125  |  Tamaño: 43 KiB

BIN
data/uploads/1777902386600-logo-afasystems-blu-orizzontale.png Ver fichero

Antes Después
Anchura: 2667  |  Altura: 1125  |  Tamaño: 47 KiB

BIN
data/uploads/1777903077110-fibra-ottica-blu-con-cavo-ethernet.jpg Ver fichero

Antes Después
Anchura: 6720  |  Altura: 4480  |  Tamaño: 2.5 MiB

BIN
data/uploads/1777903155955-ai-concetto-di-dispositivo-alimentato.jpg Ver fichero

Antes Después
Anchura: 4096  |  Altura: 2800  |  Tamaño: 4.3 MiB

BIN
data/uploads/1777903241584-vecteezy_ai-generated-aerial-top-down-drone-view-autonomous-self_35872738.jpg Ver fichero

Antes Después
Anchura: 4000  |  Altura: 2667  |  Tamaño: 4.6 MiB

BIN
data/uploads/1777903347085-majornet-cloud-privato-on-premises-cybersecurity-01.jpg Ver fichero

Antes Después
Anchura: 2688  |  Altura: 1792  |  Tamaño: 2.7 MiB

BIN
data/uploads/1777903640444-sistemi-di-ia-1536x861.webp Ver fichero

Antes Después

BIN
data/uploads/1777903785702-vecteezy_modern-black-telephone-on-a-dark-surface-showcasing-its_55270514.jpeg Ver fichero

Antes Después
Anchura: 8736  |  Altura: 4896  |  Tamaño: 15 MiB

+ 80
- 0
lib/db.ts Ver fichero

@@ -0,0 +1,80 @@
import fs from 'fs/promises';
import path from 'path';
import { Portal, Card } from '@/types';
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
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, '[]'); }
}
export async function getPortals(): Promise<Portal[]> {
await ensureDb();
const data = await fs.readFile(PORTALS_FILE, 'utf-8');
return JSON.parse(data || '[]');
}
export async function getCards(portalId?: string): Promise<Card[]> {
await ensureDb();
const data = await fs.readFile(CARDS_FILE, 'utf-8');
let cards: Card[] = JSON.parse(data || '[]');
if (portalId) {
cards = cards.filter(c => c.portalId === portalId);
}
// ALWAYS sort, regardless of whether portalId was passed
return cards.sort((a, b) => a.displayOrder - b.displayOrder);
}
export async function saveCards(cards: Card[]): Promise<void> {
await ensureDb();
await fs.writeFile(CARDS_FILE, JSON.stringify(cards, null, 2));
}
// Seed function for "Casa della scuola"
export async function seedDatabase() {
const portalId = 'uuid-casa-della-scuola';
const portals: Portal[] = [{
id: portalId,
tenantId: 'casa-della-scuola',
title: 'Benvenuti alla Casa della Scuola',
welcomeText: 'Discover the rich history and beautiful landscapes of our heritage.',
heroImageUrl: '/hero-bg.jpg',
logoUrl: '/logo.png',
themeColor: '#1e3a8a'
}];
const cards: Card[] = [
{
id: 'uuid-card-1', portalId, title: 'History before the war',
imageUrl: '/history.jpg', shortDescription: 'Explore the origins.',
fullContent: '<p>Long text about the history...</p>', cardType: 'INFO_PAGE', displayOrder: 1
},
{
id: 'uuid-card-2', portalId, title: 'Pettinicchio Biography',
imageUrl: '/pettinicchio.jpg', shortDescription: 'Life and legacy.',
fullContent: '<p>Biography details...</p>', cardType: 'INFO_PAGE', displayOrder: 2
},
{
id: 'uuid-card-3', portalId, title: 'Campobasso Landscapes',
imageUrl: '/campobasso.jpg', shortDescription: 'Scenic views of the region.',
fullContent: '', cardType: 'IMAGE_GALLERY', displayOrder: 3
}
];
await fs.writeFile(PORTALS_FILE, JSON.stringify(portals, null, 2));
await fs.writeFile(CARDS_FILE, JSON.stringify(cards, null, 2));
}
export async function savePortals(portals: Portal[]): Promise<void> {
const fs = require('fs/promises');
const path = require('path');
const PORTALS_FILE = path.join(process.cwd(), 'data', 'portals.txt');
await fs.writeFile(PORTALS_FILE, JSON.stringify(portals, null, 2));
}

BIN
public/uploads/1775324170996-Screenshot-2025-05-23-112230.png Ver fichero

Antes Después
Anchura: 867  |  Altura: 755  |  Tamaño: 12 KiB

BIN
public/uploads/1775324206182-Screenshot-2025-05-23-112230.png Ver fichero

Antes Después
Anchura: 867  |  Altura: 755  |  Tamaño: 12 KiB

BIN
public/uploads/1775324250297-Screenshot-2025-06-01-131252.png Ver fichero

Antes Después
Anchura: 568  |  Altura: 426  |  Tamaño: 13 KiB

BIN
public/uploads/1775325596766-Screenshot-2025-05-27-160115.png Ver fichero

Antes Después
Anchura: 1426  |  Altura: 1091  |  Tamaño: 333 KiB

BIN
public/uploads/1775325874210-Screenshot-2025-05-11-162628.png Ver fichero

Antes Después
Anchura: 1592  |  Altura: 1554  |  Tamaño: 2.5 MiB

BIN
public/uploads/1775326516712-Screenshot-2025-03-10-151935.png Ver fichero

Antes Después
Anchura: 1080  |  Altura: 372  |  Tamaño: 206 KiB

BIN
public/uploads/1775326578909-Screenshot-2025-03-30-122754.png Ver fichero

Antes Después
Anchura: 587  |  Altura: 216  |  Tamaño: 23 KiB

BIN
public/uploads/1775326604288-Screenshot-2025-06-14-150740.png Ver fichero

Antes Después
Anchura: 1248  |  Altura: 791  |  Tamaño: 1.0 MiB

BIN
public/uploads/1775327058687-Screenshot-2025-06-17-002347.png Ver fichero

Antes Después
Anchura: 445  |  Altura: 294  |  Tamaño: 13 KiB

BIN
public/uploads/1775385385658-10-Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
public/uploads/1775385414456-10-Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
public/uploads/1775385433443-10-Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
public/uploads/1775385457017-10_Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
public/uploads/1775385460830-10_Campobasso.jpg Ver fichero

Antes Después
Anchura: 1920  |  Altura: 1097  |  Tamaño: 838 KiB

BIN
public/uploads/1775385607750-01-La-Casa-della-Scuola.jpg Ver fichero

Antes Después
Anchura: 600  |  Altura: 541  |  Tamaño: 83 KiB

+ 35
- 0
types/index.ts Ver fichero

@@ -0,0 +1,35 @@
export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST';
export interface Portal {
id: string;
tenantId: string;
title: string;
welcomeText: string;
heroImageUrl: string;
logoUrl: string;
themeColor: string;
}
export interface Card {
id: string;
portalId: string;
title: string;
imageUrl: string;
shortDescription: string;
fullContent: string;
cardType: CardType;
actionUrl?: string;
displayOrder: number;
}
export interface Portal {
id: string;
tenantId: string;
title: string;
welcomeText: string;
heroImageUrl: string;
logoUrl: string;
themeColor: string;
fadeHeroImage?: boolean;
maxGridColumns?: number;
}

Cargando…
Cancelar
Guardar