コミットを比較

...

24 コミット

作成者 SHA1 メッセージ 日付
  Lorenzo Pollutri 71de472985 Download Font locali 3日前
  Lorenzo Pollutri 41c4a24c62 External Link configurabile 2 3日前
  Lorenzo Pollutri 413ec37343 External Link configurabile 3日前
  Lorenzo Pollutri 923e6fe6f7 Ridimensionamento faschia della descrizione in caso manch 3日前
  Lorenzo Pollutri e425f583b2 Falback popolamento cover 2 3日前
  Lorenzo Pollutri 7f0b1d1139 Falback popolamento cover 3日前
  Lorenzo Pollutri 3ee08f34fe Fix zoom function on PC 3日前
  Lorenzo Pollutri 40e1202f00 Preview non caricata qunado è a schermo intero 3日前
  Lorenzo Pollutri a9e6e55132 Mute audio fix 2 3日前
  Lorenzo Pollutri ba86f8fbe8 Mute bug fix 3日前
  Lorenzo Pollutri 4ae64d7be0 Skip Preview tag 3日前
  Lorenzo Pollutri 2cfe4cfb3b Fullscreen bug fix 3日前
  Lorenzo Pollutri d781432910 Fix Fullscreen bug 3日前
  Lorenzo Pollutri 560a4d21cf AutoFullscreen Option 3日前
  Lorenzo Pollutri 14c68382bc Layout update per il video player 3日前
  Lorenzo Pollutri 0b83447024 Layout update per il video player 3日前
  Lorenzo Pollutri 613e4ecb27 Visualizzatore di PDF 4日前
  Lorenzo Pollutri eca6ab3e95 Video Fullscreen fix 4日前
  Lorenzo Pollutri 461c58dc04 Fullscreem view 4日前
  Lorenzo Pollutri c9e9022d30 integrazione video 4日前
  Lorenzo Pollutri ab143871b1 fix cross origin worning 4日前
  Lorenzo Pollutri 38bb64b83f debug page 4日前
  Lorenzo Pollutri 9bac7fda7d admin debug 4日前
  Lorenzo Pollutri 7d5d31afe6 Sviluppata funzionalità per il carrello di immagini per le cards 4日前
10個のファイルの変更1030行の追加175行の削除
分割表示
  1. +5
    -0
      README.md
  2. +346
    -23
      app/admin/page.tsx
  3. +84
    -35
      app/api/files/route.ts
  4. +6
    -15
      app/layout.tsx
  5. +561
    -88
      components/PublicGrid.tsx
  6. +3
    -0
      lib/config.ts
  7. +12
    -3
      lib/db.ts
  8. +1
    -1
      next.config.ts
  9. +4
    -1
      package.json
  10. +8
    -9
      types/index.ts

+ 5
- 0
README.md ファイルの表示

@@ -34,3 +34,8 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

## Configurazione

Per configruare usare il file lib/config.ts
Per disattivare l'utilizzo di external link, settare a false la variabile EXTERNAL_LINK_ENABLED a false;

+ 346
- 23
app/admin/page.tsx ファイルの表示

