From 7d5d31afe6cdda688da39db724a7a86fb8fb41ee Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 15:52:18 +0200 Subject: [PATCH 01/61] =?UTF-8?q?Sviluppata=20funzionalit=C3=A0=20per=20il?= =?UTF-8?q?=20carrello=20di=20immagini=20per=20le=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/page.tsx | 93 +++++++++++-- components/PublicGrid.tsx | 271 +++++++++++++++++++++++++------------- types/index.ts | 11 +- 3 files changed, 264 insertions(+), 111 deletions(-) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 53cdafa..54e894c 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -33,12 +33,12 @@ export default function AdminDashboard() { const handleUpload = async (e: React.ChangeEvent, 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 +49,36 @@ export default function AdminDashboard() { setUploading(prev => ({ ...prev, [field]: false })); }; + const handleUploadExtraImage = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + setUploading(prev => ({ ...prev, extraImages: true })); + + const uploaded: string[] = []; + for (const file of Array.from(files)) { + 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) uploaded.push(data.url); + } + + setIsEditing(prev => ({ + ...prev, + extraImages: [...(prev?.extraImages || []), ...uploaded], + })); + setUploading(prev => ({ ...prev, extraImages: false })); + // Reset input so the same file can be re-selected if needed + e.target.value = ''; + }; + + const removeExtraImage = (index: number) => { + setIsEditing(prev => ({ + ...prev, + extraImages: (prev?.extraImages || []).filter((_, i) => i !== index), + })); + }; + const handleSaveCard = async () => { if (!isEditing) return; const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); @@ -315,22 +345,59 @@ export default function AdminDashboard() {
+ {/* Cover Image */}
- -
+ +
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'] &&

Uploading image...

} + {uploading['imageUrl'] &&

Uploading...

}
{isEditing.imageUrl && ( -
- Preview - + title="Remove cover image" + >✕ +
+ )} +
+ + {/* Gallery Images */} +
+ +
+ + {uploading['extraImages'] &&

Uploading...

} +
+ + {/* Thumbnails strip */} + {(isEditing.extraImages || []).length > 0 && ( +
+ {(isEditing.extraImages || []).map((url, i) => ( +
+ {`Gallery +
+ +
+ {i + 1} +
+ ))}
)}
diff --git a/components/PublicGrid.tsx b/components/PublicGrid.tsx index ab76447..bc349a5 100644 --- a/components/PublicGrid.tsx +++ b/components/PublicGrid.tsx @@ -1,88 +1,183 @@ -'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(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 = { - 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 ( - <> -
- {cards.map((card) => ( -
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 ? ( - {card.title} - ) : ( -
No Image
- )} -
-

{card.title}

-

- {card.shortDescription} -

-
-
- ))} -
- - {/* Improved Modal Pop-up */} - {activeCard && ( -
setActiveCard(null)} // Click outside to close - > -
e.stopPropagation()} // Prevent clicks inside modal from closing it - > -
- {activeCard.imageUrl && ( - - )} - {/* Improved Close Button */} - -
-
-
{activeCard.cardType.replace('_', ' ')}
-

{activeCard.title}

- {activeCard.fullContent ? ( -
- ) : ( -

{activeCard.shortDescription}

- )} -
-
-
- )} - - ); -} \ No newline at end of file +'use client'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Card } from '@/types'; + +function ImageCarousel({ images }: { images: string[] }) { + const [current, setCurrent] = useState(0); + const touchStartX = useRef(null); + + const prev = useCallback(() => setCurrent(i => (i - 1 + images.length) % images.length), [images.length]); + const next = useCallback(() => setCurrent(i => (i + 1) % images.length), [images.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]); + + // Reset to first image when a new card is shown + useEffect(() => { setCurrent(0); }, [images]); + + 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 (images.length === 0) { + return
No Image
; + } + + return ( +
+ {/* Images */} + {images.map((src, i) => ( + + ))} + + {/* Arrows — only if more than one image */} + {images.length > 1 && ( + <> + + + + {/* Dot indicators */} +
+ {images.map((_, i) => ( +
+ + {/* Counter badge */} +
+ {current + 1} / {images.length} +
+ + )} +
+ ); +} + +export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { + const [activeCard, setActiveCard] = useState(null); + + useEffect(() => { + if (activeCard) document.body.style.overflow = 'hidden'; + else document.body.style.overflow = 'unset'; + return () => { document.body.style.overflow = 'unset'; }; + }, [activeCard]); + + const gridClasses: Record = { + 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]; + + return ( + <> +
+ {cards.map((card) => ( +
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 ? ( + {card.title} + ) : ( +
No Image
+ )} + {/* Gallery badge */} + {card.extraImages && card.extraImages.length > 0 && ( +
+ + {1 + card.extraImages.length} +
+ )} +
+

{card.title}

+

+ {card.shortDescription} +

+
+
+ ))} +
+ + {activeCard && ( +
setActiveCard(null)} + > +
e.stopPropagation()} + > +
+ + +
+
+
{activeCard.cardType.replace('_', ' ')}
+

{activeCard.title}

+ {activeCard.fullContent ? ( +
+ ) : ( +

{activeCard.shortDescription}

+ )} +
+
+
+ )} + + ); +} diff --git a/types/index.ts b/types/index.ts index da55c5f..21fbc8a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,20 +1,11 @@ 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; + extraImages?: string[]; shortDescription: string; fullContent: string; cardType: CardType; From 9bac7fda7dc0790acd50b25421eef9bceeac136e Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 16:22:33 +0200 Subject: [PATCH 02/61] admin debug --- app/admin/page.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 54e894c..211f5df 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,17 +4,19 @@ import { useState, useEffect } from 'react'; import { Card, Portal } from '@/types'; export default function AdminDashboard() { + console.log('[ADMIN] Component rendering'); + const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards'); - + // Card State const [cards, setCards] = useState([]); const [isEditing, setIsEditing] = useState | null>(null); - + // Portal State const [portal, setPortal] = useState>({}); const [savingPortal, setSavingPortal] = useState(false); const [uploading, setUploading] = useState<{ [key: string]: boolean }>({}); - + // NEW UI STATES: Toast and Confirm Dialog const [toast, setToast] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); @@ -24,10 +26,17 @@ export default function AdminDashboard() { 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)); + console.log('[ADMIN] useEffect fired - fetching data'); + fetch('/api/cards') + .then(res => { console.log('[ADMIN] /api/cards status:', res.status); return res.json(); }) + .then(data => { console.log('[ADMIN] cards received:', data); setCards(data); }) + .catch(err => console.error('[ADMIN] cards fetch error:', err)); + fetch('/api/portals') + .then(res => { console.log('[ADMIN] /api/portals status:', res.status); return res.json(); }) + .then(data => { console.log('[ADMIN] portal received:', data); if (data) setPortal(data); }) + .catch(err => console.error('[ADMIN] portal fetch error:', err)); }, []); const handleUpload = async (e: React.ChangeEvent, field: string, isPortal = false) => { @@ -163,10 +172,10 @@ export default function AdminDashboard() {
{/* Tab Navigation */}
- -
From 38bb64b83f7188441cc7aea4a6a2f66106e36eb8 Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 16:59:42 +0200 Subject: [PATCH 03/61] debug page --- app/admin/test/page.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/admin/test/page.tsx diff --git a/app/admin/test/page.tsx b/app/admin/test/page.tsx new file mode 100644 index 0000000..fc7aed4 --- /dev/null +++ b/app/admin/test/page.tsx @@ -0,0 +1,19 @@ +'use client'; +import { useState } from 'react'; + +export default function Test() { + console.log('[TEST] mounted'); + const [n, setN] = useState(0); + return ( +
+

Hydration test

+

Count: {n}

+ +
+ ); +} From ab143871b1ebe3751aed4007405a68b21021aee3 Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 17:04:30 +0200 Subject: [PATCH 04/61] fix cross origin worning --- next.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index e9ffa30..9839a9e 100644 --- a/next.config.ts +++ b/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; From c9e9022d3063a824b291d5d120a339982e3eb6d9 Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 17:24:53 +0200 Subject: [PATCH 05/61] integrazione video --- app/admin/page.tsx | 106 ++++++++++++++++---------- app/admin/test/page.tsx | 19 ----- app/api/files/route.ts | 119 ++++++++++++++++++++--------- components/PublicGrid.tsx | 153 ++++++++++++++++++++++---------------- lib/db.ts | 15 +++- types/index.ts | 7 +- 6 files changed, 258 insertions(+), 161 deletions(-) delete mode 100644 app/admin/test/page.tsx diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 211f5df..b49b473 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Card, Portal } from '@/types'; +import { Card, Portal, MediaItem } from '@/types'; -export default function AdminDashboard() { - console.log('[ADMIN] Component rendering'); +const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); +export default function AdminDashboard() { const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards'); // Card State @@ -28,15 +28,8 @@ export default function AdminDashboard() { }; useEffect(() => { - console.log('[ADMIN] useEffect fired - fetching data'); - fetch('/api/cards') - .then(res => { console.log('[ADMIN] /api/cards status:', res.status); return res.json(); }) - .then(data => { console.log('[ADMIN] cards received:', data); setCards(data); }) - .catch(err => console.error('[ADMIN] cards fetch error:', err)); - fetch('/api/portals') - .then(res => { console.log('[ADMIN] /api/portals status:', res.status); return res.json(); }) - .then(data => { console.log('[ADMIN] portal received:', data); if (data) setPortal(data); }) - .catch(err => console.error('[ADMIN] portal fetch error:', err)); + 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, field: string, isPortal = false) => { @@ -58,33 +51,41 @@ export default function AdminDashboard() { setUploading(prev => ({ ...prev, [field]: false })); }; - const handleUploadExtraImage = async (e: React.ChangeEvent) => { + const handleUploadExtraMedia = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; - setUploading(prev => ({ ...prev, extraImages: true })); + setUploading(prev => ({ ...prev, extraMedia: true })); - const uploaded: string[] = []; + const uploaded: MediaItem[] = []; for (const file of Array.from(files)) { 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) uploaded.push(data.url); + if (data.url) uploaded.push({ url: data.url }); } setIsEditing(prev => ({ ...prev, - extraImages: [...(prev?.extraImages || []), ...uploaded], + extraMedia: [...(prev?.extraMedia || []), ...uploaded], })); - setUploading(prev => ({ ...prev, extraImages: false })); - // Reset input so the same file can be re-selected if needed + setUploading(prev => ({ ...prev, extraMedia: false })); e.target.value = ''; }; - const removeExtraImage = (index: number) => { + const removeExtraMedia = (index: number) => { + setIsEditing(prev => ({ + ...prev, + extraMedia: (prev?.extraMedia || []).filter((_, i) => i !== index), + })); + }; + + const toggleAutoplay = (index: number) => { setIsEditing(prev => ({ ...prev, - extraImages: (prev?.extraImages || []).filter((_, i) => i !== index), + extraMedia: (prev?.extraMedia || []).map((m, i) => + i === index ? { ...m, autoplay: !m.autoplay } : m + ), })); }; @@ -172,10 +173,10 @@ export default function AdminDashboard() {
{/* Tab Navigation */}
- -
@@ -375,38 +376,63 @@ export default function AdminDashboard() { )}
- {/* Gallery Images */} + {/* Gallery Media (images + videos) */}
- {uploading['extraImages'] &&

Uploading...

} + {uploading['extraMedia'] &&

Uploading...

}
- {/* Thumbnails strip */} - {(isEditing.extraImages || []).length > 0 && ( -
- {(isEditing.extraImages || []).map((url, i) => ( -
- {`Gallery -
+ {(isEditing.extraMedia || []).length > 0 && ( +
+ {(isEditing.extraMedia || []).map((item, i) => { + const video = isVideoUrl(item.url); + return ( +
+
+ {video ? ( + <> +
+
+
+ {video ? 'Video' : 'Image'} +
+ {video && ( + + )} +
- {i + 1} -
- ))} + ); + })}
)}
diff --git a/app/admin/test/page.tsx b/app/admin/test/page.tsx deleted file mode 100644 index fc7aed4..0000000 --- a/app/admin/test/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; -import { useState } from 'react'; - -export default function Test() { - console.log('[TEST] mounted'); - const [n, setN] = useState(0); - return ( -
-

Hydration test

-

Count: {n}

- -
- ); -} diff --git a/app/api/files/route.ts b/app/api/files/route.ts index 5ed7c22..d0edcee 100644 --- a/app/api/files/route.ts +++ b/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 }); - } -} \ No newline at end of file +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +export const dynamic = 'force-dynamic'; + +const MIME: Record = { + '.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', + }, + }); +} diff --git a/components/PublicGrid.tsx b/components/PublicGrid.tsx index bc349a5..7bdf4d1 100644 --- a/components/PublicGrid.tsx +++ b/components/PublicGrid.tsx @@ -1,13 +1,16 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; -import { Card } from '@/types'; +import { Card, MediaItem } from '@/types'; -function ImageCarousel({ images }: { images: string[] }) { +const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); + +function MediaCarousel({ items }: { items: MediaItem[] }) { const [current, setCurrent] = useState(0); const touchStartX = useRef(null); + const videoRefs = useRef>({}); - const prev = useCallback(() => setCurrent(i => (i - 1 + images.length) % images.length), [images.length]); - const next = useCallback(() => setCurrent(i => (i + 1) % images.length), [images.length]); + 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) => { @@ -18,8 +21,24 @@ function ImageCarousel({ images }: { images: string[] }) { return () => window.removeEventListener('keydown', onKey); }, [prev, next]); - // Reset to first image when a new card is shown - useEffect(() => { setCurrent(0); }, [images]); + useEffect(() => { setCurrent(0); }, [items]); + + // Pause all videos that aren't current; 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) { + vid.muted = true; + vid.play().catch(() => {/* autoplay blocked, ignore */}); + } + }); + }, [current, items]); const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; @@ -31,7 +50,7 @@ function ImageCarousel({ images }: { images: string[] }) { touchStartX.current = null; }; - if (images.length === 0) { + if (items.length === 0) { return
No Image
; } @@ -41,49 +60,57 @@ function ImageCarousel({ images }: { images: string[] }) { onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} > - {/* Images */} - {images.map((src, i) => ( - - ))} - - {/* Arrows — only if more than one image */} - {images.length > 1 && ( + {items.map((item, i) => { + const isActive = i === current; + const video = isVideoUrl(item.url); + return ( +
+ {video ? ( +
+ ); + })} + + {items.length > 1 && ( <> + aria-label="Previous" + >‹ + aria-label="Next" + >› - {/* Dot indicators */}
- {images.map((_, i) => ( + {items.map((_, i) => (
- {/* Counter badge */}
- {current + 1} / {images.length} + {current + 1} / {items.length}
)} @@ -114,32 +141,34 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC return ( <>
- {cards.map((card) => ( -
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 ? ( - {card.title} - ) : ( -
No Image
- )} - {/* Gallery badge */} - {card.extraImages && card.extraImages.length > 0 && ( -
- - {1 + card.extraImages.length} + {cards.map((card) => { + const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0); + return ( +
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 ? ( + {card.title} + ) : ( +
No Image
+ )} + {galleryCount > 1 && ( +
+ + {galleryCount} +
+ )} +
+

{card.title}

+

+ {card.shortDescription} +

- )} -
-

{card.title}

-

- {card.shortDescription} -

-
- ))} + ); + })}
{activeCard && ( @@ -152,19 +181,17 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC onClick={(e) => e.stopPropagation()} >
- + >✕
{activeCard.cardType.replace('_', ' ')}
diff --git a/lib/db.ts b/lib/db.ts index f4cfeca..767db26 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -23,12 +23,21 @@ export async function getCards(portalId?: string): Promise { 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); } diff --git a/types/index.ts b/types/index.ts index 21fbc8a..0be89e2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,11 +1,16 @@ export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST'; +export type MediaItem = { + url: string; + autoplay?: boolean; +}; + export interface Card { id: string; portalId: string; title: string; imageUrl: string; - extraImages?: string[]; + extraMedia?: MediaItem[]; shortDescription: string; fullContent: string; cardType: CardType; From 461c58dc0449e76fb6fdc7b18236a2f8a2dfe0cc Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 18:03:41 +0200 Subject: [PATCH 06/61] Fullscreem view --- components/PublicGrid.tsx | 173 ++++++++++++++++++++++++++++++++++---- 1 file changed, 156 insertions(+), 17 deletions(-) diff --git a/components/PublicGrid.tsx b/components/PublicGrid.tsx index 7bdf4d1..8161a5f 100644 --- a/components/PublicGrid.tsx +++ b/components/PublicGrid.tsx @@ -4,7 +4,13 @@ import { Card, MediaItem } from '@/types'; const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); -function MediaCarousel({ items }: { items: MediaItem[] }) { +function MediaCarousel({ + items, + onImageClick, +}: { + items: MediaItem[]; + onImageClick?: (index: number) => void; +}) { const [current, setCurrent] = useState(0); const touchStartX = useRef(null); const videoRefs = useRef>({}); @@ -23,26 +29,21 @@ function MediaCarousel({ items }: { items: MediaItem[] }) { useEffect(() => { setCurrent(0); }, [items]); - // Pause all videos that aren't current; autoplay current if flagged + // 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; - } + if (idx !== current) { vid.pause(); return; } const item = items[idx]; if (item && isVideoUrl(item.url) && item.autoplay) { vid.muted = true; - vid.play().catch(() => {/* autoplay blocked, ignore */}); + vid.play().catch(() => {}); } }); }, [current, items]); - const onTouchStart = (e: React.TouchEvent) => { - touchStartX.current = e.touches[0].clientX; - }; + 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; @@ -79,7 +80,13 @@ function MediaCarousel({ items }: { items: MediaItem[] }) { preload="metadata" /> ) : ( - + onImageClick?.(i)} + title="Click to view fullscreen" + /> )}
); @@ -118,14 +125,133 @@ function MediaCarousel({ items }: { items: MediaItem[] }) { ); } +function FullscreenViewer({ + items, + startIndex, + onClose, +}: { + items: MediaItem[]; + startIndex: number; + onClose: () => void; +}) { + const [current, setCurrent] = useState(startIndex); + const touchStartX = useRef(null); + + 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]); + + 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; + }; + + return ( +
+ {/* Media — full resolution, contained */} + {items.map((item, i) => { + const isActive = i === current; + const video = isVideoUrl(item.url); + return ( +
+ {video ? ( +
+ ); + })} + + {/* Counter — top center */} + {items.length > 1 && ( +
+ {current + 1} / {items.length} +
+ )} + + {/* Side arrows */} + {items.length > 1 && ( + <> + + + + )} + + {/* Close button — bottom center, ABOVE the dots */} + + + {/* Dots — at the very bottom */} + {items.length > 1 && ( +
+ {items.map((_, i) => ( +
+ )} +
+ ); +} + export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { const [activeCard, setActiveCard] = useState(null); + const [fullscreenIndex, setFullscreenIndex] = useState(null); useEffect(() => { - if (activeCard) document.body.style.overflow = 'hidden'; + if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden'; else document.body.style.overflow = 'unset'; return () => { document.body.style.overflow = 'unset'; }; - }, [activeCard]); + }, [activeCard, fullscreenIndex]); const gridClasses: Record = { 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', @@ -138,6 +264,13 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC const activeGridClass = gridClasses[maxCols] || gridClasses[5]; + const carouselItems: MediaItem[] = activeCard + ? [ + ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []), + ...(activeCard.extraMedia || []), + ] + : []; + return ( <>
@@ -182,10 +315,8 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC >
setFullscreenIndex(i)} />
)} + + {fullscreenIndex !== null && activeCard && ( + setFullscreenIndex(null)} + /> + )} ); } From eca6ab3e953ed5844574e5c1dc520cc2ddbd8c29 Mon Sep 17 00:00:00 2001 From: pollutri Date: Thu, 7 May 2026 18:10:58 +0200 Subject: [PATCH 07/61] Video Fullscreen fix --- components/PublicGrid.tsx | 42 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/components/PublicGrid.tsx b/components/PublicGrid.tsx index 8161a5f..532f28b 100644 --- a/components/PublicGrid.tsx +++ b/components/PublicGrid.tsx @@ -6,10 +6,10 @@ const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); function MediaCarousel({ items, - onImageClick, + onMediaClick, }: { items: MediaItem[]; - onImageClick?: (index: number) => void; + onMediaClick?: (index: number) => void; }) { const [current, setCurrent] = useState(0); const touchStartX = useRef(null); @@ -70,21 +70,35 @@ function MediaCarousel({ className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} > {video ? ( -