Files
gridpilot.gg/apps/website/components/leagues/LeagueDecalPlacementEditor.tsx
2025-12-10 12:38:55 +01:00

417 lines
15 KiB
TypeScript

'use client';
import { useState, useRef, useCallback } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
Move,
RotateCw,
ZoomIn,
ZoomOut,
Save,
Trash2,
Plus,
Image,
Target
} from 'lucide-react';
interface DecalPlacement {
id: string;
sponsorType: 'main' | 'secondary-1' | 'secondary-2';
sponsorName: string;
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
rotation: number; // 0-360 degrees
}
interface LeagueDecalPlacementEditorProps {
leagueId: string;
seasonId: string;
carId: string;
carName: string;
baseImageUrl?: string;
existingPlacements?: DecalPlacement[];
onSave?: (placements: DecalPlacement[]) => void;
}
const DEFAULT_PLACEMENTS: Omit<DecalPlacement, 'id'>[] = [
{
sponsorType: 'main',
sponsorName: 'Main Sponsor',
x: 0.3,
y: 0.15,
width: 0.4,
height: 0.15,
rotation: 0,
},
{
sponsorType: 'secondary-1',
sponsorName: 'Secondary Sponsor 1',
x: 0.05,
y: 0.5,
width: 0.15,
height: 0.1,
rotation: 0,
},
{
sponsorType: 'secondary-2',
sponsorName: 'Secondary Sponsor 2',
x: 0.8,
y: 0.5,
width: 0.15,
height: 0.1,
rotation: 0,
},
];
export default function LeagueDecalPlacementEditor({
leagueId,
seasonId,
carId,
carName,
baseImageUrl,
existingPlacements,
onSave,
}: LeagueDecalPlacementEditorProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const [placements, setPlacements] = useState<DecalPlacement[]>(
existingPlacements ?? DEFAULT_PLACEMENTS.map((p, i) => ({ ...p, id: `decal-${i}` }))
);
const [selectedDecal, setSelectedDecal] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const selectedPlacement = placements.find(p => p.id === selectedDecal);
const handleDecalClick = (id: string) => {
setSelectedDecal(id === selectedDecal ? null : id);
};
const updatePlacement = useCallback((id: string, updates: Partial<DecalPlacement>) => {
setPlacements(prev => prev.map(p =>
p.id === id ? { ...p, ...updates } : p
));
}, []);
const handleMouseDown = (e: React.MouseEvent, decalId: string) => {
e.stopPropagation();
setSelectedDecal(decalId);
setIsDragging(true);
};
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging || !selectedDecal || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
// Clamp to canvas bounds
const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y));
updatePlacement(selectedDecal, { x: clampedX, y: clampedY });
}, [isDragging, selectedDecal, updatePlacement]);
const handleMouseUp = () => {
setIsDragging(false);
};
const handleRotate = (id: string, delta: number) => {
const placement = placements.find(p => p.id === id);
if (placement) {
const newRotation = (placement.rotation + delta + 360) % 360;
updatePlacement(id, { rotation: newRotation });
}
};
const handleResize = (id: string, scaleFactor: number) => {
const placement = placements.find(p => p.id === id);
if (placement) {
const newWidth = Math.max(0.05, Math.min(0.5, placement.width * scaleFactor));
const newHeight = Math.max(0.03, Math.min(0.3, placement.height * scaleFactor));
updatePlacement(id, { width: newWidth, height: newHeight });
}
};
const handleSave = async () => {
setSaving(true);
try {
// Alpha: In-memory save simulation
console.log('Saving decal placements:', {
leagueId,
seasonId,
carId,
placements,
});
await new Promise(resolve => setTimeout(resolve, 500));
onSave?.(placements);
alert('Decal placements saved successfully.');
} catch (err) {
console.error('Save failed:', err);
alert('Failed to save placements.');
} finally {
setSaving(false);
}
};
const getSponsorTypeColor = (type: DecalPlacement['sponsorType']) => {
switch (type) {
case 'main':
return { border: 'border-primary-blue', bg: 'bg-primary-blue/20', text: 'text-primary-blue' };
case 'secondary-1':
return { border: 'border-purple-500', bg: 'bg-purple-500/20', text: 'text-purple-400' };
case 'secondary-2':
return { border: 'border-purple-500', bg: 'bg-purple-500/20', text: 'text-purple-400' };
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{carName}</h3>
<p className="text-sm text-gray-400">Position sponsor decals on this car's template</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
disabled={zoom <= 0.5}
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-sm text-gray-400 min-w-[3rem] text-center">{Math.round(zoom * 100)}%</span>
<Button
variant="secondary"
onClick={() => setZoom(z => Math.min(2, z + 0.25))}
disabled={zoom >= 2}
>
<ZoomIn className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Canvas */}
<div className="lg:col-span-2">
<div
ref={canvasRef}
className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Base Image or Placeholder */}
{baseImageUrl ? (
<img
src={baseImageUrl}
alt="Livery template"
className="w-full h-full object-cover"
draggable={false}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<Image className="w-16 h-16 text-gray-600 mb-2" />
<p className="text-sm text-gray-500">No base template uploaded</p>
<p className="text-xs text-gray-600">Upload a template image first</p>
</div>
)}
{/* Decal Placeholders */}
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
return (
<div
key={placement.id}
onMouseDown={(e) => handleMouseDown(e, placement.id)}
onClick={() => handleDecalClick(placement.id)}
className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg} ${colors.text} shadow-lg`
: `${colors.border} ${colors.bg} ${colors.text} opacity-70 hover:opacity-100`
}`}
style={{
left: `${placement.x * 100}%`,
top: `${placement.y * 100}%`,
width: `${placement.width * 100}%`,
height: `${placement.height * 100}%`,
transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`,
}}
>
<div className="text-center truncate px-1">
<div className="text-[10px] uppercase tracking-wide opacity-70">
{placement.sponsorType === 'main' ? 'Main' : 'Secondary'}
</div>
<div className="truncate">{placement.sponsorName}</div>
</div>
{/* Drag handle indicator */}
{selectedDecal === placement.id && (
<div className="absolute -top-1 -left-1 w-3 h-3 bg-white rounded-full border-2 border-primary-blue" />
)}
</div>
);
})}
{/* Grid overlay when dragging */}
{isDragging && (
<div className="absolute inset-0 pointer-events-none">
<div className="w-full h-full" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '10% 10%',
}} />
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation.
</p>
</div>
{/* Controls Panel */}
<div className="space-y-4">
{/* Decal List */}
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Sponsor Slots</h4>
<div className="space-y-2">
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
return (
<button
key={placement.id}
onClick={() => setSelectedDecal(placement.id)}
className={`w-full p-3 rounded-lg border text-left transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg}`
: 'border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className={`text-xs font-medium uppercase ${colors.text}`}>
{placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% • {placement.rotation}°
</div>
</div>
<Target className={`w-4 h-4 ${selectedDecal === placement.id ? colors.text : 'text-gray-500'}`} />
</div>
</button>
);
})}
</div>
</Card>
{/* Selected Decal Controls */}
{selectedPlacement && (
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Adjust Selected</h4>
{/* Position */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Position</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">X</label>
<input
type="range"
min="0"
max="100"
value={selectedPlacement.x * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Y</label>
<input
type="range"
min="0"
max="100"
value={selectedPlacement.y * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
/>
</div>
</div>
</div>
{/* Size */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Size</label>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 0.9)}
className="flex-1"
>
<ZoomOut className="w-4 h-4 mr-1" />
Smaller
</Button>
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 1.1)}
className="flex-1"
>
<ZoomIn className="w-4 h-4 mr-1" />
Larger
</Button>
</div>
</div>
{/* Rotation */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Rotation: {selectedPlacement.rotation}°</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="360"
step="15"
value={selectedPlacement.rotation}
onChange={(e) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
/>
<Button
variant="secondary"
onClick={() => handleRotate(selectedPlacement.id, 90)}
className="px-2"
>
<RotateCw className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
)}
{/* Save Button */}
<Button
variant="primary"
onClick={handleSave}
disabled={saving}
className="w-full"
>
<Save className="w-4 h-4 mr-2" />
{saving ? 'Saving...' : 'Save Placements'}
</Button>
{/* Help Text */}
<div className="p-3 rounded-lg bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-500">
<strong className="text-gray-400">Tip:</strong> Main sponsor gets the largest, most prominent placement.
Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries.
</p>
</div>
</div>
</div>
</div>
);
}