@@ -1,30 +1,117 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, Portal } from '@/types';
import { Card, Portal, MediaItem } from '@/types';
import { EXTERNAL_LINK_ENABLED } from '@/lib/config';
const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url);
const isPdfFile = (file: File) =>
file.type === 'application/pdf' || /\.pdf$/i.test(file.name);
const isVideoFile = (file: File) =>
file.type.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/i.test(file.name);
async function uploadBlobAsImage(blob: Blob, name: string): Promise<string | null> {
const formData = new FormData();
formData.append('file', new File([blob], name, { type: blob.type || 'image/png' }));
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
return data.url || null;
}
async function extractVideoFrame(file: File): Promise<Blob | null> {
const url = URL.createObjectURL(file);
try {
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
video.src = url;
await new Promise<void>((resolve, reject) => {
video.addEventListener('loadedmetadata', () => resolve(), { once: true });
video.addEventListener('error', () => reject(new Error('video load error')), { once: true });
});
// Seek slightly past 0 — at exactly 0 some codecs return a black frame
video.currentTime = Math.min(0.1, Math.max(0, video.duration / 10));
await new Promise<void>((resolve, reject) => {
video.addEventListener('seeked', () => resolve(), { once: true });
video.addEventListener('error', () => reject(new Error('video seek error')), { once: true });
});
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0);
return await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85)
);
} finally {
URL.revokeObjectURL(url);
}
}
async function pdfToImageItems(
file: File,
onProgress: (page: number, total: number) => void
): Promise<MediaItem[]> {
const pdfjs = await import('pdfjs-dist');
// Worker file is copied to /public via the postinstall script
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
const baseName = file.name.replace(/\.pdf$/i, '').replace(/[^a-zA-Z0-9-_]/g, '_');
const items: MediaItem[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
onProgress(i, pdf.numPages);
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
await page.render({ canvasContext: ctx, viewport }).promise;
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
const url = await uploadBlobAsImage(blob, `${baseName}-page${i}.png`);
if (url) items.push({ url });
}
return items;
}
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);
const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | 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));
@@ -33,12 +120,12 @@ export default function AdminDashboard() {
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 }));
@@ -49,6 +136,104 @@ export default function AdminDashboard() {
setUploading(prev => ({ ...prev, [field]: false }));
};
const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(prev => ({ ...prev, extraMedia: true }));
const startedWithoutCover = !isEditing?.imageUrl;
let pendingCover: string | null = null;
const canPromote = () => startedWithoutCover && !pendingCover;
const uploaded: MediaItem[] = [];
for (const file of Array.from(files)) {
try {
if (isPdfFile(file)) {
const items = await pdfToImageItems(file, (page, total) =>
setPdfProgress({ name: file.name, page, total })
);
setPdfProgress(null);
if (items.length > 0 && canPromote()) {
// Promote the first PDF page to cover; skip it from the gallery to avoid duplication.
pendingCover = items[0].url;
uploaded.push(...items.slice(1));
} else {
uploaded.push(...items);
}
} else {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.url) continue;
if (isVideoFile(file)) {
// Video always goes to the gallery so users can play it.
uploaded.push({ url: data.url });
// If no cover yet, extract the first frame and use it as the cover.
if (canPromote()) {
try {
const blob = await extractVideoFrame(file);
if (blob) {
const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9-_]/g, '_');
const posterUrl = await uploadBlobAsImage(blob, `${baseName}-poster.jpg`);
if (posterUrl) pendingCover = posterUrl;
}
} catch (err) {
console.warn('Could not extract video poster for', file.name, err);
}
}
} else {
// Plain image
if (canPromote()) {
// Promote to cover; skip the gallery to avoid duplication.
pendingCover = data.url;
} else {
uploaded.push({ url: data.url });
}
}
}
} catch (err) {
console.error('Upload failed for', file.name, err);
showToast(`Failed to process "${file.name}".`);
setPdfProgress(null);
}
}
setIsEditing(prev => ({
...prev,
imageUrl: (startedWithoutCover && pendingCover) ? pendingCover : (prev?.imageUrl || ''),
extraMedia: [...(prev?.extraMedia || []), ...uploaded],
}));
setUploading(prev => ({ ...prev, extraMedia: false }));
e.target.value = '';
};
const removeExtraMedia = (index: number) => {
setIsEditing(prev => ({
...prev,
extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index),
}));
};
const toggleAutoplay = (index: number) => {
setIsEditing(prev => ({
...prev,
extraMedia: (prev?.extraMedia || []).map((m, i) =>
i === index ? { ...m, autoplay: !m.autoplay } : m
),
}));
};
const toggleMuted = (index: number) => {
setIsEditing(prev => ({
...prev,
extraMedia: (prev?.extraMedia || []).map((m, i) =>
i === index ? { ...m, muted: !m.muted } : m
),
}));
};
const handleSaveCard = async () => {
if (!isEditing) return;
const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
@@ -159,7 +344,15 @@ export default function AdminDashboard() {
// 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>}
{(() => {
const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
if (!previewUrl) {
return <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>;
}
return isVideoUrl(previewUrl)
? <video src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" muted playsInline preload="metadata" />
: <img src={previewUrl} className="w-16 h-16 object-cover rounded-md shadow-sm shrink-0" alt="" />;
})()}
<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>
@@ -306,31 +499,161 @@ export default function AdminDashboard() {
<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>
{EXTERNAL_LINK_ENABLED && <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..." />
{isEditing.cardType === 'EXTERNAL_LINK' ? (
<>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">URL</label>
<input
type="url"
value={isEditing.actionUrl || ''}
onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })}
className={inputClasses}
placeholder="https://esempio.it/pagina"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label>
<input
type="text"
value={isEditing.shortDescription || ''}
onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })}
className={inputClasses}
placeholder="es. Visita il sito ufficiale"
/>
<p className="text-xs text-gray-500 mt-1">Testo visualizzato come link cliccabile nel modale. Se vuoto, viene mostrata l&rsquo;URL stessa.</p>
</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 className="bg-gray-50 p-3 rounded-lg border border-gray-200 space-y-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!isEditing.autoFullscreen}
onChange={e => setIsEditing({ ...isEditing, autoFullscreen: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded mt-0.5"
/>
<div>
<span className="block text-sm font-semibold text-gray-900">Auto fullscreen</span>
<span className="block text-xs text-gray-600">Open the gallery in fullscreen immediately when the user clicks this card.</span>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!isEditing.skipPreview}
onChange={e => setIsEditing({ ...isEditing, skipPreview: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded mt-0.5"
/>
<div>
<span className="block text-sm font-semibold text-gray-900">Skip preview</span>
<span className="block text-xs text-gray-600">Don&rsquo;t show the cover as a slide in the gallery. The cover stays as the card thumbnail only. Combine with &ldquo;Auto fullscreen&rdquo; to jump straight into the gallery items.</span>
</div>
</label>
</div>
</div>
<div className="space-y-5">
{/* Cover Image */}
<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">
<label className="block text-sm font-semibold text-gray-800 mb-1">
Cover Image <span className="text-gray-400 font-normal text-xs">(shown in grid)</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 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>}
{uploading['imageUrl'] && <p className="mt-2 text-sm text-blue-600 font-medium">Uploading...</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: ''})}
<div className="mt-3 relative rounded-lg overflow-hidden border border-gray-200 group">
<img src={isEditing.imageUrl} className="w-full h-32 object-cover" alt="Cover 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>
title="Remove cover image"
>✕</button>
</div>
)}
</div>
{/* Gallery Media (images + videos + PDFs) */}
<div>
<label className="block text-sm font-semibold text-gray-800 mb-1">
Gallery Media <span className="text-gray-400 font-normal text-xs">(images, videos or PDFs — PDF pages become slides)</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<input
type="file"
accept="image/*,video/*,application/pdf,.pdf"
multiple
onChange={handleUploadExtraMedia}
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-purple-50 file:text-purple-700 hover:file:bg-purple-100 cursor-pointer"
/>
{uploading['extraMedia'] && !pdfProgress && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>}
{pdfProgress && (
<p className="mt-2 text-sm text-purple-600 font-medium">
Processing &ldquo;{pdfProgress.name}&rdquo;: page {pdfProgress.page} of {pdfProgress.total}
</p>
)}
</div>
{(isEditing.extraMedia || []).length > 0 && (
<div className="mt-3 space-y-2">
{(isEditing.extraMedia || []).map((item, i) => {
const video = isVideoUrl(item.url);
return (
<div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg">
<div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0">
{video ? (
<>
<video src={item.url} className="w-full h-full object-cover" muted preload="metadata" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-xl">▶</div>
</>
) : (
<img src={item.url} className="w-full h-full object-cover" alt="" />
)}
<span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
{video ? 'Video' : 'Image'}
</div>
{video && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!item.autoplay}
onChange={() => toggleAutoplay(i)}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Autoplay</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!item.muted}
onChange={() => toggleMuted(i)}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-gray-700">Muted</span>
</label>
</div>
)}
</div>
<button
onClick={() => removeExtraMedia(i)}
className="bg-red-500 hover:bg-red-600 text-white w-8 h-8 rounded-full text-sm font-bold shrink-0"
title="Remove"
>✕</button>
</div>
);
})}
</div>
)}
</div>


