Files
openccb/web/studio/src/components/blocks/HotspotBlock.tsx
T

252 lines
14 KiB
TypeScript

"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import { Search, MapPin, Plus, Trash2, Image as ImageIcon, Crosshair } from "lucide-react";
import AssetPickerModal from "../AssetPickerModal";
import { Asset, getImageUrl } from "@/lib/api";
interface Hotspot {
id: string;
x: number;
y: number;
radius: number;
label: string;
}
interface HotspotBlockProps {
id: string;
title?: string;
description?: string;
imageUrl?: string;
hotspots?: Hotspot[];
editMode: boolean;
courseId: string;
onChange: (updates: { title?: string; description?: string; imageUrl?: string; hotspots?: Hotspot[] }) => void;
}
export default function HotspotBlock({
id,
title,
description,
imageUrl,
hotspots = [],
editMode,
courseId,
onChange
}: HotspotBlockProps) {
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleImageSelect = (asset: Asset) => {
const url = asset.storage_path.replace('uploads/', '/assets/');
onChange({ imageUrl: url });
setIsAssetPickerOpen(false);
};
const handleImageClick = (e: React.MouseEvent) => {
if (!editMode || !containerRef.current || !imageUrl) return;
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
const newHotspot: Hotspot = {
id: Math.random().toString(36).substr(2, 9),
x,
y,
radius: 5,
label: "New Hotspot"
};
onChange({ hotspots: [...hotspots, newHotspot] });
};
const updateHotspot = (index: number, updates: Partial<Hotspot>) => {
const newHotspots = [...hotspots];
newHotspots[index] = { ...newHotspots[index], ...updates };
onChange({ hotspots: newHotspots });
};
const removeHotspot = (index: number) => {
const newHotspots = hotspots.filter((_, i) => i !== index);
onChange({ hotspots: newHotspots });
};
if (!editMode) {
return (
<div className="space-y-4" id={id}>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/20 text-amber-500">
<Search size={20} />
</div>
<div>
<h3 className="text-lg font-bold text-white transition-colors">{title || "Image Hunt"}</h3>
<p className="text-xs text-gray-500 uppercase tracking-widest font-black">{description || "Find the hidden spots!"}</p>
</div>
</div>
<div className="relative aspect-video rounded-2xl overflow-hidden border border-white/5 bg-black/40">
{imageUrl ? (
<Image src={getImageUrl(imageUrl)} alt={title || ""} fill className="object-cover opacity-50" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-gray-600 italic text-sm">No image provided.</div>
)}
<div className="absolute inset-0 flex items-center justify-center backdrop-blur-[2px]">
<div className="text-center space-y-2">
<Crosshair className="w-12 h-12 text-white/20 mx-auto" />
<p className="text-xs font-bold text-white/40 uppercase tracking-widest">Interactive Game Preview (Switch to Student View to Play)</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6" id={id}>
<div className="p-6 glass border-white/5 bg-white/5 space-y-6 rounded-3xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Game Title</label>
<input
type="text"
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="e.g. Parts of the Body..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-sm font-bold focus:border-amber-500/50 focus:outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Student Instructions</label>
<input
type="text"
value={description || ""}
onChange={(e) => onChange({ description: e.target.value })}
placeholder="e.g. Find and click on the following items..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2 text-sm focus:border-amber-500/50 focus:outline-none"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Game Image</label>
{!imageUrl ? (
<button
onClick={() => setIsAssetPickerOpen(true)}
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 hover:border-amber-500/50 hover:bg-amber-500/5 transition-all flex flex-col items-center justify-center gap-2 group"
>
<ImageIcon className="text-gray-600 group-hover:text-amber-500 transition-colors" size={32} />
<span className="text-xs font-bold text-gray-500 uppercase tracking-widest group-hover:text-amber-300">Choose Image</span>
</button>
) : (
<div className="relative aspect-video rounded-2xl overflow-hidden group">
<Image src={getImageUrl(imageUrl)} alt="Hotspot base" fill className="object-cover" />
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<button onClick={() => setIsAssetPickerOpen(true)} className="p-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-all"><ImageIcon size={18} /></button>
<button onClick={() => onChange({ imageUrl: undefined })} className="p-2 bg-red-500/20 hover:bg-red-500/40 rounded-lg text-red-400 transition-all"><Trash2 size={18} /></button>
</div>
</div>
)}
</div>
</div>
{imageUrl && (
<div className="space-y-4 pt-4 border-t border-white/5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">Define Hotspots (Click on the image below)</label>
<span className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded uppercase tracking-widest">{hotspots.length} Defined</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div
ref={containerRef}
onClick={handleImageClick}
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-white/10 cursor-crosshair shadow-2xl"
>
<Image src={getImageUrl(imageUrl)} alt="Define Hotspots" fill className="object-cover select-none" />
{hotspots.map((h, idx) => (
<div
key={h.id}
className="absolute group/pin"
style={{
left: `${h.x}%`,
top: `${h.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
<div
className="bg-amber-500/30 border-2 border-amber-400 rounded-full flex items-center justify-center relative transition-transform hover:scale-110"
style={{
width: `${h.radius * 2}vw`,
height: `${h.radius * 2}vw`,
maxWidth: '100px',
maxHeight: '100px'
}}
>
<div className="bg-amber-500 rounded-full p-1 text-black shadow-lg">
<MapPin size={12} strokeWidth={3} />
</div>
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-[10px] font-bold text-white whitespace-nowrap opacity-0 group-hover/pin:opacity-100 transition-opacity">
{h.label}
</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-3 max-h-[400px] overflow-y-auto custom-scrollbar pr-2">
{hotspots.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 border-2 border-dashed border-white/5 rounded-2xl">
<Plus className="text-gray-700 mb-2" size={24} />
<p className="text-xs text-gray-600 font-bold uppercase tracking-widest">Click on the image to add hotspots</p>
</div>
) : (
hotspots.map((h, idx) => (
<div key={h.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-amber-500 uppercase tracking-widest">Hotspot #{idx + 1}</span>
<button onClick={() => removeHotspot(idx)} className="p-1 hover:bg-red-500/20 text-red-500 rounded transition-colors"><Trash2 size={12} /></button>
</div>
<div className="space-y-2">
<input
type="text"
value={h.label}
onChange={(e) => updateHotspot(idx, { label: e.target.value })}
placeholder="Item name..."
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-xs font-bold focus:border-amber-500/50 focus:outline-none"
/>
<div className="flex items-center gap-3">
<label className="text-[9px] font-black uppercase tracking-widest text-gray-600">Radius</label>
<input
type="range"
min="2"
max="15"
value={h.radius}
onChange={(e) => updateHotspot(idx, { radius: parseInt(e.target.value) })}
className="flex-1 accent-amber-500 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-[10px] font-bold text-gray-400 w-6">{h.radius}%</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
<AssetPickerModal
isOpen={isAssetPickerOpen}
onClose={() => setIsAssetPickerOpen(false)}
courseId={courseId}
filterType="image"
onSelect={handleImageSelect}
/>
</div>
);
}