From d512196fb7d051d2f4af8987925e7641e670865b Mon Sep 17 00:00:00 2001 From: chandresh Date: Mon, 9 Feb 2026 21:01:28 +0530 Subject: [PATCH] fix --- backend/config/s3.js | 78 +++++- backend/routes/upload.js | 42 ++- frontend/package.json | 1 + frontend/src/components/AltTextModal.jsx | 75 +++++ frontend/src/components/Editor.jsx | 261 ++++++++++-------- frontend/src/components/ImageBubbleMenu.jsx | 238 ++++++++++++++++ frontend/src/components/MediaLibraryGrid.jsx | 116 ++++++++ frontend/src/components/MediaLibraryModal.jsx | 176 ++++++++++++ frontend/src/components/MediaUploadZone.jsx | 91 ++++++ .../src/components/TipTapContentRenderer.jsx | 12 +- frontend/src/components/Toolbar.jsx | 28 +- frontend/src/extensions/ImageResize.js | 138 +++++++-- frontend/src/index.css | 45 ++- frontend/src/pages/Editor.jsx | 93 ++++--- 14 files changed, 1170 insertions(+), 224 deletions(-) create mode 100644 frontend/src/components/AltTextModal.jsx create mode 100644 frontend/src/components/ImageBubbleMenu.jsx create mode 100644 frontend/src/components/MediaLibraryGrid.jsx create mode 100644 frontend/src/components/MediaLibraryModal.jsx create mode 100644 frontend/src/components/MediaUploadZone.jsx diff --git a/backend/config/s3.js b/backend/config/s3.js index d6d61be..9e04f7e 100644 --- a/backend/config/s3.js +++ b/backend/config/s3.js @@ -1,6 +1,6 @@ import { S3Client } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' -import { PutObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3' +import { PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3' import { v4 as uuid } from 'uuid' import dotenv from 'dotenv' import logger from '../utils/logger.js' @@ -36,8 +36,14 @@ export const s3Client = isS3Configured() // Export ListObjectsV2Command for health checks (only requires s3:ListBucket permission) export { ListObjectsV2Command } -export async function getPresignedUploadUrl(filename, contentType) { - logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType }) +/** + * @param {string} filename + * @param {string} contentType + * @param {string} [postId] - Blog post ID for per-blog folder structure + * @param {string} [sessionId] - Session ID for draft posts (no postId yet) + */ +export async function getPresignedUploadUrl(filename, contentType, postId, sessionId) { + logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType, postId, sessionId }) if (!isS3Configured()) { logger.error('S3', 'S3 not configured', null) @@ -56,8 +62,11 @@ export async function getPresignedUploadUrl(filename, contentType) { // Extract file extension from filename or content type const ext = filename.split('.').pop() || contentType.split('/')[1] || 'jpg' - // Use UUID for unique file names (matching api-v1 pattern) - const key = `images/${uuid()}.${ext}` + // Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts + const folderPrefix = postId + ? `blogs/${postId}/images` + : `blogs/draft/${sessionId || 'temp'}/images` + const key = `${folderPrefix}/${uuid()}.${ext}` logger.s3('GENERATING_PRESIGNED_URL', { bucket: BUCKET_NAME, @@ -86,3 +95,62 @@ export async function getPresignedUploadUrl(filename, contentType) { return { uploadUrl, imageUrl, key } } + +/** + * List images in a blog's folder for Media Library + * @param {string} postId - Blog post ID + * @param {string} [sessionId] - Session ID for draft posts (no postId) + * @returns {Promise>} + */ +export async function listBlogImages(postId, sessionId) { + if (!isS3Configured() || !s3Client || !BUCKET_NAME) { + throw new Error('S3 is not configured') + } + + const prefix = postId + ? `blogs/${postId}/images/` + : sessionId + ? `blogs/draft/${sessionId}/images/` + : null + + if (!prefix) { + return [] + } + + const result = await s3Client.send(new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + Prefix: prefix, + MaxKeys: 100, + })) + + const baseUrl = `https://${BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com` + const items = (result.Contents || []) + .filter((obj) => obj.Key && !obj.Key.endsWith('/')) + .map((obj) => ({ + key: obj.Key, + url: `${baseUrl}/${obj.Key}`, + filename: obj.Key.split('/').pop() || obj.Key, + })) + .sort((a, b) => (b.key.localeCompare(a.key))) // newest first + + return items +} + +/** + * Delete a single image from S3 (Media Library "delete from storage") + * @param {string} key - S3 object key (e.g. blogs/123/images/uuid.jpg) + * @throws {Error} if key is not under blogs/ prefix or S3 not configured + */ +export async function deleteBlogImage(key) { + if (!isS3Configured() || !s3Client || !BUCKET_NAME) { + throw new Error('S3 is not configured') + } + if (!key || typeof key !== 'string' || !key.startsWith('blogs/')) { + throw new Error('Invalid key: must be an S3 object key under blogs/') + } + await s3Client.send(new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + })) + logger.s3('OBJECT_DELETED', { key, bucket: BUCKET_NAME }) +} diff --git a/backend/routes/upload.js b/backend/routes/upload.js index 346c587..bc247db 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -1,19 +1,37 @@ import express from 'express' -import { getPresignedUploadUrl } from '../config/s3.js' +import { getPresignedUploadUrl, listBlogImages, deleteBlogImage } from '../config/s3.js' import logger from '../utils/logger.js' const router = express.Router() +// List media for a blog (Media Library) +// GET /upload/media?postId=xxx or ?sessionId=xxx +router.get('/media', async (req, res) => { + try { + const { postId, sessionId } = req.query + if (!postId && !sessionId) { + return res.status(400).json({ message: 'postId or sessionId is required' }) + } + const items = await listBlogImages(postId || null, sessionId || null) + res.json({ items }) + } catch (error) { + logger.error('UPLOAD', 'Error listing media', error) + res.status(500).json({ message: error.message || 'Failed to list media' }) + } +}) + // Get presigned URL for image upload // Note: authenticateToken middleware is applied at server level router.post('/presigned-url', async (req, res) => { try { - const { filename, contentType } = req.body + const { filename, contentType, postId, sessionId } = req.body logger.transaction('GENERATE_PRESIGNED_URL', { userId: req.user.id, filename, - contentType + contentType, + postId, + sessionId }) if (!filename || !contentType) { @@ -48,7 +66,7 @@ router.post('/presigned-url', async (req, res) => { } const startTime = Date.now() - const { uploadUrl, imageUrl, key } = await getPresignedUploadUrl(filename, contentType) + const { uploadUrl, imageUrl, key } = await getPresignedUploadUrl(filename, contentType, postId, sessionId) const duration = Date.now() - startTime logger.s3('PRESIGNED_URL_GENERATED', { @@ -89,4 +107,20 @@ router.post('/presigned-url', async (req, res) => { } }) +// Delete image from S3 (Media Library) +// DELETE /upload/media with body { key: "blogs/123/images/uuid.jpg" } +router.delete('/media', async (req, res) => { + try { + const { key } = req.body + if (!key) { + return res.status(400).json({ message: 'key is required' }) + } + await deleteBlogImage(key) + res.status(204).end() + } catch (error) { + logger.error('UPLOAD', 'Error deleting media', error) + res.status(500).json({ message: error.message || 'Failed to delete from storage' }) + } +}) + export default router diff --git a/frontend/package.json b/frontend/package.json index 0f7ffdd..f1ae042 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@tiptap/extension-bubble-menu": "^3.19.0", "@tiptap/extension-color": "^2.1.13", "@tiptap/extension-image": "^2.1.13", "@tiptap/extension-text-style": "^2.1.13", diff --git a/frontend/src/components/AltTextModal.jsx b/frontend/src/components/AltTextModal.jsx new file mode 100644 index 0000000..c85d34b --- /dev/null +++ b/frontend/src/components/AltTextModal.jsx @@ -0,0 +1,75 @@ +import { useState, useEffect } from 'react' + +export default function AltTextModal({ + isOpen, + initialValue, + onSave, + onClose, + title = 'Edit alt text', + placeholder = 'Describe the image for accessibility', +}) { + const [value, setValue] = useState(initialValue || '') + + useEffect(() => { + if (isOpen) { + setValue(initialValue || '') + } + }, [isOpen, initialValue]) + + const handleSubmit = (e) => { + e.preventDefault() + onSave(value.trim()) + onClose() + } + + const handleKeyDown = (e) => { + if (e.key === 'Escape') onClose() + } + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > +

