website refactor
This commit is contained in:
@@ -3,14 +3,16 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import {
|
||||
Move,
|
||||
RotateCw,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Save,
|
||||
Trash2,
|
||||
Plus,
|
||||
Image as ImageIcon,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
@@ -66,7 +68,7 @@ const DEFAULT_PLACEMENTS: Omit<DecalPlacement, 'id'>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function LeagueDecalPlacementEditor({
|
||||
export function LeagueDecalPlacementEditor({
|
||||
leagueId,
|
||||
seasonId,
|
||||
carId,
|
||||
@@ -170,38 +172,47 @@ export default function LeagueDecalPlacementEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={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">
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box>
|
||||
<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>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="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" />
|
||||
<Icon icon={ZoomOut} size={4} />
|
||||
</Button>
|
||||
<span className="text-sm text-gray-400 min-w-[3rem] text-center">{Math.round(zoom * 100)}%</span>
|
||||
<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}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
<Icon icon={ZoomIn} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
|
||||
{/* Canvas */}
|
||||
<div className="lg:col-span-2">
|
||||
<div
|
||||
<Box responsiveColSpan={{ lg: 2 }}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
|
||||
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}
|
||||
@@ -209,33 +220,50 @@ export default function LeagueDecalPlacementEditor({
|
||||
>
|
||||
{/* Base Image or Placeholder */}
|
||||
{baseImageUrl ? (
|
||||
<img
|
||||
<Box
|
||||
as="img"
|
||||
src={baseImageUrl}
|
||||
alt="Livery template"
|
||||
className="w-full h-full object-cover"
|
||||
fullWidth
|
||||
fullHeight
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
<ImageIcon 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>
|
||||
<Box 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>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Decal Placeholders */}
|
||||
{placements.map((placement) => {
|
||||
const colors = getSponsorTypeColor(placement.sponsorType);
|
||||
const decalColors = getSponsorTypeColor(placement.sponsorType);
|
||||
return (
|
||||
<div
|
||||
<Box
|
||||
key={placement.id}
|
||||
onMouseDown={(e) => handleMouseDown(e, placement.id)}
|
||||
onMouseDown={(e: React.MouseEvent) => 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 ${
|
||||
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
|
||||
? `${colors.border} ${colors.bg} ${colors.text} shadow-lg`
|
||||
: `${colors.border} ${colors.bg} ${colors.text} opacity-70 hover:opacity-100`
|
||||
? `${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}%`,
|
||||
@@ -244,151 +272,200 @@ export default function LeagueDecalPlacementEditor({
|
||||
transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center truncate px-1">
|
||||
<div className="text-[10px] uppercase tracking-wide opacity-70">
|
||||
<Box textAlign="center" truncate px={1}>
|
||||
<Box
|
||||
// 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'}
|
||||
</div>
|
||||
<div className="truncate">{placement.sponsorName}</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box truncate>{placement.sponsorName}</Box>
|
||||
</Box>
|
||||
|
||||
{/* 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" />
|
||||
<Box position="absolute" top="-1" left="-1" w="3" h="3" bg="bg-white" rounded="full" border borderWidth="2px" borderColor="border-primary-blue" />
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
<Box position="absolute" inset="0" pointerEvents="none">
|
||||
<Box 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%',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Controls Panel */}
|
||||
<div className="space-y-4">
|
||||
<Stack gap={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">
|
||||
<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 colors = getSponsorTypeColor(placement.sponsorType);
|
||||
const decalColors = getSponsorTypeColor(placement.sponsorType);
|
||||
return (
|
||||
<button
|
||||
<Box
|
||||
key={placement.id}
|
||||
as="button"
|
||||
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'
|
||||
}`}
|
||||
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}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className={`text-xs font-medium uppercase ${colors.text}`}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box>
|
||||
<Box
|
||||
// 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]}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
</Box>
|
||||
<Box
|
||||
// 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}°
|
||||
</div>
|
||||
</div>
|
||||
<Target className={`w-4 h-4 ${selectedDecal === placement.id ? colors.text : 'text-gray-500'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Icon icon={Target} size={4} color={selectedDecal === placement.id ? decalColors.text : 'text-gray-500'} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Selected Decal Controls */}
|
||||
{selectedPlacement && (
|
||||
<Card className="p-4">
|
||||
<h4 className="text-sm font-semibold text-white mb-3">Adjust Selected</h4>
|
||||
<Card p={4}>
|
||||
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Adjust Selected</Heading>
|
||||
|
||||
{/* 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
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Position</Text>
|
||||
<Box display="grid" gridCols={2} gap={2}>
|
||||
<Box>
|
||||
<Text as="label" size="xs" color="text-gray-500" block mb={1}>X</Text>
|
||||
<Box
|
||||
as="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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Y</label>
|
||||
<input
|
||||
</Box>
|
||||
<Box>
|
||||
<Text as="label" size="xs" color="text-gray-500" block mb={1}>Y</Text>
|
||||
<Box
|
||||
as="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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Size */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-400 mb-2">Size</label>
|
||||
<div className="flex gap-2">
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Size</Text>
|
||||
<Box display="flex" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleResize(selectedPlacement.id, 0.9)}
|
||||
className="flex-1"
|
||||
fullWidth
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 mr-1" />
|
||||
<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)}
|
||||
className="flex-1"
|
||||
fullWidth
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 mr-1" />
|
||||
<Icon icon={ZoomIn} size={4}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="mr-1"
|
||||
/>
|
||||
Larger
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 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
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Rotation: {selectedPlacement.rotation}°</Text>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box
|
||||
as="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"
|
||||
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)}
|
||||
className="px-2"
|
||||
px={2}
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
<Icon icon={RotateCw} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -397,21 +474,24 @@ export default function LeagueDecalPlacementEditor({
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full"
|
||||
fullWidth
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
<Icon icon={Save} size={4}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="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.
|
||||
<Box 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user