first commit

This commit is contained in:
Puechberty Arthur
2026-03-30 19:30:30 +02:00
commit a0d952ae0f
42 changed files with 9896 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { FileSpreadsheet, X, Download, Upload, AlertCircle, CheckCircle2 } from 'lucide-react';
import Image from 'next/image';
import Papa from 'papaparse';
import QRCode from 'qrcode';
interface CSVBulkGeneratorProps {
isOpen: boolean;
onClose: () => void;
}
interface CSVRow {
content: string;
type?: string;
filename?: string;
}
interface GeneratedQR {
content: string;
dataUrl: string;
filename: string;
}
export default function CSVBulkGenerator({ isOpen, onClose }: CSVBulkGeneratorProps) {
const [isDragging, setIsDragging] = useState(false);
const [rows, setRows] = useState<CSVRow[]>([]);
const [generated, setGenerated] = useState<GeneratedQR[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFile = useCallback((file: File) => {
setError(null);
setGenerated([]);
Papa.parse<Record<string, string>>(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.errors.length > 0) {
setError('Erreur de lecture du fichier CSV');
return;
}
const parsed: CSVRow[] = results.data
.map((row) => ({
content: row.content || row.text || row.url || row.data || Object.values(row)[0] || '',
type: row.type,
filename: row.filename || row.name,
}))
.filter((r) => r.content.trim() !== '');
if (parsed.length === 0) {
setError('Aucune donnée trouvée. Assurez-vous que le CSV contient une colonne "content".');
return;
}
setRows(parsed);
},
});
}, []);
const generateAll = useCallback(async () => {
setIsProcessing(true);
const results: GeneratedQR[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
try {
const dataUrl = await QRCode.toDataURL(row.content, {
width: 400,
margin: 2,
errorCorrectionLevel: 'M',
});
results.push({
content: row.content,
dataUrl,
filename: row.filename || `qrcode-${i + 1}`,
});
} catch {
results.push({
content: row.content,
dataUrl: '',
filename: row.filename || `qrcode-${i + 1}`,
});
}
}
setGenerated(results);
setIsProcessing(false);
}, [rows]);
const downloadAll = useCallback(() => {
generated.forEach((qr) => {
if (qr.dataUrl) {
const link = document.createElement('a');
link.download = `${qr.filename}.png`;
link.href = qr.dataUrl;
link.click();
}
});
}, [generated]);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file && (file.type === 'text/csv' || file.name.endsWith('.csv'))) {
handleFile(file);
} else {
setError('Veuillez importer un fichier CSV');
}
},
[handleFile]
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden animate-scale-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-2">
<FileSpreadsheet size={20} className="text-violet-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Génération en masse
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(80vh-130px)]">
{/* Upload area */}
{rows.length === 0 && (
<div>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 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'
}`}
>
<Upload size={40} className="mx-auto mb-4 text-gray-400" />
<p className="text-gray-600 dark:text-gray-400 font-medium">
Glissez votre fichier CSV ici
</p>
<p className="text-sm text-gray-400 mt-2">
ou <span className="text-violet-500 font-medium">parcourir</span>
</p>
<p className="text-xs text-gray-400 mt-4">
Le CSV doit contenir une colonne &quot;content&quot; avec les données à encoder
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
className="hidden"
/>
</div>
{/* CSV format help */}
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
Format CSV attendu :
</p>
<code className="text-xs text-gray-600 dark:text-gray-400 font-mono block whitespace-pre">
{`content,filename
https://example.com,site-web
Bonjour le monde,message
+33612345678,contact`}
</code>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl mb-4">
<AlertCircle size={18} />
<span className="text-sm">{error}</span>
</div>
)}
{/* Rows preview */}
{rows.length > 0 && generated.length === 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-white">{rows.length}</span> QR Codes à générer
</p>
<div className="flex gap-2">
<button
onClick={() => { setRows([]); setError(null); }}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Annuler
</button>
<button
onClick={generateAll}
disabled={isProcessing}
className="px-4 py-1.5 bg-violet-600 hover:bg-violet-700 text-white text-sm rounded-lg font-medium transition-colors disabled:opacity-50"
>
{isProcessing ? 'Génération...' : 'Générer tout'}
</button>
</div>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{rows.slice(0, 20).map((row, i) => (
<div
key={i}
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm"
>
<span className="text-gray-400 text-xs w-6">{i + 1}</span>
<span className="text-gray-700 dark:text-gray-300 truncate flex-1">
{row.content}
</span>
</div>
))}
{rows.length > 20 && (
<p className="text-xs text-gray-400 text-center py-2">
... et {rows.length - 20} de plus
</p>
)}
</div>
</div>
)}
{/* Generated results */}
{generated.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle2 size={18} />
<span className="text-sm font-medium">
{generated.filter((g) => g.dataUrl).length} QR Codes générés
</span>
</div>
<button
onClick={downloadAll}
className="flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white text-sm rounded-lg font-medium transition-colors"
>
<Download size={16} />
Tout télécharger
</button>
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3 max-h-60 overflow-y-auto">
{generated.map((qr, i) => (
<div key={i} className="text-center">
{qr.dataUrl ? (
<Image
src={qr.dataUrl}
alt={qr.content}
width={256}
height={256}
unoptimized
className="w-full aspect-square rounded-lg border border-gray-200 dark:border-gray-700"
/>
) : (
<div className="w-full aspect-square rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle size={20} className="text-red-400" />
</div>
)}
<p className="text-[10px] text-gray-400 mt-1 truncate">{qr.filename}</p>
</div>
))}
</div>
<button
onClick={() => { setRows([]); setGenerated([]); }}
className="mt-4 w-full py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Nouvelle importation
</button>
</div>
)}
</div>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
'use client';
import { useState, useCallback } from 'react';
import {
Type,
Link,
Mail,
Phone,
MessageSquare,
Wifi,
Eye,
EyeOff,
Sparkles,
} from 'lucide-react';
import type { ContentType, WifiData, EmailData, SmsData, SecurityType } from '../lib/types';
import { CONTENT_TYPE_LABELS } from '../lib/types';
import { detectContentType } from '../lib/qr-renderer';
const ICONS: Record<ContentType, React.ReactNode> = {
text: <Type size={16} />,
url: <Link size={16} />,
email: <Mail size={16} />,
phone: <Phone size={16} />,
sms: <MessageSquare size={16} />,
wifi: <Wifi size={16} />,
};
interface ContentInputProps {
contentType: ContentType;
onContentTypeChange: (type: ContentType) => void;
rawContent: string;
onRawContentChange: (content: string) => void;
wifiData: WifiData;
onWifiDataChange: (data: WifiData) => void;
emailData: EmailData;
onEmailDataChange: (data: EmailData) => void;
smsData: SmsData;
onSmsDataChange: (data: SmsData) => void;
maxChars: number;
currentChars: number;
onAutoDetect: (type: ContentType) => void;
}
export default function ContentInput({
contentType,
onContentTypeChange,
rawContent,
onRawContentChange,
wifiData,
onWifiDataChange,
emailData,
onEmailDataChange,
smsData,
onSmsDataChange,
maxChars,
currentChars,
onAutoDetect,
}: ContentInputProps) {
const [showPassword, setShowPassword] = useState(false);
const [autoDetected, setAutoDetected] = useState(false);
const handleTextChange = useCallback(
(value: string) => {
onRawContentChange(value);
if (contentType === 'text' && value.length > 3) {
const detected = detectContentType(value);
if (detected !== 'text') {
setAutoDetected(true);
onAutoDetect(detected);
setTimeout(() => setAutoDetected(false), 2000);
}
}
},
[contentType, onRawContentChange, onAutoDetect]
);
const types: ContentType[] = ['text', 'url', 'email', 'phone', 'sms', 'wifi'];
return (
<div className="space-y-4">
{/* Content Type Selector */}
<div className="flex flex-wrap gap-2">
{types.map((type) => (
<button
key={type}
onClick={() => onContentTypeChange(type)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
contentType === type
? 'bg-violet-600 text-white shadow-lg shadow-violet-500/25 scale-105'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{ICONS[type]}
{CONTENT_TYPE_LABELS[type]}
</button>
))}
</div>
{/* Auto-detect indicator */}
{autoDetected && (
<div className="flex items-center gap-2 text-sm text-violet-600 dark:text-violet-400 animate-fade-in">
<Sparkles size={14} />
<span>Type détecté automatiquement</span>
</div>
)}
{/* Input Forms */}
<div className="space-y-3">
{contentType === 'text' && (
<div>
<label htmlFor="qr-text-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Texte
</label>
<textarea
id="qr-text-input"
value={rawContent}
onChange={(e) => handleTextChange(e.target.value)}
placeholder="Entrez votre texte ici..."
rows={4}
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all resize-none"
/>
</div>
)}
{contentType === 'url' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
URL
</label>
<input
type="url"
value={rawContent}
onChange={(e) => onRawContentChange(e.target.value)}
placeholder="https://example.com"
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
</div>
)}
{contentType === 'email' && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Adresse email
</label>
<input
type="email"
value={emailData.to}
onChange={(e) => onEmailDataChange({ ...emailData, to: e.target.value })}
placeholder="contact@example.com"
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Sujet <span className="text-gray-400">(optionnel)</span>
</label>
<input
type="text"
value={emailData.subject}
onChange={(e) => onEmailDataChange({ ...emailData, subject: e.target.value })}
placeholder="Sujet de l'email"
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Corps <span className="text-gray-400">(optionnel)</span>
</label>
<textarea
value={emailData.body}
onChange={(e) => onEmailDataChange({ ...emailData, body: e.target.value })}
placeholder="Corps de l'email"
rows={3}
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all resize-none"
/>
</div>
</div>
)}
{contentType === 'phone' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Numéro de téléphone
</label>
<input
type="tel"
value={rawContent}
onChange={(e) => onRawContentChange(e.target.value)}
placeholder="+33 6 12 34 56 78"
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
</div>
)}
{contentType === 'sms' && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Numéro de téléphone
</label>
<input
type="tel"
value={smsData.phone}
onChange={(e) => onSmsDataChange({ ...smsData, phone: e.target.value })}
placeholder="+33 6 12 34 56 78"
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Message
</label>
<textarea
value={smsData.message}
onChange={(e) => onSmsDataChange({ ...smsData, message: e.target.value })}
placeholder="Votre message..."
rows={3}
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all resize-none"
/>
</div>
</div>
)}
{contentType === 'wifi' && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Nom du réseau (SSID)
</label>
<input
type="text"
value={wifiData.ssid}
onChange={(e) => onWifiDataChange({ ...wifiData, ssid: e.target.value })}
placeholder="Mon WiFi"
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Mot de passe
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={wifiData.password}
onChange={(e) => onWifiDataChange({ ...wifiData, password: e.target.value })}
placeholder="••••••••"
className="w-full px-4 py-3 pr-12 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Sécurité
</label>
<div className="flex gap-2">
{(['WPA', 'WEP', 'nopass'] as SecurityType[]).map((sec) => (
<button
key={sec}
onClick={() => onWifiDataChange({ ...wifiData, security: sec })}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
wifiData.security === sec
? 'bg-violet-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{sec === 'nopass' ? 'Aucune' : sec}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={wifiData.hidden}
onChange={(e) => onWifiDataChange({ ...wifiData, hidden: e.target.checked })}
className="rounded border-gray-300 text-violet-600 focus:ring-violet-500"
/>
Réseau masqué
</label>
</div>
)}
</div>
{/* Character counter */}
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400 dark:text-gray-500">
{currentChars} / {maxChars} caractères
</span>
<div className="w-32 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
currentChars / maxChars > 0.9
? 'bg-red-500'
: currentChars / maxChars > 0.7
? 'bg-yellow-500'
: 'bg-violet-500'
}`}
style={{ width: `${Math.min(100, (currentChars / maxChars) * 100)}%` }}
/>
</div>
</div>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
'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>
);
}
+439
View File
@@ -0,0 +1,439 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { Sun, Moon, FileSpreadsheet, QrCode, History } from 'lucide-react';
import { useTheme } from './ThemeProvider';
import ContentInput from './ContentInput';
import CustomizationPanel from './CustomizationPanel';
import QRPreview from './QRPreview';
import QRHistory from './QRHistory';
import CSVBulkGenerator from './CSVBulkGenerator';
import type {
ContentType,
QRCustomization,
WifiData,
EmailData,
SmsData,
HistoryItem,
} from '../lib/types';
import { DEFAULT_CUSTOMIZATION, MAX_CHARS } from '../lib/types';
import { encodeContent, parseShareUrl, renderQRToCanvas } from '../lib/qr-renderer';
export default function QRCodeGenerator() {
const { theme, toggleTheme } = useTheme();
const [contentType, setContentType] = useState<ContentType>('text');
const [rawContent, setRawContent] = useState('');
const [customization, setCustomization] = useState<QRCustomization>(DEFAULT_CUSTOMIZATION);
const [wifiData, setWifiData] = useState<WifiData>({
ssid: '',
password: '',
security: 'WPA',
hidden: false,
});
const [emailData, setEmailData] = useState<EmailData>({
to: '',
subject: '',
body: '',
});
const [smsData, setSmsData] = useState<SmsData>({
phone: '',
message: '',
});
const [history, setHistory] = useState<HistoryItem[]>([]);
const [showCSV, setShowCSV] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Load history and URL params on mount
useEffect(() => {
const rafId = window.requestAnimationFrame(() => {
try {
const stored = localStorage.getItem('qr-history');
if (stored) {
setHistory(JSON.parse(stored));
}
} catch {
// ignore
}
// Parse URL params
const params = new URLSearchParams(window.location.search);
const parsed = parseShareUrl(params);
if (parsed) {
if (parsed.contentType) setContentType(parsed.contentType);
if (parsed.content) setRawContent(parsed.content);
if (parsed.customization) {
setCustomization((prev) => ({ ...prev, ...parsed.customization }));
}
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
}
});
return () => window.cancelAnimationFrame(rafId);
}, []);
// Encode content based on type
const encodedContent = encodeContent(
contentType,
rawContent,
wifiData,
emailData,
smsData
);
const currentChars = encodedContent.length;
const maxChars = MAX_CHARS[customization.ecLevel];
// Auto-save to history when content changes (debounced)
useEffect(() => {
if (!encodedContent || encodedContent.length < 1) return;
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
// Use a temporary canvas to get the data URL
const tempCanvas = document.createElement('canvas');
renderQRToCanvas(tempCanvas, encodedContent, {
...customization,
size: 200,
logoDataUrl: null, // don't save logo in history thumbnails
}).then(() => {
const dataUrl = tempCanvas.toDataURL('image/png');
const newItem: HistoryItem = {
id: Date.now().toString(),
contentType,
rawContent: contentType === 'wifi'
? wifiData.ssid
: contentType === 'email'
? emailData.to
: contentType === 'sms'
? smsData.phone
: rawContent,
encodedContent,
dataUrl,
timestamp: Date.now(),
customization: { ...customization, logoDataUrl: null },
};
setHistory((prev) => {
// Check if the same content already exists
const filtered = prev.filter((h) => h.encodedContent !== encodedContent);
const updated = [newItem, ...filtered].slice(0, 10);
localStorage.setItem('qr-history', JSON.stringify(updated));
return updated;
});
});
}, 1500);
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, [encodedContent, contentType, customization, rawContent, wifiData, emailData, smsData]);
const handleReset = useCallback(() => {
setRawContent('');
setWifiData({ ssid: '', password: '', security: 'WPA', hidden: false });
setEmailData({ to: '', subject: '', body: '' });
setSmsData({ phone: '', message: '' });
setCustomization(DEFAULT_CUSTOMIZATION);
setContentType('text');
}, []);
const handleHistorySelect = useCallback((item: HistoryItem) => {
setContentType(item.contentType);
setRawContent(item.encodedContent);
setCustomization({ ...item.customization, logoDataUrl: null });
setShowHistory(false);
}, []);
const handleClearHistory = useCallback(() => {
setHistory([]);
localStorage.removeItem('qr-history');
}, []);
const handleAutoDetect = useCallback((type: ContentType) => {
setContentType(type);
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors duration-300">
{/* Skip to content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-violet-600 focus:text-white focus:rounded-lg"
>
Aller au contenu principal
</a>
{/* Header */}
<header className="sticky top-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200 dark:border-gray-800" role="banner">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between" aria-label="Navigation principale">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-linear-to-br from-violet-600 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-violet-500/25" aria-hidden="true">
<QrCode size={20} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">
QR Code Generator
</h1>
<p className="text-xs text-gray-400 hidden sm:block">
Créez, personnalisez et partagez vos QR Codes
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHistory(!showHistory)}
aria-expanded={showHistory}
aria-controls="history-panel"
className={`relative flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
showHistory
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}
>
<History size={16} />
<span className="hidden sm:inline">Historique</span>
{history.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-violet-600 text-white text-[10px] font-bold rounded-full flex items-center justify-center" aria-label={`${history.length} QR codes dans l'historique`}>
{history.length}
</span>
)}
</button>
<button
onClick={() => setShowCSV(true)}
className="flex items-center gap-1.5 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl text-sm font-medium text-gray-600 dark:text-gray-400 transition-all"
aria-label="Import CSV pour génération en masse"
>
<FileSpreadsheet size={16} />
<span className="hidden sm:inline">CSV</span>
</button>
<button
onClick={toggleTheme}
className="p-2.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl text-gray-600 dark:text-gray-400 transition-all"
aria-label={theme === 'light' ? 'Activer le mode sombre' : 'Activer le mode clair'}
title={theme === 'light' ? 'Mode sombre' : 'Mode clair'}
>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
</div>
</nav>
</header>
{/* Main Content */}
<main id="main-content" className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8" role="main">
{/* History Panel */}
{showHistory && (
<section id="history-panel" className="mb-6 bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5 animate-slide-down" aria-label="Historique des QR Codes">
<QRHistory
history={history}
onSelect={handleHistorySelect}
onClear={handleClearHistory}
/>
</section>
)}
{/* Generator */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Panel - Input & Customization */}
<div className="space-y-5">
{/* Content Input Card */}
<section className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5 sm:p-6" aria-label="Contenu du QR Code">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<div className="w-1.5 h-5 bg-linear-to-b from-violet-600 to-indigo-600 rounded-full" aria-hidden="true" />
Contenu du QR Code
</h2>
<ContentInput
contentType={contentType}
onContentTypeChange={setContentType}
rawContent={rawContent}
onRawContentChange={setRawContent}
wifiData={wifiData}
onWifiDataChange={setWifiData}
emailData={emailData}
onEmailDataChange={setEmailData}
smsData={smsData}
onSmsDataChange={setSmsData}
maxChars={maxChars}
currentChars={currentChars}
onAutoDetect={handleAutoDetect}
/>
</section>
{/* Customization Panel */}
<CustomizationPanel
customization={customization}
onCustomizationChange={setCustomization}
/>
</div>
{/* Right Panel - Preview */}
<div className="lg:sticky lg:top-20 lg:self-start">
<section className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5 sm:p-6" aria-label="Aperçu du QR Code">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<div className="w-1.5 h-5 bg-linear-to-b from-violet-600 to-indigo-600 rounded-full" aria-hidden="true" />
Aperçu
</h2>
<QRPreview
content={encodedContent}
customization={customization}
contentType={contentType}
onReset={handleReset}
/>
</section>
</div>
</div>
{/* SEO Content Section */}
<section className="mt-16 max-w-4xl mx-auto" aria-label="À propos du générateur de QR Codes">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
Générateur de QR Code en ligne gratuit
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
<article className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">🎨 Personnalisation complète</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Changez les couleurs, choisissez entre carrés, ronds ou points, ajoutez votre logo et ajustez la taille pour un QR Code unique.
</p>
</article>
<article className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">📱 Multi-formats</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Encodez des URLs, du texte, des emails, des numéros de téléphone, des SMS ou des accès WiFi dans vos QR Codes.
</p>
</article>
<article className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2"> Export haute qualité</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Téléchargez en PNG ou SVG, copiez dans le presse-papier, ou partagez directement un lien. Résolution jusqu&apos;à 800px.
</p>
</article>
<article className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">📊 Génération en masse</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Importez un fichier CSV pour créer des dizaines de QR Codes en une seule opération. Idéal pour les entreprises.
</p>
</article>
<article className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">🔒 100% privé</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Tout est généré côté client dans votre navigateur. Aucune donnée n&apos;est envoyée à un serveur. Vos informations restent privées.
</p>
</article>
<article className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">🔗 Partage par lien</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Copiez l&apos;URL de votre QR Code pour le partager sans télécharger. Le destinataire verra instantanément votre QR Code.
</p>
</article>
</div>
{/* FAQ Section - visible for SEO */}
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-6 sm:p-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
Questions fréquentes sur les QR Codes
</h2>
<dl className="space-y-5">
<div>
<dt className="font-medium text-gray-900 dark:text-white mb-1">
Comment créer un QR Code gratuitement ?
</dt>
<dd className="text-sm text-gray-600 dark:text-gray-400">
Entrez votre contenu (URL, texte, email, WiFi...) dans le formulaire ci-dessus,
personnalisez les couleurs et le style, puis téléchargez votre QR Code en PNG ou SVG.
C&apos;est 100% gratuit et sans inscription.
</dd>
</div>
<div>
<dt className="font-medium text-gray-900 dark:text-white mb-1">
Quels types de contenu peut-on encoder dans un QR Code ?
</dt>
<dd className="text-sm text-gray-600 dark:text-gray-400">
Vous pouvez encoder des URLs/liens web, du texte libre, des adresses email avec sujet
et corps, des numéros de téléphone, des SMS pré-rédigés, et des informations de
connexion WiFi (SSID, mot de passe, type de sécurité WPA/WEP).
</dd>
</div>
<div>
<dt className="font-medium text-gray-900 dark:text-white mb-1">
Quelle est la différence entre PNG et SVG ?
</dt>
<dd className="text-sm text-gray-600 dark:text-gray-400">
Le PNG est un format d&apos;image raster idéal pour le web et les réseaux sociaux. Le
SVG est un format vectoriel parfait pour l&apos;impression car il peut être agrandi sans
perte de qualité.
</dd>
</div>
<div>
<dt className="font-medium text-gray-900 dark:text-white mb-1">
Le QR Code fonctionne-t-il avec un logo au centre ?
</dt>
<dd className="text-sm text-gray-600 dark:text-gray-400">
Oui, grâce à la correction d&apos;erreur. Pour de meilleurs résultats, utilisez le
niveau de correction H (30%) qui permet de masquer jusqu&apos;à 30% du code tout en
restant lisible.
</dd>
</div>
<div>
<dt className="font-medium text-gray-900 dark:text-white mb-1">
Mes données sont-elles en sécurité ?
</dt>
<dd className="text-sm text-gray-600 dark:text-gray-400">
Absolument. Toute la génération se fait directement dans votre navigateur (côté
client). Aucune donnée n&apos;est transmise à un serveur. L&apos;historique est
stocké uniquement en local sur votre appareil.
</dd>
</div>
</dl>
</div>
</section>
</main>
{/* Footer */}
<footer className="mt-12 border-t border-gray-200 dark:border-gray-800 py-8" role="contentinfo">
<div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">QR Code Generator</h3>
<p className="text-xs text-gray-500 dark:text-gray-500">
Outil en ligne gratuit pour créer des QR Codes personnalisés.
Aucune inscription requise. Vos données restent privées.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">Fonctionnalités</h3>
<ul className="text-xs text-gray-500 dark:text-gray-500 space-y-1">
<li>QR Code URL, Texte, Email</li>
<li>QR Code Téléphone, SMS, WiFi</li>
<li>Personnalisation couleurs et style</li>
<li>Export PNG et SVG haute résolution</li>
<li>Génération en masse via CSV</li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">Informations</h3>
<ul className="text-xs text-gray-500 dark:text-gray-500 space-y-1">
<li>100% gratuit</li>
<li>Aucune inscription</li>
<li>Données privées (côté client)</li>
<li>Compatible mobile et desktop</li>
</ul>
</div>
</div>
<div className="text-center border-t border-gray-200 dark:border-gray-800 pt-4">
<p className="text-xs text-gray-400 dark:text-gray-600">
&copy; {new Date().getFullYear()} QR Code Generator &mdash; Créez et partagez des QR Codes personnalisés gratuitement
</p>
</div>
</div>
</footer>
{/* CSV Modal */}
<CSVBulkGenerator isOpen={showCSV} onClose={() => setShowCSV(false)} />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { Clock, Trash2, RotateCcw } from 'lucide-react';
import Image from 'next/image';
import type { HistoryItem } from '../lib/types';
import { CONTENT_TYPE_LABELS } from '../lib/types';
interface QRHistoryProps {
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onClear: () => void;
}
export default function QRHistory({ history, onSelect, onClear }: QRHistoryProps) {
if (history.length === 0) {
return (
<div className="text-center py-8 text-gray-400 dark:text-gray-600">
<Clock size={32} className="mx-auto mb-2 opacity-40" />
<p className="text-sm">Aucun historique</p>
<p className="text-xs mt-1">Vos QR Codes récents apparaîtront ici</p>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">
{history.length} QR Code{history.length > 1 ? 's' : ''} récent{history.length > 1 ? 's' : ''}
</h3>
<button
onClick={onClear}
className="flex items-center gap-1 text-xs text-red-500 hover:text-red-600 transition-colors"
>
<Trash2 size={12} />
Vider
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{history.map((item) => (
<button
key={item.id}
onClick={() => onSelect(item)}
className="group relative bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-3 hover:border-violet-400 dark:hover:border-violet-500 hover:shadow-lg hover:shadow-violet-500/10 transition-all duration-200"
>
<Image
src={item.dataUrl}
alt="QR Code"
width={200}
height={200}
unoptimized
className="w-full aspect-square rounded-lg mb-2"
/>
<div className="text-left">
<span className="inline-block px-1.5 py-0.5 text-[10px] font-medium bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 rounded-md">
{CONTENT_TYPE_LABELS[item.contentType]}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
{item.rawContent.substring(0, 30)}
{item.rawContent.length > 30 ? '...' : ''}
</p>
<p className="text-[10px] text-gray-400 dark:text-gray-600 mt-0.5">
{new Date(item.timestamp).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-violet-600/0 group-hover:bg-violet-600/10 rounded-xl transition-all">
<RotateCcw
size={20}
className="text-violet-600 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</button>
))}
</div>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import {
Download,
Copy,
Share2,
RotateCcw,
Check,
Link2,
Image as ImageIcon,
} from 'lucide-react';
import type { QRCustomization, ContentType } from '../lib/types';
import { renderQRToCanvas, generateSVG, generateShareUrl } from '../lib/qr-renderer';
interface QRPreviewProps {
content: string;
customization: QRCustomization;
contentType: ContentType;
onReset: () => void;
}
export default function QRPreview({
content,
customization,
contentType,
onReset,
}: QRPreviewProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [copied, setCopied] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
// Render QR code to canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const timeout = setTimeout(async () => {
setIsRendering(true);
await renderQRToCanvas(canvas, content, customization);
setIsRendering(false);
}, 100);
return () => clearTimeout(timeout);
}, [content, customization]);
const showCopied = useCallback((type: string) => {
setCopied(type);
setTimeout(() => setCopied(null), 2000);
}, []);
const downloadPNG = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !content) return;
const link = document.createElement('a');
link.download = `qrcode-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}, [content]);
const downloadSVG = useCallback(() => {
if (!content) return;
const svg = generateSVG(content, customization);
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `qrcode-${Date.now()}.svg`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
}, [content, customization]);
const copyImage = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas || !content) return;
try {
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => resolve(b!), 'image/png')
);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
showCopied('image');
} catch {
// Fallback: copy data URL
const dataUrl = canvas.toDataURL('image/png');
await navigator.clipboard.writeText(dataUrl);
showCopied('image');
}
}, [content, showCopied]);
const copyUrl = useCallback(async () => {
if (!content) return;
const url = generateShareUrl(contentType, content, customization);
await navigator.clipboard.writeText(url);
showCopied('url');
}, [content, contentType, customization, showCopied]);
const copyDataUrl = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas || !content) return;
const dataUrl = canvas.toDataURL('image/png');
await navigator.clipboard.writeText(dataUrl);
showCopied('dataurl');
}, [content, showCopied]);
const shareQR = useCallback(async () => {
if (!content) return;
const url = generateShareUrl(contentType, content, customization);
if (navigator.share) {
try {
const canvas = canvasRef.current;
if (canvas) {
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => resolve(b!), 'image/png')
);
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
await navigator.share({
title: 'QR Code',
text: 'Voici mon QR Code',
url,
files: [file],
});
return;
}
} catch {
// try without file
}
try {
await navigator.share({
title: 'QR Code',
text: 'Voici mon QR Code',
url,
});
return;
} catch {
// fallback
}
}
await navigator.clipboard.writeText(url);
showCopied('share');
}, [content, contentType, customization, showCopied]);
const hasContent = !!content;
return (
<div className="flex flex-col items-center gap-5">
{/* Preview area */}
<div
className={`relative p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 transition-all duration-300 ${
isRendering ? 'opacity-50' : 'opacity-100'
}`}
>
<canvas
ref={canvasRef}
className="max-w-full h-auto"
role="img"
aria-label="QR Code généré - scannez avec votre appareil photo"
style={{
maxWidth: '100%',
width: Math.min(customization.size, 400),
height: Math.min(customization.size, 400),
}}
/>
{!hasContent && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 rounded-2xl">
<div className="text-center text-gray-400">
<ImageIcon size={48} className="mx-auto mb-3 opacity-30" />
<p className="text-sm">Entrez du contenu pour générer un QR Code</p>
</div>
</div>
)}
</div>
{/* Action buttons */}
<div className="w-full grid grid-cols-2 gap-2">
<button
onClick={downloadPNG}
disabled={!hasContent}
aria-label="Télécharger le QR Code en format PNG"
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-violet-600 hover:bg-violet-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 text-white rounded-xl text-sm font-medium transition-all shadow-lg shadow-violet-500/25 disabled:shadow-none"
>
<Download size={16} aria-hidden="true" />
PNG
</button>
<button
onClick={downloadSVG}
disabled={!hasContent}
aria-label="Télécharger le QR Code en format SVG vectoriel"
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 dark:disabled:bg-gray-700 text-white rounded-xl text-sm font-medium transition-all shadow-lg shadow-indigo-500/25 disabled:shadow-none"
>
<Download size={16} aria-hidden="true" />
SVG
</button>
<button
onClick={copyImage}
disabled={!hasContent}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium transition-all"
>
{copied === 'image' ? <Check size={16} className="text-green-500" aria-hidden="true" /> : <Copy size={16} aria-hidden="true" />}
{copied === 'image' ? 'Copié !' : 'Copier image'}
</button>
<button
onClick={copyUrl}
disabled={!hasContent}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium transition-all"
>
{copied === 'url' ? <Check size={16} className="text-green-500" aria-hidden="true" /> : <Link2 size={16} aria-hidden="true" />}
{copied === 'url' ? 'Copié !' : 'Copier le lien'}
</button>
<button
onClick={copyDataUrl}
disabled={!hasContent}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium transition-all"
>
{copied === 'dataurl' ? <Check size={16} className="text-green-500" aria-hidden="true" /> : <ImageIcon size={16} aria-hidden="true" />}
{copied === 'dataurl' ? 'Copié !' : 'URL image'}
</button>
<button
onClick={shareQR}
disabled={!hasContent}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium transition-all"
>
{copied === 'share' ? <Check size={16} className="text-green-500" aria-hidden="true" /> : <Share2 size={16} aria-hidden="true" />}
{copied === 'share' ? 'Copié !' : 'Partager'}
</button>
</div>
<button
onClick={onReset}
aria-label="Réinitialiser le QR Code et les paramètres"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-300 dark:hover:border-red-800 text-gray-500 hover:text-red-600 dark:hover:text-red-400 rounded-xl text-sm font-medium transition-all"
>
<RotateCcw size={16} aria-hidden="true" />
Réinitialiser
</button>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {},
});
export function useTheme() {
return useContext(ThemeContext);
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
const rafId = window.requestAnimationFrame(() => {
setMounted(true);
const stored = localStorage.getItem('qr-theme') as Theme | null;
if (stored) {
setTheme(stored);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark');
}
});
return () => window.cancelAnimationFrame(rafId);
}, []);
useEffect(() => {
if (!mounted) return;
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('qr-theme', theme);
}, [theme, mounted]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
if (!mounted) {
return <div className="min-h-screen" />;
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}