+ 84
- 35
app/api/files/route.ts ファイルの表示

@@ -1,35 +1,84 @@
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 });
}
}
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';

export const dynamic = 'force-dynamic';

const MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.m4v': 'video/x-m4v',
'.ogv': 'video/ogg',
};

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);

let stat: fs.Stats;
try {
stat = fs.statSync(filePath);
} catch {
return new NextResponse('File not found', { status: 404 });
}

const ext = path.extname(name).toLowerCase();
const mimeType = MIME[ext] || 'application/octet-stream';
const fileSize = stat.size;

// Handle Range requests (essential for video seeking)
const range = request.headers.get('range');
if (range) {
const match = /bytes=(\d*)-(\d*)/.exec(range);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
const chunkSize = end - start + 1;

const stream = fs.createReadStream(filePath, { start, end });
// Convert Node stream to Web ReadableStream
const webStream = new ReadableStream({
start(controller) {
stream.on('data', chunk => controller.enqueue(new Uint8Array(chunk as Buffer)));
stream.on('end', () => controller.close());
stream.on('error', err => controller.error(err));
},
cancel() {
stream.destroy();
},
});

return new NextResponse(webStream, {
status: 206,
headers: {
'Content-Type': mimeType,
'Content-Length': chunkSize.toString(),
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=86400',
},
});
}
}

