Jämför commits

...

8 Incheckningar

7 ändrade filer med 655 tillägg och 155 borttagningar
Delad Vy
  1. +185
    -18
      app/admin/page.tsx
  2. +84
    -35
      app/api/files/route.ts
  3. +365
    -88
      components/PublicGrid.tsx
  4. +12
    -3
      lib/db.ts
  5. +1
    -1
      next.config.ts
  6. +3
    -1
      package.json
  7. +5
    -9
      types/index.ts

+ 185
- 18
app/admin/page.tsx Visa fil

@@ -1,30 +1,78 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, Portal } from '@/types';
import { Card, Portal, MediaItem } from '@/types';
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);
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 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 +81,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 +97,58 @@ 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 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 })
);
uploaded.push(...items);
setPdfProgress(null);
} 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) 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,
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 handleSaveCard = async () => {
if (!isEditing) return;
const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2);
@@ -315,22 +415,89 @@ export default function AdminDashboard() {
</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 && (
<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
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 Visa fil

@@ -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',
},
});
}

+ 365
- 88
components/PublicGrid.tsx Visa fil

@@ -1,88 +1,365 @@
'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 touchStartX = useRef<number | null>(null);
const videoRefs = useRef<Record<number, HTMLVideoElement | 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 === '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) {
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 ? (
<>
<video
ref={el => { videoRefs.current[i] = el; }}
src={item.url}
className="w-full h-full object-contain bg-black"
controls
controlsList="nofullscreen"
disablePictureInPicture
playsInline
muted={!!item.autoplay}
preload="metadata"
/>
<button
onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }}
className="absolute top-3 left-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>
</>
) : (
<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 right-3 bg-black/50 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10">
{current + 1} / {items.length}
</div>
</>
)}
</div>
);
}

function FullscreenViewer({
items,
startIndex,
onClose,
}: {
items: MediaItem[];
startIndex: number;
onClose: () => void;
}) {
const [current, setCurrent] = useState(startIndex);
const touchStartX = useRef<number | null>(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 (
<div
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 ? (
<video
src={item.url}
className="max-w-full max-h-full"
controls
controlsList="nofullscreen"
disablePictureInPicture
playsInline
autoPlay={!!item.autoplay}
muted={!!item.autoplay}
/>
) : (
<img
src={item.url}
alt=""
className="max-w-full max-h-full object-contain"
/>
)}
</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 ? [{ 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);
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>

{activeCard && (
<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>
<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>
)}

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

+ 12
- 3
lib/db.ts Visa fil

@@ -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 Visa fil

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

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

export default nextConfig;

+ 3
- 1
package.json Visa fil

@@ -6,10 +6,12 @@
"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": {
"next": "16.2.4",
"pdfjs-dist": "^4.7.76",
"react": "19.2.4",
"react-dom": "19.2.4"
},


+ 5
- 9
types/index.ts Visa fil

@@ -1,20 +1,16 @@
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;
};
export interface Card {
id: string;
portalId: string;
title: string;
imageUrl: string;
extraMedia?: MediaItem[];
shortDescription: string;
fullContent: string;
cardType: CardType;


Laddar…
Avbryt
Spara