From b0f26343464220bf05f4b24e7d0abaecdce4b5f1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 14 May 2026 12:50:49 +0200 Subject: [PATCH] updated image adding process --- .../src/components/react/admin/Editor.tsx | 131 ++++++++++++++++-- 1 file changed, 118 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index cc6ad79..eedf21e 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -8,7 +8,7 @@ import { vim } from '@replit/codemirror-vim'; import { search, searchKeymap } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; import { renderMarkdown } from '../../../lib/markdown'; -import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api'; +import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api'; import type { Asset } from '../../../lib/types'; import AssetManager from './AssetManager'; @@ -79,6 +79,7 @@ export default function Editor({ editSlug }: Props) { const previewRef = useRef(null); const previewTimerRef = useRef | null>(null); const updatePreviewRef = useRef<() => void>(() => {}); + const uploadFnRef = useRef<(files: File[], insertAt?: number) => void>(() => {}); const today = new Date().toISOString().slice(0, 10); const [title, setTitle] = useState(''); const [slug, setSlug] = useState(editSlug || ''); @@ -96,6 +97,9 @@ export default function Editor({ editSlug }: Props) { const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteAssets, setAutocompleteAssets] = useState([]); const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [uploadingCount, setUploadingCount] = useState(0); + const dragDepthRef = useRef(0); function showAlertMsg(msg: string, type: 'success' | 'error') { setAlert({ msg, type }); @@ -110,6 +114,7 @@ export default function Editor({ editSlug }: Props) { }, [showPreview]); useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]); + useEffect(() => { uploadFnRef.current = uploadFilesAndInsert; }); // Initialize CodeMirror once useEffect(() => { @@ -140,6 +145,49 @@ export default function Editor({ editSlug }: Props) { setShowAutocomplete(false); } }), + EditorView.domEventHandlers({ + dragenter(event) { + if (!event.dataTransfer?.types.includes('Files')) return false; + dragDepthRef.current += 1; + setIsDragging(true); + return false; + }, + dragover(event) { + if (!event.dataTransfer?.types.includes('Files')) return false; + event.preventDefault(); + return true; + }, + dragleave() { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragging(false); + return false; + }, + drop(event, view) { + const files = event.dataTransfer?.files; + if (!files || files.length === 0) return false; + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragging(false); + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head; + uploadFnRef.current(Array.from(files), pos); + return true; + }, + paste(event, view) { + const items = event.clipboardData?.items; + if (!items) return false; + const imageFiles: File[] = []; + for (const item of Array.from(items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const f = item.getAsFile(); + if (f) imageFiles.push(f); + } + } + if (imageFiles.length === 0) return false; + event.preventDefault(); + uploadFnRef.current(imageFiles, view.state.selection.main.head); + return true; + }, + }), ], }); @@ -224,6 +272,34 @@ export default function Editor({ editSlug }: Props) { setShowAutocomplete(false); } + async function uploadFilesAndInsert(files: File[], insertAt?: number) { + const view = viewRef.current; + if (!view || files.length === 0) return; + const images = files.filter(f => f.type.startsWith('image/')); + if (images.length === 0) { + showAlertMsg('Only image files can be dropped here.', 'error'); + return; + } + setUploadingCount(c => c + images.length); + let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head; + for (const file of images) { + try { + const asset = await uploadAsset(file); + const md = `![${asset.name}](${asset.url})`; + const line = view.state.doc.lineAt(pos); + const atLineEnd = pos === line.to; + const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`; + view.dispatch({ changes: { from: pos, insert: insertText } }); + pos += insertText.length; + } catch (e) { + showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error'); + } finally { + setUploadingCount(c => Math.max(0, c - 1)); + } + } + view.focus(); + } + function handleAssetSelect(asset: Asset) { insertAssetMarkdown(asset); setShowModal(false); @@ -236,7 +312,7 @@ export default function Editor({ editSlug }: Props) { return; } if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) { - showAlertMsg('A gallery work must include at least one image — insert via the Assets panel or type "!".', 'error'); + showAlertMsg('Add at least one image before saving — drag, paste, or use the Add image button.', 'error'); return; } const tags = tagsInput @@ -394,17 +470,20 @@ export default function Editor({ editSlug }: Props) { {/* Editor Toolbar */}
- +
@@ -472,6 +553,30 @@ export default function Editor({ editSlug }: Props) { >
+ {/* Drop overlay */} + {isDragging && ( +
+
+ +

Drop image to insert

+
+
+ )} + + {/* Uploading indicator */} + {uploadingCount > 0 && ( +
+ + Uploading {uploadingCount} image{uploadingCount === 1 ? '' : 's'}… +
+ )} + {/* Autocomplete dropdown */} {showAutocomplete && autocompleteAssets.length > 0 && (
-

Asset library

-

Click an asset to drop it into the work.

+

Add image

+

Click an image to insert it. Drag new files in to upload.