Переглянути джерело

integrazione video

Sviluppo_Carrello_Immagini
Lorenzo Pollutri 2 години тому
джерело
коміт
c9e9022d30
6 змінених файлів з 258 додано та 161 видалено
  1. +66
    -40
      app/admin/page.tsx
  2. +0
    -19
      app/admin/test/page.tsx
  3. +84
    -35
      app/api/files/route.ts
  4. +90
    -63
      components/PublicGrid.tsx
  5. +12
    -3
      lib/db.ts
  6. +6
    -1
      types/index.ts

+ 66
- 40
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<HTMLInputElement>, field: string, isPortal = false) => {
@@ -58,33 +51,41 @@ export default function AdminDashboard() {
setUploading(prev => ({ ...prev, [field]: false }));
};
const handleUploadExtraImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleUploadExtraMedia = async (e: React.ChangeEvent<HTMLInputElement>) => {
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() {
<div className="max-w-5xl mx-auto mt-8 px-4">
{/* Tab Navigation */}
<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
</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
</button>
</div>
@@ -375,38 +376,63 @@ export default function AdminDashboard() {
)}
</div>
{/* Gallery Images */}
{/* Gallery Media (images + videos) */}
<div>
<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>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<input
type="file"
accept="image/*"
accept="image/*,video/*"
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"
/>
{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>
{/* 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
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"
>✕</button>
</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>


+ 0
- 19
app/admin/test/page.tsx Переглянути файл

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

+ 84
- 35
app/api/files/route.ts Переглянути файл

@@ -1,35 +1,84 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const name = searchParams.get('name');
if (!name) return new NextResponse('File name required', { status: 400 });
const filePath = path.join(process.cwd(), 'data', 'uploads', name);
try {
const fileBuffer = fs.readFileSync(filePath);
// Determine basic mime types
const ext = path.extname(name).toLowerCase();
let mimeType = 'image/jpeg';
if (ext === '.png') mimeType = 'image/png';
if (ext === '.gif') mimeType = 'image/gif';
if (ext === '.svg') mimeType = 'image/svg+xml';
if (ext === '.webp') mimeType = 'image/webp';
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': mimeType,
'Cache-Control': 'public, max-age=86400', // Cache in browser for 1 day
},
});
} catch (error) {
return new NextResponse('Image not found', { status: 404 });
}
}
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';

export const dynamic = 'force-dynamic';

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

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const name = searchParams.get('name');
if (!name) return new NextResponse('File name required', { status: 400 });

const filePath = path.join(process.cwd(), 'data', 'uploads', name);

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

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

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

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

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

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

+ 90
- 63
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<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(() => {
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 <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}
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
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 image"
>
</button>
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 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">
{images.map((_, i) => (
{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 image ${i + 1}`}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</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">
{current + 1} / {images.length}
{current + 1} / {items.length}
</div>
</>
)}
@@ -114,32 +141,34 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC
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>
)}
{/* 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 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 && (
@@ -152,19 +181,17 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<ImageCarousel
images={[
...(activeCard.imageUrl ? [activeCard.imageUrl] : []),
...(activeCard.extraImages || []),
<MediaCarousel
items={[
...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []),
...(activeCard.extraMedia || []),
]}
/>
<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>
>✕</button>
</div>
<div className="p-8">
<div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>


+ 12
- 3
lib/db.ts Переглянути файл

@@ -23,12 +23,21 @@ export async function getCards(portalId?: string): Promise<Card[]> {
await ensureDb();
const data = await fs.readFile(CARDS_FILE, 'utf-8');
let cards: Card[] = JSON.parse(data || '[]');
// Backward-compat: convert old string[] extraImages → MediaItem[] extraMedia
cards = cards.map(c => {
const legacy = (c as any).extraImages;
if (Array.isArray(legacy) && !c.extraMedia) {
c.extraMedia = legacy.map((url: string) => ({ url }));
delete (c as any).extraImages;
}
return c;
});
if (portalId) {
cards = cards.filter(c => c.portalId === portalId);
}
// ALWAYS sort, regardless of whether portalId was passed
return cards.sort((a, b) => a.displayOrder - b.displayOrder);
}


+ 6
- 1
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;


Завантаження…
Відмінити
Зберегти