496 lines
19 KiB
TypeScript
496 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { Button } from '@/ui/Button';
|
|
import { Card } from '@/ui/Card';
|
|
import { Heading } from '@/ui/Heading';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Stack } from '@/ui/Stack';
|
|
import { Text } from '@/ui/Text';
|
|
import {
|
|
Image as ImageIcon,
|
|
RotateCw,
|
|
Save,
|
|
Target,
|
|
ZoomIn,
|
|
ZoomOut
|
|
} from 'lucide-react';
|
|
import { useCallback, useRef, useState } from '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 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 (
|
|
<Stack gap={6}>
|
|
{/* Header */}
|
|
<Stack display="flex" alignItems="center" justifyContent="between">
|
|
<Stack>
|
|
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">{carName}</Heading>
|
|
<Text size="sm" color="text-gray-400">Position sponsor decals on this car's template</Text>
|
|
</Stack>
|
|
<Stack display="flex" alignItems="center" gap={2}>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
|
|
disabled={zoom <= 0.5}
|
|
>
|
|
<Icon icon={ZoomOut} size={4} />
|
|
</Button>
|
|
<Text size="sm" color="text-gray-400"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ minWidth: '3rem' }}
|
|
textAlign="center"
|
|
>
|
|
{Math.round(zoom * 100)}%
|
|
</Text>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => setZoom(z => Math.min(2, z + 0.25))}
|
|
disabled={zoom >= 2}
|
|
>
|
|
<Icon icon={ZoomIn} size={4} />
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Stack display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
|
|
{/* Canvas */}
|
|
<Stack responsiveColSpan={{ lg: 2 }}>
|
|
<Stack
|
|
ref={canvasRef}
|
|
position="relative"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
{/* Base Image or Placeholder */}
|
|
{baseImageUrl ? (
|
|
<Stack
|
|
as="img"
|
|
src={baseImageUrl}
|
|
alt="Livery template"
|
|
fullWidth
|
|
fullHeight
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<Stack fullWidth fullHeight display="flex" flexDirection="col" alignItems="center" justifyContent="center">
|
|
<Icon icon={ImageIcon} size={16} color="text-gray-600"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="mb-2"
|
|
/>
|
|
<Text size="sm" color="text-gray-500">No base template uploaded</Text>
|
|
<Text size="xs" color="text-gray-600">Upload a template image first</Text>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Decal Placeholders */}
|
|
{placements.map((placement) => {
|
|
const decalColors = getSponsorTypeColor(placement.sponsorType);
|
|
return (
|
|
<Stack
|
|
key={placement.id}
|
|
onMouseDown={(e: React.MouseEvent) => handleMouseDown(e, placement.id)}
|
|
onClick={() => handleDecalClick(placement.id)}
|
|
position="absolute"
|
|
cursor="move"
|
|
border
|
|
borderWidth="2px"
|
|
rounded="sm"
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className={`text-xs font-medium transition-all ${
|
|
selectedDecal === placement.id
|
|
? `${decalColors.border} ${decalColors.bg} ${decalColors.text} shadow-lg`
|
|
: `${decalColors.border} ${decalColors.bg} ${decalColors.text} opacity-70 hover:opacity-100`
|
|
}`}
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
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)`,
|
|
}}
|
|
>
|
|
<Stack textAlign="center" truncate px={1}>
|
|
<Stack
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px' }}
|
|
transform="uppercase"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="tracking-wide opacity-70"
|
|
>
|
|
{placement.sponsorType === 'main' ? 'Main' : 'Secondary'}
|
|
</Stack>
|
|
<Stack truncate>{placement.sponsorName}</Stack>
|
|
</Stack>
|
|
|
|
{/* Drag handle indicator */}
|
|
{selectedDecal === placement.id && (
|
|
<Stack position="absolute" top="-1" left="-1" w="3" h="3" bg="bg-white" rounded="full" border borderWidth="2px" borderColor="border-primary-blue" />
|
|
)}
|
|
</Stack>
|
|
);
|
|
})}
|
|
|
|
{/* Grid overlay when dragging */}
|
|
{isDragging && (
|
|
<Stack position="absolute" inset="0" pointerEvents="none">
|
|
<Stack fullWidth fullHeight
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
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%',
|
|
}}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
|
|
<Text size="xs" color="text-gray-500" mt={2} block>
|
|
Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation.
|
|
</Text>
|
|
</Stack>
|
|
|
|
{/* Controls Panel */}
|
|
<Stack gap={4}>
|
|
{/* Decal List */}
|
|
<Card p={4}>
|
|
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Sponsor Slots</Heading>
|
|
<Stack gap={2}>
|
|
{placements.map((placement) => {
|
|
const decalColors = getSponsorTypeColor(placement.sponsorType);
|
|
return (
|
|
<Stack
|
|
key={placement.id}
|
|
as="button"
|
|
onClick={() => setSelectedDecal(placement.id)}
|
|
w="full"
|
|
p={3}
|
|
rounded="lg"
|
|
border
|
|
textAlign="left"
|
|
transition
|
|
borderColor={selectedDecal === placement.id ? decalColors.border : 'border-charcoal-outline'}
|
|
bg={selectedDecal === placement.id ? decalColors.bg : 'bg-iron-gray/30'}
|
|
hoverBg={selectedDecal !== placement.id ? 'bg-iron-gray/50' : undefined}
|
|
>
|
|
<Stack display="flex" alignItems="center" justifyContent="between">
|
|
<Stack>
|
|
<Stack
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px' }}
|
|
weight="medium"
|
|
transform="uppercase"
|
|
color={decalColors.text}
|
|
>
|
|
{placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`}
|
|
</Stack>
|
|
<Stack
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px' }}
|
|
color="text-gray-500"
|
|
mt={0.5}
|
|
>
|
|
{Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% • {placement.rotation}°
|
|
</Stack>
|
|
</Stack>
|
|
<Icon icon={Target} size={4} color={selectedDecal === placement.id ? decalColors.text : 'text-gray-500'} />
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Card>
|
|
|
|
{/* Selected Decal Controls */}
|
|
{selectedPlacement && (
|
|
<Card p={4}>
|
|
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Adjust Selected</Heading>
|
|
|
|
{/* Position */}
|
|
<Stack mb={4}>
|
|
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Position</Text>
|
|
<Stack display="grid" gridCols={2} gap={2}>
|
|
<Stack>
|
|
<Text as="label" size="xs" color="text-gray-500" block mb={1}>X</Text>
|
|
<Stack
|
|
as="input"
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={selectedPlacement.x * 100}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
|
|
fullWidth
|
|
h="2"
|
|
bg="bg-charcoal-outline"
|
|
rounded="lg"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="appearance-none cursor-pointer accent-primary-blue"
|
|
/>
|
|
</Stack>
|
|
<Stack>
|
|
<Text as="label" size="xs" color="text-gray-500" block mb={1}>Y</Text>
|
|
<Stack
|
|
as="input"
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={selectedPlacement.y * 100}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
|
|
fullWidth
|
|
h="2"
|
|
bg="bg-charcoal-outline"
|
|
rounded="lg"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="appearance-none cursor-pointer accent-primary-blue"
|
|
/>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Size */}
|
|
<Stack mb={4}>
|
|
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Size</Text>
|
|
<Stack display="flex" gap={2}>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleResize(selectedPlacement.id, 0.9)}
|
|
fullWidth
|
|
>
|
|
<Icon icon={ZoomOut} size={4}
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="mr-1"
|
|
/>
|
|
Smaller
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleResize(selectedPlacement.id, 1.1)}
|
|
fullWidth
|
|
>
|
|
<Icon icon={ZoomIn} size={4}
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="mr-1"
|
|
/>
|
|
Larger
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Rotation */}
|
|
<Stack mb={4}>
|
|
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Rotation: {selectedPlacement.rotation}°</Text>
|
|
<Stack display="flex" alignItems="center" gap={2}>
|
|
<Stack
|
|
as="input"
|
|
type="range"
|
|
min="0"
|
|
max="360"
|
|
step="15"
|
|
value={selectedPlacement.rotation}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
|
|
flexGrow={1}
|
|
h="2"
|
|
bg="bg-charcoal-outline"
|
|
rounded="lg"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="appearance-none cursor-pointer accent-primary-blue"
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleRotate(selectedPlacement.id, 90)}
|
|
px={2}
|
|
>
|
|
<Icon icon={RotateCw} size={4} />
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Save Button */}
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
fullWidth
|
|
>
|
|
<Icon icon={Save} size={4}
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="mr-2"
|
|
/>
|
|
{saving ? 'Saving...' : 'Save Placements'}
|
|
</Button>
|
|
|
|
{/* Help Text */}
|
|
<Stack p={3} rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
|
|
<Text size="xs" color="text-gray-500" block>
|
|
<Text weight="bold" color="text-gray-400">Tip:</Text> Main sponsor gets the largest, most prominent placement.
|
|
Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries.
|
|
</Text>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
} |