// Full file response (for images, or videos without Range header)
const buffer = fs.readFileSync(filePath);
return new NextResponse(buffer, {
headers: {
'Content-Type': mimeType,
'Content-Length': fileSize.toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=86400',
},
});
}

+ 6
- 15
app/layout.tsx ファイルの表示

@@ -1,20 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Captive Portal",
description: "Welcome",
};

export default function RootLayout({
@@ -24,8 +15,8 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="it"
className={`${GeistSans.variable} ${GeistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>


+ 561
- 88
components/PublicGrid.tsx ファイルの表示

@@ -1,88 +1,561 @@
'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>
)}
</>
);
}
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, MediaItem } from '@/types';

const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url);

function MediaCarousel({
items,
onMediaClick,
}: {
items: MediaItem[];
onMediaClick?: (index: number) => void;
}) {
const [current, setCurrent] = useState(0);
const [playing, setPlaying] = useState<Set<number>>(new Set());
const touchStartX = useRef<number | null>(null);
const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});

const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });

const applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
v.muted = !!item.muted;
};

const togglePlay = (i: number) => {
const v = videoRefs.current[i];
if (!v) return;
if (v.paused) {
const item = items[i];
if (item) applyVolume(v, item);
v.play().catch(() => {});
} else {
v.pause();
}
};

const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [prev, next]);

useEffect(() => { setCurrent(0); }, [items]);

// Pause non-current videos; autoplay current if flagged
useEffect(() => {
Object.entries(videoRefs.current).forEach(([key, vid]) => {
if (!vid) return;
const idx = parseInt(key, 10);
if (idx !== current) { vid.pause(); return; }
const item = items[idx];
if (item && isVideoUrl(item.url) && item.autoplay) {
applyVolume(vid, item);
vid.play().catch(() => {
// Browser blocked unmuted autoplay — fall back to muted.
vid.muted = true;
vid.play().catch(() => {});
});
}
});
}, [current, items]);

const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
const onTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
touchStartX.current = null;
};

if (items.length === 0) {
return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
}

return (
<div
className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{items.map((item, i) => {
const isActive = i === current;
const video = isVideoUrl(item.url);
return (
<div
key={item.url + i}
className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
{video ? (
<div
className="relative w-full h-full cursor-pointer"
onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
>
<video
ref={el => { videoRefs.current[i] = el; }}
src={item.url}
className="w-full h-full object-contain bg-black pointer-events-none"
playsInline
preload="metadata"
onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
onPlay={() => markPlaying(i)}
onPause={() => markPaused(i)}
onEnded={() => markPaused(i)}
/>
{/* Custom play overlay (shown when paused) */}
{!playing.has(i) && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="bg-black/60 text-white w-20 h-20 rounded-full flex items-center justify-center text-4xl shadow-2xl pl-1">▶</span>
</div>
)}
{/* Custom expand button */}
<button
onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }}
className="absolute bottom-3 right-3 bg-black/60 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
title="Expand fullscreen"
aria-label="Expand fullscreen"
>
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
</button>
</div>
) : (
<img
src={item.url}
alt=""
className="w-full h-full object-cover cursor-zoom-in"
onClick={() => onMediaClick?.(i)}
title="Click to view fullscreen"
/>
)}
</div>
);
})}

{items.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
aria-label="Previous"
>‹</button>
<button
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
aria-label="Next"
>›</button>

<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
{items.map((_, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
className={`rounded-full transition-all duration-200 ${i === current ? 'w-5 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</div>

<div className="absolute top-3 left-3 bg-black/60 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10 flex items-center gap-1">
<span>⊞</span>
<span>{current + 1} / {items.length}</span>
</div>
</>
)}
</div>
);
}

