mirror of
https://github.com/arthur-pbty/qrcode.git
synced 2026-06-03 15:07:36 +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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+161
@@ -0,0 +1,161 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
--color-violet-50: #f5f3ff;
|
||||
--color-violet-100: #ede9fe;
|
||||
--color-violet-200: #ddd6fe;
|
||||
--color-violet-300: #c4b5fd;
|
||||
--color-violet-400: #a78bfa;
|
||||
--color-violet-500: #8b5cf6;
|
||||
--color-violet-600: #7c3aed;
|
||||
--color-violet-700: #6d28d9;
|
||||
--color-violet-800: #5b21b6;
|
||||
--color-violet-900: #4c1d95;
|
||||
--color-indigo-600: #4f46e5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slide-down 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Range slider styling */
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
cursor: pointer;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.3);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
cursor: pointer;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(to right, #7c3aed 0%, #e5e7eb 0%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:where(.dark) input[type='range']::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(to right, #7c3aed 0%, #374151 0%);
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-track {
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:where(.dark) input[type='range']::-moz-range-track {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
input[type='checkbox'] {
|
||||
accent-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:where(.dark) ::-webkit-scrollbar-thumb {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #7c3aed;
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="7" fill="#7c3aed"/>
|
||||
<g fill="#fff">
|
||||
<rect x="6" y="6" width="4" height="4" rx="1"/>
|
||||
<rect x="6" y="12" width="4" height="4" rx="1"/>
|
||||
<rect x="12" y="6" width="4" height="4" rx="1"/>
|
||||
<rect x="12" y="12" width="4" height="4" rx="1"/>
|
||||
<rect x="22" y="6" width="4" height="4" rx="1"/>
|
||||
<rect x="22" y="12" width="4" height="4" rx="1"/>
|
||||
<rect x="6" y="22" width="4" height="4" rx="1"/>
|
||||
<rect x="12" y="22" width="4" height="4" rx="1"/>
|
||||
<rect x="18" y="18" width="4" height="4" rx="1"/>
|
||||
<rect x="22" y="22" width="4" height="4" rx="1"/>
|
||||
<rect x="18" y="12" width="2" height="2" rx="0.5"/>
|
||||
<rect x="12" y="18" width="2" height="2" rx="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 806 B |
+210
@@ -0,0 +1,210 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://qrcode-generator.fr";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#030712" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(BASE_URL),
|
||||
title: {
|
||||
default: "Générateur de QR Code Gratuit en Ligne - Personnalisé et Haute Qualité",
|
||||
template: "%s | QR Code Generator",
|
||||
},
|
||||
description:
|
||||
"Créez des QR Codes personnalisés gratuitement : URL, texte, email, téléphone, SMS, WiFi. Personnalisez les couleurs, la forme et le style. Téléchargez en PNG ou SVG haute résolution. Aucune inscription requise.",
|
||||
keywords: [
|
||||
"qr code",
|
||||
"générateur qr code",
|
||||
"qr code gratuit",
|
||||
"créer qr code",
|
||||
"qr code personnalisé",
|
||||
"qr code en ligne",
|
||||
"qr code url",
|
||||
"qr code wifi",
|
||||
"qr code email",
|
||||
"qr code téléphone",
|
||||
"qr code sms",
|
||||
"qr code png",
|
||||
"qr code svg",
|
||||
"qr code couleur",
|
||||
"qr code logo",
|
||||
"qr code generator",
|
||||
"qr code maker",
|
||||
"free qr code",
|
||||
"générateur code qr",
|
||||
"code qr gratuit",
|
||||
],
|
||||
authors: [{ name: "QR Code Generator" }],
|
||||
creator: "QR Code Generator",
|
||||
publisher: "QR Code Generator",
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
languages: {
|
||||
"fr-FR": "/",
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fr_FR",
|
||||
url: BASE_URL,
|
||||
siteName: "QR Code Generator",
|
||||
title: "Générateur de QR Code Gratuit - Personnalisé et Haute Qualité",
|
||||
description:
|
||||
"Créez des QR Codes personnalisés gratuitement. URL, texte, email, WiFi. Couleurs, logo, styles variés. Téléchargez en PNG ou SVG.",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "QR Code Generator - Créez des QR Codes personnalisés",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Générateur de QR Code Gratuit en Ligne",
|
||||
description:
|
||||
"Créez et personnalisez des QR Codes gratuitement. Téléchargez en PNG ou SVG. Aucune inscription.",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
category: "technology",
|
||||
classification: "Utility",
|
||||
};
|
||||
|
||||
// JSON-LD Structured Data
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: "QR Code Generator",
|
||||
url: BASE_URL,
|
||||
description:
|
||||
"Générateur de QR Codes gratuit et personnalisable. Créez des QR Codes pour URLs, textes, emails, WiFi et plus encore.",
|
||||
applicationCategory: "UtilityApplication",
|
||||
operatingSystem: "All",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "EUR",
|
||||
},
|
||||
featureList: [
|
||||
"Génération de QR Code en temps réel",
|
||||
"Personnalisation des couleurs et du style",
|
||||
"Téléchargement PNG et SVG",
|
||||
"Support URL, texte, email, téléphone, SMS, WiFi",
|
||||
"Ajout de logo au centre",
|
||||
"Historique local des QR Codes",
|
||||
"Génération en masse via CSV",
|
||||
"Mode sombre et clair",
|
||||
"Partage par lien",
|
||||
],
|
||||
inLanguage: "fr-FR",
|
||||
isAccessibleForFree: true,
|
||||
browserRequirements: "Requires JavaScript. Requires HTML5.",
|
||||
screenshot: `${BASE_URL}/og-image.png`,
|
||||
};
|
||||
|
||||
const faqJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Comment créer un QR Code gratuitement ?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Utilisez notre générateur en ligne : entrez votre contenu (URL, texte, email, WiFi...), personnalisez les couleurs et le style, puis téléchargez votre QR Code en PNG ou SVG. C'est 100% gratuit et sans inscription.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Quels types de contenu peut-on encoder dans un QR Code ?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Vous pouvez encoder : des URLs/liens web, du texte libre, des adresses email, des numéros de téléphone, des SMS, et des informations de connexion WiFi (SSID, mot de passe, type de sécurité).",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Peut-on personnaliser l'apparence d'un QR Code ?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Oui ! Vous pouvez modifier la couleur du QR Code et du fond, choisir la taille, le style des modules (carré, arrondi, points), ajouter un logo central, et ajuster les coins arrondis et le niveau de correction d'erreur.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "En quels formats peut-on télécharger le QR Code ?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Les QR Codes peuvent être téléchargés en haute résolution au format PNG (image raster) ou SVG (image vectorielle). Vous pouvez aussi copier l'image dans le presse-papier ou partager un lien direct.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Peut-on générer plusieurs QR Codes en une fois ?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Oui, grâce à notre fonction d'import CSV, vous pouvez générer des dizaines voire des centaines de QR Codes en une seule opération. Importez simplement un fichier CSV avec vos données.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import QRCode from 'qrcode';
|
||||
import type { QRCustomization, ContentType, WifiData, EmailData, SmsData } from './types';
|
||||
|
||||
export function encodeContent(
|
||||
type: ContentType,
|
||||
rawContent: string,
|
||||
wifiData?: WifiData,
|
||||
emailData?: EmailData,
|
||||
smsData?: SmsData
|
||||
): string {
|
||||
switch (type) {
|
||||
case 'url':
|
||||
if (rawContent && !rawContent.match(/^https?:\/\//i)) {
|
||||
return `https://${rawContent}`;
|
||||
}
|
||||
return rawContent;
|
||||
case 'email':
|
||||
if (emailData) {
|
||||
const params = new URLSearchParams();
|
||||
if (emailData.subject) params.set('subject', emailData.subject);
|
||||
if (emailData.body) params.set('body', emailData.body);
|
||||
const paramStr = params.toString();
|
||||
return `mailto:${emailData.to}${paramStr ? '?' + paramStr : ''}`;
|
||||
}
|
||||
return `mailto:${rawContent}`;
|
||||
case 'phone':
|
||||
return `tel:${rawContent}`;
|
||||
case 'sms':
|
||||
if (smsData) {
|
||||
return `smsto:${smsData.phone}:${smsData.message}`;
|
||||
}
|
||||
return `smsto:${rawContent}`;
|
||||
case 'wifi':
|
||||
if (wifiData) {
|
||||
const escaped = (s: string) => s.replace(/[\\;,:""]/g, '\\$&');
|
||||
return `WIFI:T:${wifiData.security};S:${escaped(wifiData.ssid)};P:${escaped(wifiData.password)};H:${wifiData.hidden ? 'true' : 'false'};;`;
|
||||
}
|
||||
return rawContent;
|
||||
default:
|
||||
return rawContent;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectContentType(content: string): ContentType {
|
||||
if (!content) return 'text';
|
||||
|
||||
const trimmed = content.trim();
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed) || /^www\./i.test(trimmed)) return 'url';
|
||||
if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed)) return 'email';
|
||||
if (/^[\+]?[0-9\s\-\(\)]{7,15}$/.test(trimmed)) return 'phone';
|
||||
if (/\.(com|org|net|io|dev|fr|co|app|me)\b/i.test(trimmed)) return 'url';
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export async function renderQRToCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
content: string,
|
||||
customization: QRCustomization
|
||||
): Promise<void> {
|
||||
if (!content) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = customization.size;
|
||||
canvas.height = customization.size;
|
||||
ctx.fillStyle = customization.bgColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = customization.fgColor + '30';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('QR Code', canvas.width / 2, canvas.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const qr = QRCode.create(content, {
|
||||
errorCorrectionLevel: customization.ecLevel,
|
||||
});
|
||||
|
||||
const modules = qr.modules;
|
||||
const moduleCount = modules.size;
|
||||
const size = customization.size;
|
||||
const quietZone = 4;
|
||||
const totalModules = moduleCount + quietZone * 2;
|
||||
const moduleSize = size / totalModules;
|
||||
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = customization.bgColor;
|
||||
if (customization.cornerRadius > 0) {
|
||||
drawRoundedRect(ctx, 0, 0, size, size, customization.cornerRadius);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
}
|
||||
|
||||
// Clip to rounded corners if needed
|
||||
if (customization.cornerRadius > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
drawRoundedRect(ctx, 0, 0, size, size, customization.cornerRadius);
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
// Draw modules
|
||||
const offset = quietZone * moduleSize;
|
||||
ctx.fillStyle = customization.fgColor;
|
||||
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
if (modules.get(row, col)) {
|
||||
const x = offset + col * moduleSize;
|
||||
const y = offset + row * moduleSize;
|
||||
|
||||
switch (customization.moduleStyle) {
|
||||
case 'dots':
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
x + moduleSize / 2,
|
||||
y + moduleSize / 2,
|
||||
moduleSize * 0.38,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
ctx.fill();
|
||||
break;
|
||||
case 'rounded':
|
||||
drawRoundedRect(ctx, x + 0.5, y + 0.5, moduleSize - 1, moduleSize - 1, moduleSize * 0.3);
|
||||
ctx.fill();
|
||||
break;
|
||||
default:
|
||||
ctx.fillRect(x, y, moduleSize, moduleSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw logo if present
|
||||
if (customization.logoDataUrl) {
|
||||
await drawLogo(ctx, customization.logoDataUrl, size);
|
||||
}
|
||||
|
||||
if (customization.cornerRadius > 0) {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoundedRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
) {
|
||||
r = Math.min(r, w / 2, h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
async function drawLogo(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
logoDataUrl: string,
|
||||
size: number
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const logoSize = size * 0.22;
|
||||
const logoX = (size - logoSize) / 2;
|
||||
const logoY = (size - logoSize) / 2;
|
||||
const padding = 6;
|
||||
|
||||
// White background behind logo
|
||||
ctx.fillStyle = '#ffffff';
|
||||
drawRoundedRect(
|
||||
ctx,
|
||||
logoX - padding,
|
||||
logoY - padding,
|
||||
logoSize + padding * 2,
|
||||
logoSize + padding * 2,
|
||||
8
|
||||
);
|
||||
ctx.fill();
|
||||
|
||||
// Draw logo
|
||||
ctx.drawImage(img, logoX, logoY, logoSize, logoSize);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => resolve();
|
||||
img.src = logoDataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export function generateSVG(
|
||||
content: string,
|
||||
customization: QRCustomization
|
||||
): string {
|
||||
if (!content) return '';
|
||||
|
||||
const qr = QRCode.create(content, {
|
||||
errorCorrectionLevel: customization.ecLevel,
|
||||
});
|
||||
|
||||
const modules = qr.modules;
|
||||
const moduleCount = modules.size;
|
||||
const quietZone = 4;
|
||||
const totalModules = moduleCount + quietZone * 2;
|
||||
const moduleSize = customization.size / totalModules;
|
||||
const size = customization.size;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`;
|
||||
|
||||
// Background
|
||||
if (customization.cornerRadius > 0) {
|
||||
svg += `<rect width="${size}" height="${size}" fill="${customization.bgColor}" rx="${customization.cornerRadius}" ry="${customization.cornerRadius}"/>`;
|
||||
} else {
|
||||
svg += `<rect width="${size}" height="${size}" fill="${customization.bgColor}"/>`;
|
||||
}
|
||||
|
||||
const offset = quietZone * moduleSize;
|
||||
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
if (modules.get(row, col)) {
|
||||
const x = offset + col * moduleSize;
|
||||
const y = offset + row * moduleSize;
|
||||
|
||||
switch (customization.moduleStyle) {
|
||||
case 'dots':
|
||||
svg += `<circle cx="${x + moduleSize / 2}" cy="${y + moduleSize / 2}" r="${moduleSize * 0.38}" fill="${customization.fgColor}"/>`;
|
||||
break;
|
||||
case 'rounded':
|
||||
svg += `<rect x="${x}" y="${y}" width="${moduleSize}" height="${moduleSize}" rx="${moduleSize * 0.3}" ry="${moduleSize * 0.3}" fill="${customization.fgColor}"/>`;
|
||||
break;
|
||||
default:
|
||||
svg += `<rect x="${x}" y="${y}" width="${moduleSize}" height="${moduleSize}" fill="${customization.fgColor}"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function generateShareUrl(
|
||||
contentType: ContentType,
|
||||
encodedContent: string,
|
||||
customization: QRCustomization
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('t', contentType);
|
||||
params.set('c', encodedContent);
|
||||
params.set('fg', customization.fgColor);
|
||||
params.set('bg', customization.bgColor);
|
||||
params.set('s', String(customization.size));
|
||||
params.set('ec', customization.ecLevel);
|
||||
params.set('ms', customization.moduleStyle);
|
||||
if (customization.cornerRadius > 0) {
|
||||
params.set('cr', String(customization.cornerRadius));
|
||||
}
|
||||
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin + window.location.pathname : '';
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parseShareUrl(searchParams: URLSearchParams): {
|
||||
contentType?: ContentType;
|
||||
content?: string;
|
||||
customization?: Partial<QRCustomization>;
|
||||
} | null {
|
||||
const t = searchParams.get('t') as ContentType | null;
|
||||
const c = searchParams.get('c');
|
||||
|
||||
if (!t || !c) return null;
|
||||
|
||||
const customization: Partial<QRCustomization> = {};
|
||||
const fg = searchParams.get('fg');
|
||||
const bg = searchParams.get('bg');
|
||||
const s = searchParams.get('s');
|
||||
const ec = searchParams.get('ec');
|
||||
const ms = searchParams.get('ms');
|
||||
const cr = searchParams.get('cr');
|
||||
|
||||
if (fg) customization.fgColor = fg;
|
||||
if (bg) customization.bgColor = bg;
|
||||
if (s) customization.size = parseInt(s);
|
||||
if (ec) customization.ecLevel = ec as QRCustomization['ecLevel'];
|
||||
if (ms) customization.moduleStyle = ms as QRCustomization['moduleStyle'];
|
||||
if (cr) customization.cornerRadius = parseInt(cr);
|
||||
|
||||
return { contentType: t, content: c, customization };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export type ContentType = 'text' | 'url' | 'email' | 'phone' | 'sms' | 'wifi';
|
||||
|
||||
export type ModuleStyle = 'square' | 'rounded' | 'dots';
|
||||
|
||||
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
export type SecurityType = 'WPA' | 'WEP' | 'nopass';
|
||||
|
||||
export interface QRCustomization {
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
size: number;
|
||||
ecLevel: ErrorCorrectionLevel;
|
||||
moduleStyle: ModuleStyle;
|
||||
logoDataUrl: string | null;
|
||||
cornerRadius: number;
|
||||
}
|
||||
|
||||
export interface WifiData {
|
||||
ssid: string;
|
||||
password: string;
|
||||
security: SecurityType;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface EmailData {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface SmsData {
|
||||
phone: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
contentType: ContentType;
|
||||
rawContent: string;
|
||||
encodedContent: string;
|
||||
dataUrl: string;
|
||||
timestamp: number;
|
||||
customization: QRCustomization;
|
||||
}
|
||||
|
||||
export const MAX_CHARS: Record<ErrorCorrectionLevel, number> = {
|
||||
L: 2953,
|
||||
M: 2331,
|
||||
Q: 1663,
|
||||
H: 1273,
|
||||
};
|
||||
|
||||
export const CONTENT_TYPE_LABELS: Record<ContentType, string> = {
|
||||
text: 'Texte',
|
||||
url: 'URL',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
sms: 'SMS',
|
||||
wifi: 'WiFi',
|
||||
};
|
||||
|
||||
export const CONTENT_TYPE_ICONS: Record<ContentType, string> = {
|
||||
text: 'Type',
|
||||
url: 'Link',
|
||||
email: 'Mail',
|
||||
phone: 'Phone',
|
||||
sms: 'MessageSquare',
|
||||
wifi: 'Wifi',
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOMIZATION: QRCustomization = {
|
||||
fgColor: '#000000',
|
||||
bgColor: '#ffffff',
|
||||
size: 300,
|
||||
ecLevel: 'M',
|
||||
moduleStyle: 'square',
|
||||
logoDataUrl: null,
|
||||
cornerRadius: 0,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'QR Code Generator - Générateur de QR Codes Gratuit',
|
||||
short_name: 'QR Generator',
|
||||
description:
|
||||
'Créez, personnalisez et téléchargez des QR Codes gratuitement. Supports URL, texte, email, téléphone, SMS, WiFi. Export PNG et SVG.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#7c3aed',
|
||||
orientation: 'portrait-primary',
|
||||
categories: ['utilities', 'productivity'],
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ThemeProvider } from './components/ThemeProvider';
|
||||
import QRCodeGenerator from './components/QRCodeGenerator';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QRCodeGenerator />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/_next/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://qrcode.arthurp.fr/sitemap.xml',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = 'https://qrcode.arthurp.fr';
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user