Files
qrcode/app/components/CustomizationPanel.tsx
Puechberty Arthur a0d952ae0f first commit
2026-03-30 19:30:30 +02:00

311 lines
12 KiB
TypeScript

'use client';
import { useCallback, useRef, useState } from 'react';
import Image from 'next/image';
import {
Palette,
Maximize,
Shield,
ImagePlus,
Square,
Circle,
RectangleHorizontal,
Trash2,
Upload,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import type { QRCustomization, ErrorCorrectionLevel, ModuleStyle } from '../lib/types';
interface CustomizationPanelProps {
customization: QRCustomization;
onCustomizationChange: (customization: QRCustomization) => void;
}
const EC_LEVELS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
{ value: 'L', label: 'L', desc: '7% correction' },
{ value: 'M', label: 'M', desc: '15% correction' },
{ value: 'Q', label: 'Q', desc: '25% correction' },
{ value: 'H', label: 'H', desc: '30% correction' },
];
const MODULE_STYLES: { value: ModuleStyle; label: string; icon: React.ReactNode }[] = [
{ value: 'square', label: 'Carré', icon: <Square size={16} /> },
{ value: 'rounded', label: 'Arrondi', icon: <RectangleHorizontal size={16} /> },
{ value: 'dots', label: 'Points', icon: <Circle size={16} /> },
];
export default function CustomizationPanel({
customization,
onCustomizationChange,
}: CustomizationPanelProps) {
const [isOpen, setIsOpen] = useState(true);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const update = useCallback(
(partial: Partial<QRCustomization>) => {
onCustomizationChange({ ...customization, ...partial });
},
[customization, onCustomizationChange]
);
const handleLogoUpload = useCallback(
(file: File) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
update({ logoDataUrl: e.target?.result as string });
};
reader.readAsDataURL(file);
},
[update]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleLogoUpload(file);
},
[handleLogoUpload]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
return (
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-center gap-2">
<Palette size={18} className="text-violet-500" />
<span className="font-semibold text-gray-900 dark:text-white">Personnalisation</span>
</div>
{isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
{isOpen && (
<div className="px-5 pb-5 space-y-5 animate-fade-in">
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
Couleur QR
</label>
<div className="flex items-center gap-2">
<div className="relative w-10 h-10 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<input
type="color"
value={customization.fgColor}
onChange={(e) => update({ fgColor: e.target.value })}
aria-label="Couleur du QR Code"
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div
className="w-full h-full"
style={{ backgroundColor: customization.fgColor }}
/>
</div>
<input
type="text"
value={customization.fgColor}
onChange={(e) => update({ fgColor: e.target.value })}
aria-label="Code hexadécimal couleur QR"
className="flex-1 px-3 py-2 text-xs rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
Couleur fond
</label>
<div className="flex items-center gap-2">
<div className="relative w-10 h-10 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<input
type="color"
value={customization.bgColor}
onChange={(e) => update({ bgColor: e.target.value })}
aria-label="Couleur de fond du QR Code"
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div
className="w-full h-full"
style={{ backgroundColor: customization.bgColor }}
/>
</div>
<input
type="text"
value={customization.bgColor}
onChange={(e) => update({ bgColor: e.target.value })}
aria-label="Code hexadécimal couleur de fond"
className="flex-1 px-3 py-2 text-xs rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono"
/>
</div>
</div>
</div>
{/* Size slider */}
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<Maximize size={14} />
Taille
</label>
<span className="text-xs font-mono text-gray-400">{customization.size}px</span>
</div>
<input
type="range"
min={150}
max={800}
step={10}
value={customization.size}
onChange={(e) => update({ size: parseInt(e.target.value) })}
aria-label="Taille du QR Code en pixels"
className="w-full accent-violet-600"
/>
</div>
{/* Error Correction Level */}
<div>
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5 mb-2">
<Shield size={14} />
Niveau de correction
</label>
<div className="grid grid-cols-4 gap-2">
{EC_LEVELS.map((level) => (
<button
key={level.value}
onClick={() => update({ ecLevel: level.value })}
className={`p-2 rounded-lg text-center transition-all ${
customization.ecLevel === level.value
? 'bg-violet-600 text-white shadow-lg shadow-violet-500/25'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<div className="text-sm font-bold">{level.label}</div>
<div className={`text-[10px] ${customization.ecLevel === level.value ? 'text-violet-200' : 'text-gray-400'}`}>
{level.desc}
</div>
</button>
))}
</div>
</div>
{/* Module Style */}
<div>
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 block">
Style des modules
</label>
<div className="grid grid-cols-3 gap-2">
{MODULE_STYLES.map((style) => (
<button
key={style.value}
onClick={() => update({ moduleStyle: style.value })}
className={`flex items-center justify-center gap-1.5 p-2.5 rounded-lg text-sm transition-all ${
customization.moduleStyle === style.value
? 'bg-violet-600 text-white shadow-lg shadow-violet-500/25'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{style.icon}
{style.label}
</button>
))}
</div>
</div>
{/* Corner Radius */}
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">
Coins arrondis
</label>
<span className="text-xs font-mono text-gray-400">{customization.cornerRadius}px</span>
</div>
<input
type="range"
min={0}
max={40}
step={2}
value={customization.cornerRadius}
onChange={(e) => update({ cornerRadius: parseInt(e.target.value) })}
aria-label="Rayon des coins arrondis en pixels"
className="w-full accent-violet-600"
/>
</div>
{/* Logo Upload */}
<div>
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5 mb-2">
<ImagePlus size={14} />
Logo central
</label>
{customization.logoDataUrl ? (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
<Image
src={customization.logoDataUrl}
alt="Logo"
width={48}
height={48}
unoptimized
className="w-12 h-12 object-contain rounded-lg"
/>
<div className="flex-1 text-sm text-gray-600 dark:text-gray-400">
Logo ajouté
</div>
<button
onClick={() => update({ logoDataUrl: null })}
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
</div>
) : (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${
isDragging
? 'border-violet-500 bg-violet-50 dark:bg-violet-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-violet-400 hover:bg-violet-50/50 dark:hover:bg-violet-900/10'
}`}
>
<Upload size={24} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Glissez une image ou <span className="text-violet-500 font-medium">parcourir</span>
</p>
<p className="text-xs text-gray-400 mt-1">
PNG, JPG, SVG Conseil : utilisez un niveau H
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
aria-label="Télécharger un logo pour le QR Code"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleLogoUpload(file);
}}
className="hidden"
/>
</div>
)}
</div>
</div>
)}
</div>
);
}