+ {title} +

+
+ setValue(e.target.value)} + placeholder={placeholder} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + autoFocus + /> +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx index 0cc1347..1600241 100644 --- a/frontend/src/components/Editor.jsx +++ b/frontend/src/components/Editor.jsx @@ -1,5 +1,5 @@ import { useEditor, EditorContent } from '@tiptap/react' -import { useRef, useEffect } from 'react' +import { useRef, useEffect, useCallback, useState } from 'react' import StarterKit from '@tiptap/starter-kit' import TextStyle from '@tiptap/extension-text-style' import Color from '@tiptap/extension-color' @@ -7,128 +7,125 @@ import Underline from '@tiptap/extension-underline' import { FontSize } from '../extensions/FontSize' import { ImageResize } from '../extensions/ImageResize' import Toolbar from './Toolbar' +import ImageBubbleMenu from './ImageBubbleMenu' +import MediaLibraryModal from './MediaLibraryModal' +import AltTextModal from './AltTextModal' import api from '../utils/api' import toast from 'react-hot-toast' -export default function Editor({ content, onChange, onImageUpload }) { +export default function Editor({ content, onChange, onImageUpload, postId, sessionId }) { const editorRef = useRef(null) + const [showMediaModal, setShowMediaModal] = useState(false) + const [mediaModalMode, setMediaModalMode] = useState('insert') + const [showAltModal, setShowAltModal] = useState(false) + const [showCaptionModal, setShowCaptionModal] = useState(false) - const handleImageUpload = async (file) => { + const performUpload = useCallback(async (file, options = {}) => { + const { insert = true } = options const editor = editorRef.current - if (!editor) { - toast.error('Editor not ready') - return + if (insert && !editor) { + throw new Error('Editor not ready') + } + if (!file.type.startsWith('image/')) { + throw new Error('Please select an image file') + } + if (file.size > 10 * 1024 * 1024) { + throw new Error('Image size must be less than 10MB') } + const response = await api.post('/upload/presigned-url', { + filename: file.name, + contentType: file.type, + postId: postId || undefined, + sessionId: sessionId || undefined, + }) + const data = response.data + + const uploadResponse = await fetch(data.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }) + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text().catch(() => 'Unknown error') + throw new Error(`Upload failed: ${uploadResponse.status} ${errorText}`) + } + + const imageUrl = data.imageUrl + if (insert && editor) { + editor.chain().focus().setImage({ src: imageUrl, alt: file.name }).run() + if (onImageUpload) onImageUpload(imageUrl) + } + return imageUrl + }, [postId, sessionId, onImageUpload]) + + const handleImageUpload = useCallback(async (file) => { try { - // Validate file type - if (!file.type.startsWith('image/')) { - toast.error('Please select an image file') - return - } - - // Validate file size (max 10MB) - if (file.size > 10 * 1024 * 1024) { - toast.error('Image size must be less than 10MB') - return - } - toast.loading('Uploading image...', { id: 'image-upload' }) - - // Get presigned URL from backend - let data - try { - const response = await api.post('/upload/presigned-url', { - filename: file.name, - contentType: file.type, - }) - data = response.data - } catch (error) { - console.error('Presigned URL request failed:', error) - if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') { - throw new Error('Cannot connect to server. Make sure the backend is running.') - } - if (error.response?.status === 401) { - throw new Error('Authentication failed. Please login again.') - } - if (error.response?.status === 500) { - throw new Error('Server error. Check if AWS S3 is configured correctly.') - } - throw error - } - - // Upload to S3 using presigned URL - console.log('Uploading to S3:', { - uploadUrl: data.uploadUrl.substring(0, 100) + '...', - imageUrl: data.imageUrl, - fileSize: file.size, - contentType: file.type - }) - - let uploadResponse - try { - uploadResponse = await fetch(data.uploadUrl, { - method: 'PUT', - body: file, - headers: { - 'Content-Type': file.type, - }, - }) - } catch (fetchError) { - console.error('S3 upload fetch error:', fetchError) - if (fetchError.message === 'Failed to fetch' || fetchError.name === 'TypeError') { - throw new Error('Failed to connect to S3. This might be a CORS issue. Check your S3 bucket CORS configuration.') - } - throw fetchError - } - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text().catch(() => 'Unknown error') - console.error('S3 upload failed:', { - status: uploadResponse.status, - statusText: uploadResponse.statusText, - error: errorText - }) - throw new Error(`S3 upload failed: ${uploadResponse.status} ${uploadResponse.statusText}. ${errorText}`) - } - - console.log('S3 upload successful:', { - status: uploadResponse.status, - imageUrl: data.imageUrl - }) - - // Use the image URL from the presigned URL response - const imageUrl = data.imageUrl - editor.chain().focus().setImage({ - src: imageUrl, - alt: file.name, - }).run() + await performUpload(file, { insert: true }) toast.success('Image uploaded successfully!', { id: 'image-upload' }) - - if (onImageUpload) { - onImageUpload(imageUrl) - } } catch (error) { - console.error('Image upload failed:', error) - const errorMessage = error.response?.data?.message || - error.message || - 'Failed to upload image. Please try again.' - - toast.error(errorMessage, { - id: 'image-upload', - duration: 5000, - }) - - // Log detailed error for debugging - if (error.response) { - console.error('Error response:', error.response.data) - console.error('Error status:', error.response.status) - } - if (error.request) { - console.error('Request made but no response:', error.request) - } + const msg = error.response?.data?.message || error.message || 'Failed to upload image.' + toast.error(msg, { id: 'image-upload', duration: 5000 }) + throw error } - } + }, [performUpload]) + + const uploadFileOnly = useCallback(async (file) => { + return performUpload(file, { insert: false }) + }, [performUpload]) + + const insertImage = useCallback((url, alt = '') => { + const editor = editorRef.current + if (editor) { + editor.chain().focus().setImage({ src: url, alt }).run() + } + }, []) + + const replaceImage = useCallback((url, alt = '') => { + const editor = editorRef.current + if (editor && editor.isActive('image')) { + editor.chain().focus().updateAttributes('image', { src: url, alt }).run() + } + }, []) + + const handleMediaSelect = useCallback((url, alt) => { + if (mediaModalMode === 'replace') { + replaceImage(url, alt) + toast.success('Image replaced') + } else { + insertImage(url, alt) + } + setShowMediaModal(false) + }, [mediaModalMode, replaceImage, insertImage]) + + const handleAltTextSave = useCallback((alt) => { + const editor = editorRef.current + if (editor && editor.isActive('image')) { + editor.chain().focus().updateAttributes('image', { alt }).run() + toast.success('Alt text updated') + } + setShowAltModal(false) + }, []) + + const getCurrentAlt = useCallback(() => { + const editor = editorRef.current + return editor?.isActive('image') ? editor.getAttributes('image').alt || '' : '' + }, []) + + const handleCaptionSave = useCallback((title) => { + const editor = editorRef.current + if (editor && editor.isActive('image')) { + editor.chain().focus().updateAttributes('image', { title: title || null }).run() + toast.success('Caption updated') + } + setShowCaptionModal(false) + }, []) + + const getCurrentTitle = useCallback(() => { + const editor = editorRef.current + return editor?.isActive('image') ? editor.getAttributes('image').title || '' : '' + }, []) const editor = useEditor({ extensions: [ @@ -199,8 +196,50 @@ export default function Editor({ content, onChange, onImageUpload }) { return (
- + { + setMediaModalMode('insert') + setShowMediaModal(true) + }} + postId={postId} + sessionId={sessionId} + /> + { + setMediaModalMode('replace') + setShowMediaModal(true) + }} + onAltTextClick={() => setShowAltModal(true)} + onCaptionClick={() => setShowCaptionModal(true)} + /> + setShowMediaModal(false)} + onInsertImage={handleMediaSelect} + postId={postId} + sessionId={sessionId} + onUploadFiles={uploadFileOnly} + mode={mediaModalMode} + /> + setShowAltModal(false)} + /> + setShowCaptionModal(false)} + title="Edit caption" + placeholder="Caption (shown below image)" + />
) } diff --git a/frontend/src/components/ImageBubbleMenu.jsx b/frontend/src/components/ImageBubbleMenu.jsx new file mode 100644 index 0000000..f811123 --- /dev/null +++ b/frontend/src/components/ImageBubbleMenu.jsx @@ -0,0 +1,238 @@ +import { useState } from 'react' +import { BubbleMenu } from '@tiptap/react' + +const SIZE_PRESETS = [ + { label: 'Original', width: null }, + { label: 'S', width: 200 }, + { label: 'M', width: 400 }, + { label: 'L', width: 600 }, + { label: 'Full', width: null }, +] +const WIDTH_ONLY_PRESETS = [200, 400, 600] +const HEIGHT_ONLY_PRESETS = [200, 300, 400] + +const ALIGN_OPTIONS = [ + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, + { label: 'Right', value: 'right' }, +] + +export default function ImageBubbleMenu({ + editor, + onReplaceClick, + onAltTextClick, + onCaptionClick, +}) { + const [showAlignMenu, setShowAlignMenu] = useState(false) + const [showSizeMenu, setShowSizeMenu] = useState(false) + + if (!editor) return null + const currentAlign = editor.getAttributes('image').align + + const setSize = (width) => { + if (width === null) { + editor.chain().focus().updateAttributes('image', { width: null, height: null }).run() + } else { + const attrs = editor.getAttributes('image') + let aspectRatio = 1 + if (attrs.width && attrs.height) { + aspectRatio = attrs.height / attrs.width + } else if (attrs.naturalWidth && attrs.naturalHeight) { + aspectRatio = attrs.naturalHeight / attrs.naturalWidth + } + editor.chain().focus().updateAttributes('image', { width, height: Math.round(width * aspectRatio) }).run() + } + setShowSizeMenu(false) + } + + const setWidthOnly = (width) => { + editor.chain().focus().updateAttributes('image', { width, height: null }).run() + setShowSizeMenu(false) + } + + const setHeightOnly = (height) => { + editor.chain().focus().updateAttributes('image', { width: null, height }).run() + setShowSizeMenu(false) + } + + const setAlign = (align) => { + editor.chain().focus().updateAttributes('image', { align }).run() + setShowAlignMenu(false) + } + + const handleDelete = () => { + // Run delete on mousedown so selection is still on the image (see Delete button below) + let deleted = editor.chain().focus().deleteNode('image').run() + if (!deleted && editor.isActive('image')) { + // Fallback: find image node position from selection and delete by range + const { state } = editor + const { $from } = state.selection + for (let d = $from.depth; d > 0; d--) { + const node = $from.node(d) + if (node.type.name === 'image') { + const pos = $from.before(d) + editor.chain().focus().setNodeSelection(pos).deleteSelection().run() + break + } + } + } + } + + return ( + ed.isActive('image')} + tippyOptions={{ + placement: 'top', + duration: 100, + interactive: true, + }} + > +
+ {/* Size presets */} +
+ + {showSizeMenu && ( +
+
Proportional
+
+ {SIZE_PRESETS.map(({ label, width }) => ( + + ))} +
+
Width only
+
+ {WIDTH_ONLY_PRESETS.map((w) => ( + + ))} +
+
Height only
+
+ {HEIGHT_ONLY_PRESETS.map((h) => ( + + ))} +
+
+ )} +
+ +
+ + {/* Align */} +
+ + {showAlignMenu && ( +
+ {ALIGN_OPTIONS.map(({ label, value }) => ( + + ))} +
+ )} +
+ +
+ + {/* Alt text */} + + + {/* Caption */} + {onCaptionClick && ( + + )} + + {/* Replace */} + + +
+ + {/* Delete - use onMouseDown so selection stays on image before command runs */} + +
+ + ) +} diff --git a/frontend/src/components/MediaLibraryGrid.jsx b/frontend/src/components/MediaLibraryGrid.jsx new file mode 100644 index 0000000..23409d1 --- /dev/null +++ b/frontend/src/components/MediaLibraryGrid.jsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import api from '../utils/api' +import toast from 'react-hot-toast' + +export default function MediaLibraryGrid({ items, onSelect, loading, onDeleted }) { + const [deletingKey, setDeletingKey] = useState(null) + const [confirmDelete, setConfirmDelete] = useState(null) + + const handleDeleteClick = (e, item) => { + e.stopPropagation() + setConfirmDelete(item) + } + + const handleConfirmDelete = async () => { + if (!confirmDelete) return + const key = confirmDelete.key + setConfirmDelete(null) + setDeletingKey(key) + try { + await api.delete('/upload/media', { data: { key } }) + toast.success('Removed from storage') + onDeleted?.() + } catch (err) { + toast.error(err.response?.data?.message || err.message || 'Failed to delete') + } finally { + setDeletingKey(null) + } + } + + if (loading) { + return ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ ) + } + + if (!items || items.length === 0) { + return ( +
+
🖼️
+

No images yet.

+

Upload in the Upload tab.

+
+ ) + } + + return ( + <> +
+ {items.map((item) => ( +
+ + +
+ ))} +
+ {confirmDelete && ( +
+
e.stopPropagation()}> +

Remove from S3?

+

This cannot be undone.

+

This won't remove the image from the post content.

+
+ + +
+
+
+ )} + + ) +} diff --git a/frontend/src/components/MediaLibraryModal.jsx b/frontend/src/components/MediaLibraryModal.jsx new file mode 100644 index 0000000..15ec330 --- /dev/null +++ b/frontend/src/components/MediaLibraryModal.jsx @@ -0,0 +1,176 @@ +import { useState, useEffect, useCallback } from 'react' +import MediaUploadZone from './MediaUploadZone' +import MediaLibraryGrid from './MediaLibraryGrid' +import api from '../utils/api' +import toast from 'react-hot-toast' + +const TABS = { UPLOAD: 'upload', LIBRARY: 'library' } + +export default function MediaLibraryModal({ + isOpen, + onClose, + onInsertImage, + postId, + sessionId, + onUploadFiles, + mode = 'insert', +}) { + const [activeTab, setActiveTab] = useState(TABS.UPLOAD) + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) + + const fetchMedia = useCallback(async () => { + if (!postId && !sessionId) { + setItems([]) + return + } + setLoading(true) + try { + const params = postId ? { postId } : { sessionId } + const res = await api.get('/upload/media', { params }) + setItems(res.data.items || []) + } catch (err) { + console.error('Failed to fetch media:', err) + setItems([]) + } finally { + setLoading(false) + } + }, [postId, sessionId]) + + useEffect(() => { + if (isOpen) { + fetchMedia() + setActiveTab(items.length > 0 ? TABS.LIBRARY : TABS.UPLOAD) + } + }, [isOpen, postId, sessionId]) + + const handleUpload = async (files) => { + if (!onUploadFiles || files.length === 0) return + setUploading(true) + try { + for (const file of files) { + await onUploadFiles(file) // called per file + } + toast.success( + files.length === 1 ? 'Image uploaded!' : `${files.length} images uploaded!` + ) + await fetchMedia() + // Stay on Upload tab so user can add more files + } catch (err) { + toast.error(err.message || 'Upload failed') + } finally { + setUploading(false) + } + } + + const handleSelect = (item) => { + onInsertImage?.(item.url, item.filename) + onClose() + } + + const handleKeyDown = (e) => { + if (e.key === 'Escape') onClose() + } + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + role="document" + > +
+

+ Media Library +

+ +
+ +
+
+ + +
+
+ +
+ {mode === 'replace' && activeTab === TABS.LIBRARY && ( +

+ Choose an image to replace the current one. +

+ )} + {activeTab === TABS.UPLOAD && ( +
+ + {!postId && !sessionId && ( +

+ Save your post first to upload images, or add content and wait for auto-save. +

+ )} +
+ )} + {activeTab === TABS.LIBRARY && ( + + )} +
+ +
+ Images are saved to this blog's folder. +
+
+
+ ) +} diff --git a/frontend/src/components/MediaUploadZone.jsx b/frontend/src/components/MediaUploadZone.jsx new file mode 100644 index 0000000..656b277 --- /dev/null +++ b/frontend/src/components/MediaUploadZone.jsx @@ -0,0 +1,91 @@ +import { useCallback } from 'react' + +const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] +const MAX_SIZE = 10 * 1024 * 1024 // 10MB + +function validateFile(file) { + if (!file.type.startsWith('image/') || !ACCEPTED_TYPES.includes(file.type)) { + return { valid: false, error: 'Please select a valid image (JPEG, PNG, GIF, or WebP)' } + } + if (file.size > MAX_SIZE) { + return { valid: false, error: 'Image must be less than 10MB' } + } + return { valid: true } +} + +export default function MediaUploadZone({ onFilesSelected, uploading, disabled }) { + const handleFiles = useCallback( + (files) => { + const fileList = Array.from(files || []).filter((f) => f.type.startsWith('image/')) + const validFiles = [] + const errors = [] + fileList.forEach((file) => { + const { valid, error } = validateFile(file) + if (valid) validFiles.push(file) + else if (error) errors.push(`${file.name}: ${error}`) + }) + if (errors.length) { + console.warn('Media upload validation:', errors) + } + if (validFiles.length) { + onFilesSelected(validFiles) + } + }, + [onFilesSelected] + ) + + const handleDrop = (e) => { + e.preventDefault() + e.stopPropagation() + if (disabled || uploading) return + handleFiles(e.dataTransfer.files) + } + + const handleDragOver = (e) => { + e.preventDefault() + e.stopPropagation() + } + + const handleChange = (e) => { + handleFiles(e.target.files) + e.target.value = '' + } + + return ( +
+ +