function FullscreenViewer({
items,
startIndex,
onClose,
}: {
items: MediaItem[];
startIndex: number;
onClose: () => void;
}) {
const [current, setCurrent] = useState(startIndex);
const [playing, setPlaying] = useState<Set<number>>(new Set());
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const touchStartX = useRef<number | null>(null);
const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
const containerRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{ sx: number; sy: number; px: number; py: number; moved: boolean } | null>(null);

const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });

const applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
v.muted = !!item.muted;
};

const togglePlay = (i: number) => {
const v = videoRefs.current[i];
if (!v) return;
if (v.paused) {
const item = items[i];
if (item) applyVolume(v, item);
v.play().catch(() => {});
} else {
v.pause();
}
};

const onImgClick = (e: React.MouseEvent<HTMLImageElement>) => {
if (dragRef.current?.moved) {
dragRef.current = null;
return;
}
dragRef.current = null;
if (zoom > 1) {
setZoom(1);
setPan({ x: 0, y: 0 });
} else {
const r = e.currentTarget.getBoundingClientRect();
const ox = e.clientX - r.left - r.width / 2;
const oy = e.clientY - r.top - r.height / 2;
setZoom(2);
setPan({ x: -2 * ox, y: -2 * oy });
}
};

const onImgPointerDown = (e: React.PointerEvent<HTMLImageElement>) => {
if (zoom <= 1) return;
dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false };
e.currentTarget.setPointerCapture?.(e.pointerId);
};

const onImgPointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.sx;
const dy = e.clientY - dragRef.current.sy;
if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true;
setPan({ x: dragRef.current.px + dx, y: dragRef.current.py + dy });
};

const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [prev, next, onClose]);

// Pause non-current videos in fullscreen
useEffect(() => {
Object.entries(videoRefs.current).forEach(([key, vid]) => {
if (!vid) return;
const idx = parseInt(key, 10);
if (idx !== current) { vid.pause(); return; }
const item = items[idx];
if (item && isVideoUrl(item.url) && item.autoplay) {
applyVolume(vid, item);
vid.play().catch(() => {
vid.muted = true;
vid.play().catch(() => {});
});
}
});
}, [current, items]);

