diff --git a/src/app/dashboard/_components/DashboardArticleList.tsx b/src/app/dashboard/_components/DashboardArticleList.tsx index 5cb5bb4..b95ddfd 100644 --- a/src/app/dashboard/_components/DashboardArticleList.tsx +++ b/src/app/dashboard/_components/DashboardArticleList.tsx @@ -133,7 +133,7 @@ const DashboardArticleList = () => {
{feedInfiniteQuery.isFetching && Array.from({ length: 10 }).map((_, i) => ( -
+
))} {feedInfiniteQuery.data?.pages.map((page) => { @@ -207,11 +207,9 @@ const DashboardArticleList = () => {
*/} - - + +

{_t("Actions")}

+
diff --git a/src/app/dashboard/_components/DashboardScaffold.tsx b/src/app/dashboard/_components/DashboardScaffold.tsx index 1c7a87d..278cbb7 100644 --- a/src/app/dashboard/_components/DashboardScaffold.tsx +++ b/src/app/dashboard/_components/DashboardScaffold.tsx @@ -70,7 +70,7 @@ const DashboardScaffold: React.FC<
- + diff --git a/src/app/dashboard/_components/MatrixReport.tsx b/src/app/dashboard/_components/MatrixReport.tsx index 02975a1..dfb49dd 100644 --- a/src/app/dashboard/_components/MatrixReport.tsx +++ b/src/app/dashboard/_components/MatrixReport.tsx @@ -24,7 +24,7 @@ const MatrixReport = () => {

{query.isFetching && ( - + )} {query.data?.total_articles}

@@ -38,7 +38,7 @@ const MatrixReport = () => {

{query.isFetching && ( - + )} {query.data?.total_comments}

diff --git a/src/components/Editor/MarkdownEditorContent.tsx b/src/components/Editor/MarkdownEditorContent.tsx index 01f297a..8d59fad 100644 --- a/src/components/Editor/MarkdownEditorContent.tsx +++ b/src/components/Editor/MarkdownEditorContent.tsx @@ -1,6 +1,14 @@ "use client"; -import { HeadingIcon, FontBoldIcon, FontItalicIcon, Link2Icon, UploadIcon } from "@radix-ui/react-icons"; +import { useTranslation } from "@/i18n/use-translation"; +import { + FontBoldIcon, + FontItalicIcon, + HeadingIcon, + Link2Icon, + UploadIcon, +} from "@radix-ui/react-icons"; +import { Loader } from "lucide-react"; import React from "react"; import EditorCommandButton from "./EditorCommandButton"; import { useMarkdownEditor } from "./useMarkdownEditor"; @@ -10,7 +18,11 @@ interface MarkdownEditorContentProps { onChange: (value: string) => void; } -export function MarkdownEditorContent({ bodyRef, onChange }: MarkdownEditorContentProps) { +export function MarkdownEditorContent({ + bodyRef, + onChange, +}: MarkdownEditorContentProps) { + const { _t } = useTranslation(); const editor = useMarkdownEditor({ ref: bodyRef, onChange, @@ -40,15 +52,35 @@ export function MarkdownEditorContent({ bodyRef, onChange }: MarkdownEditorConte /> editor?.executeCommand("upload-image")} - Icon={} + Icon={ + editor?.isUploading ? ( + + ) : ( + + ) + } title="Upload Image (Ctrl/Cmd + U)" + isDisabled={editor?.isUploading} /> + + {editor?.isUploading && ( +
+ + {_t("Uploading image")}... +
+ )}
); return ( -
- {renderEditorToolbar()} +
+
+ {renderEditorToolbar()} + +
+ {_t("Tip: Drag & drop or paste images directly into the editor")} +
+
); -} \ No newline at end of file +} diff --git a/src/components/Editor/useMarkdownEditor.ts b/src/components/Editor/useMarkdownEditor.ts index 20b43c6..ad81ab5 100644 --- a/src/components/Editor/useMarkdownEditor.ts +++ b/src/components/Editor/useMarkdownEditor.ts @@ -1,3 +1,6 @@ +import { DIRECTORY_NAME } from "@/backend/models/domain-models"; +import { useServerFile } from "@/hooks/use-file-upload"; +import getFileUrl from "@/utils/getFileUrl"; import { useCallback, useEffect } from "react"; import { useMarkdownEditorContext } from "./MarkdownEditorProvider"; @@ -19,70 +22,172 @@ interface Options { export function useMarkdownEditor(options?: Options) { const textareaRef = options?.ref; const editorContext = useMarkdownEditorContext(); + const { uploadFile, uploading } = useServerFile(); - const executeCommand = useCallback((command: MarkdownCommand) => { - if (!textareaRef?.current) return; - const { selectionStart, selectionEnd } = textareaRef.current; - const selectedText = textareaRef.current.value.substring( - selectionStart, - selectionEnd - ); - let updatedValue = textareaRef.current.value; - let newCursorPos = selectionStart; - - switch (command) { - case "heading": - const headingText = `## ${selectedText}`; - updatedValue = - updatedValue.substring(0, selectionStart) + - headingText + - updatedValue.substring(selectionEnd); - newCursorPos = selectionStart + headingText.length; - break; - case "bold": - const boldText = `**${selectedText}**`; - updatedValue = - updatedValue.substring(0, selectionStart) + - boldText + - updatedValue.substring(selectionEnd); - newCursorPos = selectionStart + (selectedText ? boldText.length : 2); - break; - case "italic": - const italicText = `*${selectedText}*`; - updatedValue = - updatedValue.substring(0, selectionStart) + - italicText + - updatedValue.substring(selectionEnd); - newCursorPos = selectionStart + (selectedText ? italicText.length : 1); - break; - case "link": - const linkText = selectedText - ? `[${selectedText}](url)` - : `[link text](url)`; - updatedValue = - updatedValue.substring(0, selectionStart) + - linkText + - updatedValue.substring(selectionEnd); - newCursorPos = selectionStart + linkText.length; - break; - case "upload-image": - // Open the image uploader modal at current cursor position - editorContext.openImageUploader(selectionStart); - return; // Don't update text immediately, wait for upload - } - - if (options?.onChange) { - options.onChange(updatedValue); - } - - // Use setTimeout to ensure the value is updated before setting cursor position - setTimeout(() => { - if (textareaRef?.current) { - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + // Helper function to check if a file is an image + const isImageFile = useCallback((file: File) => { + return file.type.startsWith("image/"); + }, []); + + // Helper function to handle direct image upload + const handleDirectImageUpload = useCallback( + async (files: FileList | File[], cursorPosition: number) => { + const imageFiles = Array.from(files).filter(isImageFile); + + if (imageFiles.length === 0) return; + + try { + // Insert placeholder markdown immediately for UX + const placeholderMarkdown = `![Uploading...](uploading)`; + editorContext.insertImageAtPosition( + "uploading", + "Uploading...", + cursorPosition + ); + + // Convert File to Blob to remove .name property (matches upload button behavior) + const file = imageFiles[0]; + const blob = new Blob([file], { type: file.type }); + + // Upload the blob (without .name property like upload button does) + const uploadResult = await uploadFile({ + files: [blob as any], + directory: DIRECTORY_NAME.ARTICLE_CONTENT, + generateUniqueFileName: true, + }); + + if (uploadResult.success && uploadResult.data?.keys[0]) { + // Create the server file object + const serverFile = { + provider: "r2" as const, + key: uploadResult.data.keys[0], + }; + + // Generate the final image URL + const imageUrl = getFileUrl(serverFile); + + // Replace the placeholder with the actual image + setTimeout(() => { + if (textareaRef?.current && options?.onChange) { + const currentValue = textareaRef.current.value; + const updatedValue = currentValue.replace( + placeholderMarkdown, + `\n![Uploaded image](${imageUrl})\n` + ); + options.onChange(updatedValue); + } + }, 100); + } else { + // Replace placeholder with error message on failure + setTimeout(() => { + if (textareaRef?.current && options?.onChange) { + const currentValue = textareaRef.current.value; + const updatedValue = currentValue.replace( + placeholderMarkdown, + `\n![Upload failed](error)\n` + ); + options.onChange(updatedValue); + } + }, 100); + } + } catch (error) { + console.error("Image upload failed:", error); + // Replace placeholder with error message + setTimeout(() => { + if (textareaRef?.current && options?.onChange) { + const currentValue = textareaRef.current.value; + const updatedValue = currentValue.replace( + `![Uploading...](uploading)`, + `\n![Upload failed](error)\n` + ); + options.onChange(updatedValue); + } + }, 100); } - }, 0); - }, [textareaRef, editorContext, options]); + }, + [isImageFile, uploadFile, editorContext, textareaRef, options] + ); + + // Updated helper function for handling image files (now uses direct upload) + const handleImageFiles = useCallback( + (files: FileList | File[]) => { + const imageFiles = Array.from(files).filter(isImageFile); + + if (imageFiles.length > 0 && textareaRef?.current) { + const cursorPosition = textareaRef.current.selectionStart; + handleDirectImageUpload(files, cursorPosition); + } + }, + [textareaRef, isImageFile, handleDirectImageUpload] + ); + + const executeCommand = useCallback( + (command: MarkdownCommand) => { + if (!textareaRef?.current) return; + const { selectionStart, selectionEnd } = textareaRef.current; + const selectedText = textareaRef.current.value.substring( + selectionStart, + selectionEnd + ); + let updatedValue = textareaRef.current.value; + let newCursorPos = selectionStart; + + switch (command) { + case "heading": + const headingText = `## ${selectedText}`; + updatedValue = + updatedValue.substring(0, selectionStart) + + headingText + + updatedValue.substring(selectionEnd); + newCursorPos = selectionStart + headingText.length; + break; + case "bold": + const boldText = `**${selectedText}**`; + updatedValue = + updatedValue.substring(0, selectionStart) + + boldText + + updatedValue.substring(selectionEnd); + newCursorPos = selectionStart + (selectedText ? boldText.length : 2); + break; + case "italic": + const italicText = `*${selectedText}*`; + updatedValue = + updatedValue.substring(0, selectionStart) + + italicText + + updatedValue.substring(selectionEnd); + newCursorPos = + selectionStart + (selectedText ? italicText.length : 1); + break; + case "link": + const linkText = selectedText + ? `[${selectedText}](url)` + : `[link text](url)`; + updatedValue = + updatedValue.substring(0, selectionStart) + + linkText + + updatedValue.substring(selectionEnd); + newCursorPos = selectionStart + linkText.length; + break; + case "upload-image": + // Open the image uploader modal at current cursor position (for manual upload button) + editorContext.openImageUploader(selectionStart); + return; // Don't update text immediately, wait for upload + } + + if (options?.onChange) { + options.onChange(updatedValue); + } + + // Use setTimeout to ensure the value is updated before setting cursor position + setTimeout(() => { + if (textareaRef?.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }, + [textareaRef, editorContext, options] + ); // Keyboard shortcut handler useEffect(() => { @@ -128,7 +233,72 @@ export function useMarkdownEditor(options?: Options) { return () => document.removeEventListener("keydown", handleKeyDown); }, [executeCommand, textareaRef]); + // Drag and drop functionality + useEffect(() => { + const textarea = textareaRef?.current; + if (!textarea) return; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Add visual feedback for drag over + textarea.classList.add("border-primary", "bg-primary/5"); + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Remove visual feedback when drag leaves + textarea.classList.remove("border-primary", "bg-primary/5"); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Remove visual feedback + textarea.classList.remove("border-primary", "bg-primary/5"); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + handleImageFiles(files); + } + }; + + const handlePaste = (e: ClipboardEvent) => { + const files = e.clipboardData?.files; + if (files && files.length > 0) { + const hasImages = Array.from(files).some(isImageFile); + if (hasImages) { + e.preventDefault(); // Prevent default paste behavior for images + handleImageFiles(files); + } + } + }; + + // Add event listeners + textarea.addEventListener("dragover", handleDragOver); + textarea.addEventListener("dragenter", handleDragEnter); + textarea.addEventListener("dragleave", handleDragLeave); + textarea.addEventListener("drop", handleDrop); + textarea.addEventListener("paste", handlePaste); + + // Cleanup + return () => { + textarea.removeEventListener("dragover", handleDragOver); + textarea.removeEventListener("dragenter", handleDragEnter); + textarea.removeEventListener("dragleave", handleDragLeave); + textarea.removeEventListener("drop", handleDrop); + textarea.removeEventListener("paste", handlePaste); + }; + }, [textareaRef, handleImageFiles, isImageFile]); + if (!textareaRef) return; - return { executeCommand }; + return { executeCommand, isUploading: uploading }; }