| @@ -1,11 +1,11 @@ | |||||
| 'use client'; | 'use client'; | ||||
| import { useState, useEffect } from 'react'; | 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'); | const [activeTab, setActiveTab] = useState<'cards' | 'settings'>('cards'); | ||||
| // Card State | // Card State | ||||
| @@ -28,15 +28,8 @@ export default function AdminDashboard() { | |||||
| }; | }; | ||||
| useEffect(() => { | 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<HTMLInputElement>, field: string, isPortal = false) => { | const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => { | ||||
| @@ -58,33 +51,41 @@ export default function AdminDashboard() { | |||||
| setUploading(prev => ({ ...prev, [field]: false })); | setUploading(prev => ({ ...prev, [field]: false })); | ||||
| }; | }; | ||||
| const handleUploadExtraImage = async (e: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const files = e.target.files; | const files = e.target.files; | ||||
| if (!files || files.length === 0) return; | 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)) { | for (const file of Array.from(files)) { | ||||
| const formData = new FormData(); | const formData = new FormData(); | ||||
| formData.append('file', file); | formData.append('file', file); | ||||
| const res = await fetch('/api/upload', { method: 'POST', body: formData }); | const res = await fetch('/api/upload', { method: 'POST', body: formData }); | ||||
| const data = await res.json(); | const data = await res.json(); | ||||
| if (data.url) uploaded.push(data.url); | |||||
| if (data.url) uploaded.push({ url: data.url }); | |||||
| } | } | ||||
| setIsEditing(prev => ({ | setIsEditing(prev => ({ | ||||
| ...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 = ''; | 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 => ({ | setIsEditing(prev => ({ | ||||
| ...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() { | |||||
| <div className="max-w-5xl mx-auto mt-8 px-4"> | <div className="max-w-5xl mx-auto mt-8 px-4"> | ||||
| {/* Tab Navigation */} | {/* Tab Navigation */} | ||||
| <div className="flex space-x-2 mb-6"> | <div className="flex space-x-2 mb-6"> | ||||
| <button onClick={() => { console.log('[ADMIN] click: cards tab'); 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'}`}> | |||||
| <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 | Manage Cards | ||||
| </button> | </button> | ||||
| <button onClick={() => { console.log('[ADMIN] click: settings tab'); 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'}`}> | |||||
| <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 | Portal Settings | ||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| @@ -375,38 +376,63 @@ export default function AdminDashboard() { | |||||
| )} | )} | ||||
| </div> | </div> | ||||
| {/* Gallery Images */} | |||||
| {/* Gallery Media (images + videos) */} | |||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-800 mb-1"> | <label className="block text-sm font-semibold text-gray-800 mb-1"> | ||||
| Gallery Images <span className="text-gray-400 font-normal text-xs">(optional, shown in detail modal)</span> | |||||
| Gallery Media <span className="text-gray-400 font-normal text-xs">(images or videos, shown in detail modal)</span> | |||||
| </label> | </label> | ||||
| <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors"> | <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors"> | ||||
| <input | <input | ||||
| type="file" | type="file" | ||||
| accept="image/*" | |||||
| accept="image/*,video/*" | |||||
| multiple | multiple | ||||
| onChange={handleUploadExtraImage} | |||||
| 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" | 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['extraImages'] && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>} | |||||
| {uploading['extraMedia'] && <p className="mt-2 text-sm text-purple-600 font-medium">Uploading...</p>} | |||||
| </div> | </div> | ||||
| {/* Thumbnails strip */} | |||||
| {(isEditing.extraImages || []).length > 0 && ( | |||||
| <div className="mt-3 flex flex-wrap gap-2"> | |||||
| {(isEditing.extraImages || []).map((url, i) => ( | |||||
| <div key={url + i} className="relative group w-20 h-20 rounded-lg overflow-hidden border border-gray-200 shrink-0"> | |||||
| <img src={url} className="w-full h-full object-cover" alt={`Gallery ${i + 1}`} /> | |||||
| <div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> | |||||
| {(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 && ( | |||||
| <label className="flex items-center gap-2 mt-1 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 (muted)</span> | |||||
| </label> | |||||
| )} | |||||
| </div> | |||||
| <button | <button | ||||
| onClick={() => removeExtraImage(i)} | |||||
| className="bg-red-500 text-white w-7 h-7 rounded-full text-xs font-bold hover:bg-red-600" | |||||
| 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" | title="Remove" | ||||
| >✕</button> | >✕</button> | ||||
| </div> | </div> | ||||
| <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/50 pb-0.5">{i + 1}</span> | |||||
| </div> | |||||
| ))} | |||||
| ); | |||||
| })} | |||||
| </div> | </div> | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| @@ -1,19 +0,0 @@ | |||||
| 'use client'; | |||||
| import { useState } from 'react'; | |||||
| export default function Test() { | |||||
| console.log('[TEST] mounted'); | |||||
| const [n, setN] = useState(0); | |||||
| return ( | |||||
| <div style={{ padding: 40, fontSize: 24 }}> | |||||
| <h1>Hydration test</h1> | |||||
| <p>Count: {n}</p> | |||||
| <button | |||||
| onClick={() => { console.log('[TEST] click'); setN(n + 1); }} | |||||
| style={{ padding: '10px 20px', background: '#007', color: '#fff', cursor: 'pointer' }} | |||||
| > | |||||
| Click me | |||||
| </button> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| @@ -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', | |||||
| }, | |||||
| }); | |||||
| } | |||||
| @@ -1,13 +1,16 @@ | |||||
| 'use client'; | 'use client'; | ||||
| import { useState, useEffect, useCallback, useRef } from 'react'; | 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 [current, setCurrent] = useState(0); | ||||
| const touchStartX = useRef<number | null>(null); | const touchStartX = useRef<number | null>(null); | ||||
| const videoRefs = useRef<Record<number, HTMLVideoElement | 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]); | |||||
| 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(() => { | useEffect(() => { | ||||
| const onKey = (e: KeyboardEvent) => { | const onKey = (e: KeyboardEvent) => { | ||||
| @@ -18,8 +21,24 @@ function ImageCarousel({ images }: { images: string[] }) { | |||||
| return () => window.removeEventListener('keydown', onKey); | return () => window.removeEventListener('keydown', onKey); | ||||
| }, [prev, next]); | }, [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) => { | const onTouchStart = (e: React.TouchEvent) => { | ||||
| touchStartX.current = e.touches[0].clientX; | touchStartX.current = e.touches[0].clientX; | ||||
| @@ -31,7 +50,7 @@ function ImageCarousel({ images }: { images: string[] }) { | |||||
| touchStartX.current = null; | touchStartX.current = null; | ||||
| }; | }; | ||||
| if (images.length === 0) { | |||||
| 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="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>; | ||||
| } | } | ||||
| @@ -41,49 +60,57 @@ function ImageCarousel({ images }: { images: string[] }) { | |||||
| onTouchStart={onTouchStart} | onTouchStart={onTouchStart} | ||||
| onTouchEnd={onTouchEnd} | onTouchEnd={onTouchEnd} | ||||
| > | > | ||||
| {/* Images */} | |||||
| {images.map((src, i) => ( | |||||
| <img | |||||
| key={src} | |||||
| src={src} | |||||
| alt="" | |||||
| className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${i === current ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} | |||||
| /> | |||||
| ))} | |||||
| {/* Arrows — only if more than one image */} | |||||
| {images.length > 1 && ( | |||||
| {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 ? ( | |||||
| <video | |||||
| ref={el => { videoRefs.current[i] = el; }} | |||||
| src={item.url} | |||||
| className="w-full h-full object-contain bg-black" | |||||
| controls | |||||
| playsInline | |||||
| muted={!!item.autoplay} | |||||
| preload="metadata" | |||||
| /> | |||||
| ) : ( | |||||
| <img src={item.url} alt="" className="w-full h-full object-cover" /> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| })} | |||||
| {items.length > 1 && ( | |||||
| <> | <> | ||||
| <button | <button | ||||
| onClick={(e) => { e.stopPropagation(); prev(); }} | 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" | 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 image" | |||||
| > | |||||
| ‹ | |||||
| </button> | |||||
| aria-label="Previous" | |||||
| >‹</button> | |||||
| <button | <button | ||||
| onClick={(e) => { e.stopPropagation(); next(); }} | 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" | 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 image" | |||||
| > | |||||
| › | |||||
| </button> | |||||
| aria-label="Next" | |||||
| >›</button> | |||||
| {/* Dot indicators */} | |||||
| <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10"> | <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10"> | ||||
| {images.map((_, i) => ( | |||||
| {items.map((_, i) => ( | |||||
| <button | <button | ||||
| key={i} | key={i} | ||||
| onClick={(e) => { e.stopPropagation(); setCurrent(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'}`} | 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 image ${i + 1}`} | |||||
| aria-label={`Go to slide ${i + 1}`} | |||||
| /> | /> | ||||
| ))} | ))} | ||||
| </div> | </div> | ||||
| {/* Counter badge */} | |||||
| <div className="absolute top-3 right-3 bg-black/50 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10"> | <div className="absolute top-3 right-3 bg-black/50 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10"> | ||||
| {current + 1} / {images.length} | |||||
| {current + 1} / {items.length} | |||||
| </div> | </div> | ||||
| </> | </> | ||||
| )} | )} | ||||
| @@ -114,32 +141,34 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <div className={`grid gap-4 ${activeGridClass}`}> | <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> | |||||
| )} | |||||
| {/* Gallery badge */} | |||||
| {card.extraImages && card.extraImages.length > 0 && ( | |||||
| <div className="absolute top-2 right-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1"> | |||||
| <span>⊞</span> | |||||
| <span>{1 + card.extraImages.length}</span> | |||||
| {cards.map((card) => { | |||||
| const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0); | |||||
| return ( | |||||
| <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> | |||||
| )} | |||||
| {galleryCount > 1 && ( | |||||
| <div className="absolute top-2 right-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1"> | |||||
| <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 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> | |||||
| ))} | |||||
| ); | |||||
| })} | |||||
| </div> | </div> | ||||
| {activeCard && ( | {activeCard && ( | ||||
| @@ -152,19 +181,17 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC | |||||
| onClick={(e) => e.stopPropagation()} | onClick={(e) => e.stopPropagation()} | ||||
| > | > | ||||
| <div className="relative"> | <div className="relative"> | ||||
| <ImageCarousel | |||||
| images={[ | |||||
| ...(activeCard.imageUrl ? [activeCard.imageUrl] : []), | |||||
| ...(activeCard.extraImages || []), | |||||
| <MediaCarousel | |||||
| items={[ | |||||
| ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []), | |||||
| ...(activeCard.extraMedia || []), | |||||
| ]} | ]} | ||||
| /> | /> | ||||
| <button | <button | ||||
| onClick={() => setActiveCard(null)} | 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" | 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" | title="Close" | ||||
| > | |||||
| ✕ | |||||
| </button> | |||||
| >✕</button> | |||||
| </div> | </div> | ||||
| <div className="p-8"> | <div className="p-8"> | ||||
| <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div> | <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div> | ||||
| @@ -23,12 +23,21 @@ export async function getCards(portalId?: string): Promise<Card[]> { | |||||
| await ensureDb(); | await ensureDb(); | ||||
| const data = await fs.readFile(CARDS_FILE, 'utf-8'); | const data = await fs.readFile(CARDS_FILE, 'utf-8'); | ||||
| let cards: Card[] = JSON.parse(data || '[]'); | 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) { | if (portalId) { | ||||
| cards = cards.filter(c => c.portalId === 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); | return cards.sort((a, b) => a.displayOrder - b.displayOrder); | ||||
| } | } | ||||
| @@ -1,11 +1,16 @@ | |||||
| export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST'; | export type CardType = 'INFO_PAGE' | 'EXTERNAL_LINK' | 'IMAGE_GALLERY' | 'SERVICE_REQUEST'; | ||||
| export type MediaItem = { | |||||
| url: string; | |||||
| autoplay?: boolean; | |||||
| }; | |||||
| export interface Card { | export interface Card { | ||||
| id: string; | id: string; | ||||
| portalId: string; | portalId: string; | ||||
| title: string; | title: string; | ||||
| imageUrl: string; | imageUrl: string; | ||||
| extraImages?: string[]; | |||||
| extraMedia?: MediaItem[]; | |||||
| shortDescription: string; | shortDescription: string; | ||||
| fullContent: string; | fullContent: string; | ||||
| cardType: CardType; | cardType: CardType; | ||||