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;