// Reset zoom whenever the active slide changes
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, [current]);

// Wheel zoom (only on images). preventDefault requires passive: false → manual listener.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
const item = items[current];
if (!item || isVideoUrl(item.url)) return;
e.preventDefault();
const factor = 1 - e.deltaY * 0.001;
setZoom(prev => {
const next = Math.max(1, Math.min(4, prev * factor));
if (next === 1) setPan({ x: 0, y: 0 });
return next;
});
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [current, items]);

const onTouchStart = (e: React.TouchEvent) => {
if (zoom > 1) return; // pan via pointer events instead
touchStartX.current = e.touches[0].clientX;
};
const onTouchEnd = (e: React.TouchEvent) => {
if (zoom > 1) return;
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
touchStartX.current = null;
};

return (
<div
ref={containerRef}
className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Media — full resolution, contained */}
{items.map((item, i) => {
const isActive = i === current;
const video = isVideoUrl(item.url);
return (
<div
key={item.url + i}
className={`absolute inset-0 flex items-center justify-center p-4 pb-28 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
{video ? (
<div
className="relative flex items-center justify-center max-w-full max-h-full cursor-pointer"
onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
>
<video
ref={el => { videoRefs.current[i] = el; }}
src={item.url}
className="max-w-full max-h-full pointer-events-none"
playsInline
preload="metadata"
onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
onPlay={() => markPlaying(i)}
onPause={() => markPaused(i)}
onEnded={() => markPaused(i)}
/>
{!playing.has(i) && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="bg-black/60 text-white w-24 h-24 rounded-full flex items-center justify-center text-5xl shadow-2xl pl-1">▶</span>
</div>
)}
</div>
) : (
<img
src={item.url}
alt=""
className="max-w-full max-h-full object-contain"
draggable={false}
style={isActive ? {
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
cursor: zoom > 1 ? 'grab' : 'zoom-in',
transition: dragRef.current ? 'none' : 'transform 0.2s',
touchAction: zoom > 1 ? 'none' : 'auto',
willChange: 'transform',
} : undefined}
onClick={isActive ? onImgClick : undefined}
onPointerDown={isActive ? onImgPointerDown : undefined}
onPointerMove={isActive ? onImgPointerMove : undefined}
/>
)}
</div>
);
})}

{/* Counter — top center */}
{items.length > 1 && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/60 text-white text-sm font-semibold px-3 py-1 rounded-full z-20">
{current + 1} / {items.length}
</div>
)}

{/* Side arrows */}
{items.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20"
aria-label="Previous"
>‹</button>
<button
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20"
aria-label="Next"
>›</button>
</>
)}

{/* Close button — bottom center, ABOVE the dots */}
<button
onClick={onClose}
className="absolute bottom-12 left-1/2 -translate-x-1/2 bg-white/90 hover:bg-white text-black px-6 py-2.5 rounded-full font-semibold shadow-2xl flex items-center gap-2 z-20 transition-transform hover:scale-105"
aria-label="Close fullscreen"
>
<span className="text-lg leading-none">✕</span>
<span>Close</span>
</button>

{/* Dots — at the very bottom */}
{items.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20">
{items.map((_, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
className={`rounded-full transition-all duration-200 ${i === current ? 'w-6 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</div>
)}
</div>
);
}

export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);

useEffect(() => {
if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden';
else document.body.style.overflow = 'unset';
return () => { document.body.style.overflow = 'unset'; };
}, [activeCard, fullscreenIndex]);

const gridClasses: Record<number, string> = {
3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
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];

const carouselItems: MediaItem[] = activeCard
? [
...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []),
...(activeCard.extraMedia || []),
]
: [];

