diff --git a/.gitignore b/.gitignore index 8cad2f7..17e4806 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ next-env.d.ts /data/*.db /data/backups/* + +# Uploaded images +/public/uploads/notes/* +!/public/uploads/notes/.gitkeep diff --git a/public/uploads/notes/.gitkeep b/public/uploads/notes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/notes/images/upload/route.ts b/src/app/api/notes/images/upload/route.ts new file mode 100644 index 0000000..ffb7403 --- /dev/null +++ b/src/app/api/notes/images/upload/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { error: 'Aucun fichier fourni' }, + { status: 400 } + ); + } + + // Vérifier que c'est bien une image + if (!file.type.startsWith('image/')) { + return NextResponse.json( + { error: 'Le fichier doit être une image' }, + { status: 400 } + ); + } + + // Limiter la taille à 10MB + const maxSize = 10 * 1024 * 1024; // 10MB + if (file.size > maxSize) { + return NextResponse.json( + { error: "L'image est trop grande (max 10MB)" }, + { status: 400 } + ); + } + + // Créer le dossier de stockage s'il n'existe pas + const uploadsDir = join(process.cwd(), 'public', 'uploads', 'notes'); + if (!existsSync(uploadsDir)) { + await mkdir(uploadsDir, { recursive: true }); + } + + // Générer un nom de fichier unique + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).substring(2, 15); + const extension = file.name.split('.').pop() || 'png'; + const filename = `${timestamp}-${randomStr}.${extension}`; + const filepath = join(uploadsDir, filename); + + // Convertir le File en Buffer et l'écrire + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + await writeFile(filepath, buffer); + + // Retourner l'URL relative de l'image + const imageUrl = `/uploads/notes/${filename}`; + + return NextResponse.json({ url: imageUrl }); + } catch (error) { + console.error('Error uploading image:', error); + return NextResponse.json( + { error: "Erreur lors de l'upload de l'image" }, + { status: 500 } + ); + } +} diff --git a/src/components/notes/MarkdownEditor.tsx b/src/components/notes/MarkdownEditor.tsx index c9210c3..f587bc5 100644 --- a/src/components/notes/MarkdownEditor.tsx +++ b/src/components/notes/MarkdownEditor.tsx @@ -279,6 +279,22 @@ const createMarkdownComponents = ( {children} ), hr: () =>
, + img: (({ + src, + alt, + ...props + }: { + src?: string; + alt?: string; + } & Record) => ( + {alt + )) as Components['img'], }); interface MarkdownEditorProps { @@ -327,6 +343,7 @@ export function MarkdownEditor({ const [showPreview, setShowPreview] = useState(true); // Aperçu par défaut const [isEditing, setIsEditing] = useState(initialIsEditing); // Mode édition const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [isUploadingImage, setIsUploadingImage] = useState(false); const autoSaveTimeoutRef = useRef(null); const lastSavedValueRef = useRef(value); const textareaRef = useRef(null); @@ -443,6 +460,82 @@ export function MarkdownEditor({ [value, onChange, addToHistory] ); + // Fonction pour uploader une image + const uploadImage = useCallback(async (file: File) => { + setIsUploadingImage(true); + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/notes/images/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Erreur lors de l'upload"); + } + + const data = await response.json(); + return data.url; + } catch (error) { + console.error('Error uploading image:', error); + throw error; + } finally { + setIsUploadingImage(false); + } + }, []); + + // Gestionnaire de collage d'images + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const textarea = textareaRef.current; + if (!textarea || !isEditing) return; + + const items = e.clipboardData.items; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.indexOf('image') !== -1) { + e.preventDefault(); + const file = item.getAsFile(); + if (!file) continue; + + try { + // Uploader l'image + const imageUrl = await uploadImage(file); + + // Insérer le markdown de l'image à la position du curseur + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const imageMarkdown = `![${file.name || 'Image'}](${imageUrl})\n`; + const newValue = + value.substring(0, start) + imageMarkdown + value.substring(end); + + onChange(newValue); + addToHistory(newValue); + + // Repositionner le curseur après l'image + setTimeout(() => { + const newCursorPos = start + imageMarkdown.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.focus(); + }, 0); + } catch (error) { + console.error('Error handling paste:', error); + alert( + error instanceof Error + ? error.message + : "Erreur lors de l'upload de l'image" + ); + } + break; + } + } + }, + [isEditing, value, onChange, addToHistory, uploadImage] + ); + // Fonction de sauvegarde const handleSave = useCallback(() => { if (onSave && hasUnsavedChanges) { @@ -822,11 +915,19 @@ export function MarkdownEditor({ -
+
+ {isUploadingImage && ( +
+
+ Upload de l'image en cours... +
+
+ )}