mirror of
https://github.com/arthur-pbty/qrcode.git
synced 2026-06-06 06:10:43 +02:00
first commit
This commit is contained in:
@@ -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 "content" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'à 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'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'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'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'image raster idéal pour le web et les réseaux sociaux. Le
|
||||
SVG est un format vectoriel parfait pour l'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'erreur. Pour de meilleurs résultats, utilisez le
|
||||
niveau de correction H (30%) qui permet de masquer jusqu'à 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'est transmise à un serveur. L'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">
|
||||
© {new Date().getFullYear()} QR Code Generator — Créez et partagez des QR Codes personnalisés gratuitement
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* CSV Modal */}
|
||||
<CSVBulkGenerator isOpen={showCSV} onClose={() => setShowCSV(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user