commit a0d952ae0f485253c494d8a965f35f7be1b2286a Author: Puechberty Arthur Date: Mon Mar 30 19:30:30 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59e505b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +README.md +.env*.local +docker-compose.yml +.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c3b7d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.vscode/sftp.json + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ab9a0f --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# QR Code Generator + +Générateur de QR codes moderne construit avec Next.js 16 et React 19. + +## Site officiel + +Accéder à l'application en ligne: [qrcode.arthurp.fr](https://qrcode.arthurp.fr) + +## Fonctionnalités + +- Génération de QR codes pour texte, URL, email, téléphone, SMS et Wi-Fi +- Personnalisation (couleurs, tailles, styles de modules, coins arrondis, logo) +- Export PNG et SVG +- Historique local des QR codes récents +- Génération en masse via import CSV + +## Démarrage local + +```bash +npm install +npm run dev +``` + +Application disponible sur http://localhost:3000 + +## Vérifications qualité + +```bash +npm run lint +npm run build +``` + +## Stack technique + +- Next.js 16 (App Router) +- React 19 +- TypeScript +- Tailwind CSS diff --git a/app/components/CSVBulkGenerator.tsx b/app/components/CSVBulkGenerator.tsx new file mode 100644 index 0000000..f461ed6 --- /dev/null +++ b/app/components/CSVBulkGenerator.tsx @@ -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([]); + const [generated, setGenerated] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const handleFile = useCallback((file: File) => { + setError(null); + setGenerated([]); + + Papa.parse>(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 ( +
+
+ {/* Header */} +
+
+ +

+ Génération en masse +

+
+ +
+ + {/* Content */} +
+ {/* Upload area */} + {rows.length === 0 && ( +
+
{ 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' + }`} + > + +

+ Glissez votre fichier CSV ici +

+

+ ou parcourir +

+

+ Le CSV doit contenir une colonne "content" avec les données à encoder +

+ { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }} + className="hidden" + /> +
+ + {/* CSV format help */} +
+

+ Format CSV attendu : +

+ +{`content,filename +https://example.com,site-web +Bonjour le monde,message ++33612345678,contact`} + +
+
+ )} + + {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Rows preview */} + {rows.length > 0 && generated.length === 0 && ( +
+
+

+ {rows.length} QR Codes à générer +

+
+ + +
+
+
+ {rows.slice(0, 20).map((row, i) => ( +
+ {i + 1} + + {row.content} + +
+ ))} + {rows.length > 20 && ( +

+ ... et {rows.length - 20} de plus +

+ )} +
+
+ )} + + {/* Generated results */} + {generated.length > 0 && ( +
+
+
+ + + {generated.filter((g) => g.dataUrl).length} QR Codes générés + +
+ +
+
+ {generated.map((qr, i) => ( +
+ {qr.dataUrl ? ( + {qr.content} + ) : ( +
+ +
+ )} +

{qr.filename}

+
+ ))} +
+ +
+ )} +
+
+
+ ); +} diff --git a/app/components/ContentInput.tsx b/app/components/ContentInput.tsx new file mode 100644 index 0000000..6290cd7 --- /dev/null +++ b/app/components/ContentInput.tsx @@ -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 = { + text: , + url: , + email: , + phone: , + sms: , + wifi: , +}; + +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 ( +
+ {/* Content Type Selector */} +
+ {types.map((type) => ( + + ))} +
+ + {/* Auto-detect indicator */} + {autoDetected && ( +
+ + Type détecté automatiquement +
+ )} + + {/* Input Forms */} +
+ {contentType === 'text' && ( +
+ +