return (
<>
<div className={`grid gap-4 ${activeGridClass}`}>
{cards.map((card) => {
const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0);
// Fall back to the first gallery item when no explicit cover is set.
const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
const previewIsVideo = !!previewUrl && isVideoUrl(previewUrl);
return (
<div
key={card.id}
onClick={() => {
setActiveCard(card);
if (card.autoFullscreen) setFullscreenIndex(0);
}}
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"
>
{previewUrl ? (
previewIsVideo ? (
<video
src={previewUrl}
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
muted
playsInline
preload="metadata"
/>
) : (
<img src={previewUrl} 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>
)}
{galleryCount > 1 && (
<div className="absolute top-2 left-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1 z-10">
<span>⊞</span>
<span>{galleryCount}</span>
</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>

{activeCard && fullscreenIndex === null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
onClick={() => setActiveCard(null)}
>
<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()}
>
<div className="relative">
<MediaCarousel
items={carouselItems}
onMediaClick={(i) => setFullscreenIndex(i)}
/>
<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 z-20"
title="Close"
>✕</button>
</div>
{(activeCard.title || activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) && (
<div className="p-8">
<div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
{activeCard.title && (
<h2 className={`text-3xl font-bold text-gray-900 ${(activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) ? 'mb-4' : ''}`}>
{activeCard.title}
</h2>
)}
{activeCard.cardType === 'EXTERNAL_LINK' && (activeCard.actionUrl || activeCard.shortDescription) ? (
<a
href={activeCard.actionUrl || activeCard.shortDescription}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline break-all text-lg font-medium"
>
<span>{activeCard.shortDescription || activeCard.actionUrl}</span>
<span aria-hidden>↗</span>
</a>
) : activeCard.fullContent ? (
<div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
) : activeCard.shortDescription ? (
<p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
) : null}
</div>
)}
</div>
</div>
)}

{fullscreenIndex !== null && activeCard && (
<FullscreenViewer
items={carouselItems}
startIndex={fullscreenIndex}
onClose={() => {
setFullscreenIndex(null);
setActiveCard(null);
}}
/>
)}
</>
);
}

+ 3
- 0
lib/config.ts ファイルの表示

@@ -0,0 +1,3 @@
// Feature flags compilati nel bundle. Modifica e ricostruisci (npm run build) per applicare.

export const EXTERNAL_LINK_ENABLED = true;

+ 12
- 3
lib/db.ts ファイルの表示

@@ -23,12 +23,21 @@ 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 || '[]');
// Backward-compat: convert old string[] extraImages → MediaItem[] extraMedia
cards = cards.map(c => {
const legacy = (c as any).extraImages;
if (Array.isArray(legacy) && !c.extraMedia) {
c.extraMedia = legacy.map((url: string) => ({ url }));
delete (c as any).extraImages;
}
return c;
});
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);
}


+ 1
- 1
next.config.ts ファイルの表示

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ['10.210.1.225'],
};

export default nextConfig;

+ 4
- 1
package.json ファイルの表示

@@ -6,10 +6,13 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\""
},
"dependencies": {
"geist": "^1.4.2",
"next": "16.2.4",
"pdfjs-dist": "^4.7.76",
"react": "19.2.4",
"react-dom": "19.2.4"
},


+ 8
- 9
types/index.ts ファイルの表示

@@ -1,25 +1,24 @@
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 type MediaItem = {
url: string;
autoplay?: boolean; // videos only — start playing as soon as the slide is shown
muted?: boolean; // videos only — start muted (default: unmuted)
};
export interface Card {
id: string;
portalId: string;
title: string;
imageUrl: string;
extraMedia?: MediaItem[];
shortDescription: string;
fullContent: string;
cardType: CardType;
actionUrl?: string;
displayOrder: number;
autoFullscreen?: boolean; // open the fullscreen viewer immediately when this card is clicked
skipPreview?: boolean; // skip the modal preview AND exclude the cover image from the swipe sequence
}
export interface Portal {


読み込み中…
キャンセル
保存