fix
This commit is contained in:
parent
7613e66da8
commit
d512196fb7
|
|
@ -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<Array<{key: string, url: string, filename: string}>>}
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="alt-text-title"
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl w-full max-w-md p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="alt-text-title" className="text-lg font-semibold mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file')
|
||||
return
|
||||
throw new Error('Please select an image file')
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 10MB')
|
||||
return
|
||||
throw new Error('Image size must be less than 10MB')
|
||||
}
|
||||
|
||||
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,
|
||||
postId: postId || undefined,
|
||||
sessionId: sessionId || undefined,
|
||||
})
|
||||
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
|
||||
}
|
||||
const data = response.data
|
||||
|
||||
// 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, {
|
||||
const uploadResponse = await fetch(data.uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
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}`)
|
||||
throw new Error(`Upload failed: ${uploadResponse.status} ${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()
|
||||
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 {
|
||||
toast.loading('Uploading image...', { id: 'image-upload' })
|
||||
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.'
|
||||
const msg = error.response?.data?.message || error.message || 'Failed to upload image.'
|
||||
toast.error(msg, { id: 'image-upload', duration: 5000 })
|
||||
throw error
|
||||
}
|
||||
}, [performUpload])
|
||||
|
||||
toast.error(errorMessage, {
|
||||
id: 'image-upload',
|
||||
duration: 5000,
|
||||
})
|
||||
const uploadFileOnly = useCallback(async (file) => {
|
||||
return performUpload(file, { insert: false })
|
||||
}, [performUpload])
|
||||
|
||||
// Log detailed error for debugging
|
||||
if (error.response) {
|
||||
console.error('Error response:', error.response.data)
|
||||
console.error('Error status:', error.response.status)
|
||||
const insertImage = useCallback((url, alt = '') => {
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
editor.chain().focus().setImage({ src: url, alt }).run()
|
||||
}
|
||||
if (error.request) {
|
||||
console.error('Request made but no response:', error.request)
|
||||
}, [])
|
||||
|
||||
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 (
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<Toolbar editor={editor} onImageUpload={handleImageUpload} />
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
onImageUpload={handleImageUpload}
|
||||
onUploadFile={uploadFileOnly}
|
||||
onOpenMediaLibrary={() => {
|
||||
setMediaModalMode('insert')
|
||||
setShowMediaModal(true)
|
||||
}}
|
||||
postId={postId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
<EditorContent editor={editor} className="min-h-[400px] bg-white" />
|
||||
<ImageBubbleMenu
|
||||
editor={editor}
|
||||
onReplaceClick={() => {
|
||||
setMediaModalMode('replace')
|
||||
setShowMediaModal(true)
|
||||
}}
|
||||
onAltTextClick={() => setShowAltModal(true)}
|
||||
onCaptionClick={() => setShowCaptionModal(true)}
|
||||
/>
|
||||
<MediaLibraryModal
|
||||
isOpen={showMediaModal}
|
||||
onClose={() => setShowMediaModal(false)}
|
||||
onInsertImage={handleMediaSelect}
|
||||
postId={postId}
|
||||
sessionId={sessionId}
|
||||
onUploadFiles={uploadFileOnly}
|
||||
mode={mediaModalMode}
|
||||
/>
|
||||
<AltTextModal
|
||||
isOpen={showAltModal}
|
||||
initialValue={getCurrentAlt()}
|
||||
onSave={handleAltTextSave}
|
||||
onClose={() => setShowAltModal(false)}
|
||||
/>
|
||||
<AltTextModal
|
||||
isOpen={showCaptionModal}
|
||||
initialValue={getCurrentTitle()}
|
||||
onSave={handleCaptionSave}
|
||||
onClose={() => setShowCaptionModal(false)}
|
||||
title="Edit caption"
|
||||
placeholder="Caption (shown below image)"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={({ editor: ed }) => ed.isActive('image')}
|
||||
tippyOptions={{
|
||||
placement: 'top',
|
||||
duration: 100,
|
||||
interactive: true,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1 bg-white border border-gray-200 rounded-lg shadow-lg">
|
||||
{/* Size presets */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSizeMenu(!showSizeMenu)}
|
||||
className="px-2 py-1.5 text-sm rounded hover:bg-gray-100"
|
||||
title="Resize"
|
||||
>
|
||||
Size
|
||||
</button>
|
||||
{showSizeMenu && (
|
||||
<div className="absolute bottom-full left-0 mb-1 flex flex-col gap-1 p-1 bg-white border border-gray-200 rounded shadow-lg min-w-[120px]">
|
||||
<div className="text-xs text-gray-500 px-1 pb-0.5 border-b border-gray-100">Proportional</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{SIZE_PRESETS.map(({ label, width }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setSize(width)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-gray-100"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 px-1 pt-1 pb-0.5 border-b border-gray-100">Width only</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{WIDTH_ONLY_PRESETS.map((w) => (
|
||||
<button
|
||||
key={`w-${w}`}
|
||||
type="button"
|
||||
onClick={() => setWidthOnly(w)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-gray-100"
|
||||
>
|
||||
{w}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 px-1 pt-1 pb-0.5">Height only</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{HEIGHT_ONLY_PRESETS.map((h) => (
|
||||
<button
|
||||
key={`h-${h}`}
|
||||
type="button"
|
||||
onClick={() => setHeightOnly(h)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-gray-100"
|
||||
>
|
||||
{h}px
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-gray-200" />
|
||||
|
||||
{/* Align */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAlignMenu(!showAlignMenu)}
|
||||
className="px-2 py-1.5 text-sm rounded hover:bg-gray-100"
|
||||
title="Align"
|
||||
>
|
||||
Align
|
||||
</button>
|
||||
{showAlignMenu && (
|
||||
<div className="absolute bottom-full left-0 mb-1 flex gap-1 p-1 bg-white border border-gray-200 rounded shadow-lg">
|
||||
{ALIGN_OPTIONS.map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setAlign(value)}
|
||||
className={`px-2 py-1 text-xs rounded hover:bg-gray-100 ${currentAlign === value ? 'font-semibold bg-gray-100' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-gray-200" />
|
||||
|
||||
{/* Alt text */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAltTextClick?.()
|
||||
setShowAlignMenu(false)
|
||||
setShowSizeMenu(false)
|
||||
}}
|
||||
className="px-2 py-1.5 text-sm rounded hover:bg-gray-100"
|
||||
title="Edit alt text"
|
||||
>
|
||||
Alt
|
||||
</button>
|
||||
|
||||
{/* Caption */}
|
||||
{onCaptionClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCaptionClick?.()
|
||||
setShowAlignMenu(false)
|
||||
setShowSizeMenu(false)
|
||||
}}
|
||||
className="px-2 py-1.5 text-sm rounded hover:bg-gray-100"
|
||||
title="Edit caption"
|
||||
>
|
||||
Caption
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Replace */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onReplaceClick?.()
|
||||
setShowAlignMenu(false)
|
||||
setShowSizeMenu(false)
|
||||
}}
|
||||
className="px-2 py-1.5 text-sm rounded hover:bg-gray-100"
|
||||
title="Replace image"
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-gray-200" />
|
||||
|
||||
{/* Delete - use onMouseDown so selection stays on image before command runs */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
className="px-2 py-1.5 text-sm text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete image"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="grid grid-cols-4 gap-4 p-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-gray-200 rounded-lg animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<div className="text-4xl mb-2">🖼️</div>
|
||||
<p>No images yet.</p>
|
||||
<p className="text-sm">Upload in the Upload tab.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-4 p-4 max-h-[320px] overflow-y-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="group relative aspect-square rounded-lg overflow-hidden border-2 border-transparent hover:border-indigo-500 focus-within:border-indigo-500 transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className="absolute inset-0 w-full h-full flex items-center justify-center"
|
||||
title="Click to insert"
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.filename}
|
||||
className="w-full h-full object-cover block pointer-events-none"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDeleteClick(e, item)}
|
||||
disabled={deletingKey === item.key}
|
||||
className="absolute top-1 right-1 p-1.5 rounded bg-red-500/90 hover:bg-red-600 text-white opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100 focus:outline-none disabled:opacity-50"
|
||||
title="Delete from storage"
|
||||
aria-label="Delete from storage"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50" role="dialog" aria-modal="true">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full p-4" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="font-medium text-gray-900">Remove from S3?</p>
|
||||
<p className="text-sm text-gray-600 mt-1">This cannot be undone.</p>
|
||||
<p className="text-sm text-amber-700 mt-2">This won't remove the image from the post content.</p>
|
||||
<div className="flex gap-2 mt-4 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmDelete}
|
||||
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="media-library-title"
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl w-full max-w-[600px] max-h-[90vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
||||
<h2 id="media-library-title" className="text-lg font-semibold">
|
||||
Media Library
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(TABS.UPLOAD)}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
activeTab === TABS.UPLOAD
|
||||
? 'border-b-2 border-indigo-600 text-indigo-600'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(TABS.LIBRARY)}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
activeTab === TABS.LIBRARY
|
||||
? 'border-b-2 border-indigo-600 text-indigo-600'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{mode === 'replace' && activeTab === TABS.LIBRARY && (
|
||||
<p className="px-4 py-2 text-sm text-indigo-700 bg-indigo-50 border-b border-indigo-100">
|
||||
Choose an image to replace the current one.
|
||||
</p>
|
||||
)}
|
||||
{activeTab === TABS.UPLOAD && (
|
||||
<div className="p-4">
|
||||
<MediaUploadZone
|
||||
onFilesSelected={handleUpload}
|
||||
uploading={uploading}
|
||||
disabled={!postId && !sessionId}
|
||||
/>
|
||||
{!postId && !sessionId && (
|
||||
<p className="text-sm text-amber-600 mt-2">
|
||||
Save your post first to upload images, or add content and wait for auto-save.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === TABS.LIBRARY && (
|
||||
<MediaLibraryGrid
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
loading={loading}
|
||||
onDeleted={fetchMedia}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-gray-200 text-xs text-gray-500">
|
||||
Images are saved to this blog's folder.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||
${disabled || uploading ? 'bg-gray-50 border-gray-200 cursor-not-allowed' : 'border-gray-300 hover:border-indigo-500 hover:bg-indigo-50/50 cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(',')}
|
||||
onChange={handleChange}
|
||||
disabled={disabled || uploading}
|
||||
multiple
|
||||
className="hidden"
|
||||
id="media-upload-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="media-upload-input"
|
||||
className={`block cursor-pointer ${disabled || uploading ? 'cursor-not-allowed pointer-events-none' : ''}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-600">Uploading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-4xl mb-2">📤</div>
|
||||
<p className="text-gray-700 font-medium">Drag images here or click to browse</p>
|
||||
<p className="text-sm text-gray-500 mt-1">JPEG, PNG, GIF, WebP — max 10MB each</p>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -184,16 +184,22 @@ function ListItemNode({ content, marks }) {
|
|||
}
|
||||
|
||||
function ImageNode({ attrs }) {
|
||||
const { src, alt = '', title = null } = attrs
|
||||
const { src, alt = '', title = null, width, height, align } = attrs
|
||||
|
||||
if (!src) return null
|
||||
|
||||
const alignClass = align === 'left' ? 'mr-auto' : align === 'right' ? 'ml-auto' : 'mx-auto'
|
||||
const style = {}
|
||||
if (width) style.width = `${width}px`
|
||||
if (height) style.height = `${height}px`
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className={`mb-4 block ${alignClass}`}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full rounded-lg"
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
style={Object.keys(style).length ? style : undefined}
|
||||
/>
|
||||
{title && (
|
||||
<p className="text-sm text-gray-500 text-center mt-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
const Toolbar = ({ editor, onImageUpload }) => {
|
||||
const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, postId, sessionId }) => {
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
const [showFontSize, setShowFontSize] = useState(false)
|
||||
|
||||
|
|
@ -15,14 +15,6 @@ const Toolbar = ({ editor, onImageUpload }) => {
|
|||
|
||||
const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '32px']
|
||||
|
||||
const handleImageInput = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
onImageUpload(file)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-300 bg-gray-50 p-2 flex flex-wrap items-center gap-2">
|
||||
{/* Text Formatting */}
|
||||
|
|
@ -189,27 +181,15 @@ const Toolbar = ({ editor, onImageUpload }) => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
{/* Media Library - Insert Image */}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageInput}
|
||||
className="hidden"
|
||||
id="image-upload-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('image-upload-input')
|
||||
if (input) {
|
||||
input.click()
|
||||
}
|
||||
}}
|
||||
onClick={() => onOpenMediaLibrary?.()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${
|
||||
editor.isActive('image') ? 'bg-gray-300' : ''
|
||||
}`}
|
||||
title="Insert Image"
|
||||
title="Insert Image (Media Library)"
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,24 @@ export const ImageResize = Image.extend({
|
|||
}
|
||||
},
|
||||
},
|
||||
align: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-align') || null,
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.align) return {}
|
||||
return { 'data-align': attributes.align }
|
||||
},
|
||||
},
|
||||
naturalWidth: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
naturalHeight: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -42,10 +60,19 @@ export const ImageResize = Image.extend({
|
|||
return ({ node, HTMLAttributes, getPos, editor, selected }) => {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'image-resize-wrapper'
|
||||
if (node.attrs.align) {
|
||||
dom.classList.add(`align-${node.attrs.align}`)
|
||||
}
|
||||
if (selected) {
|
||||
dom.classList.add('selected')
|
||||
}
|
||||
|
||||
const placeholder = document.createElement('div')
|
||||
placeholder.className = 'image-resize-placeholder'
|
||||
placeholder.setAttribute('aria-hidden', 'true')
|
||||
const minHeight = node.attrs.height || node.attrs.naturalHeight || 120
|
||||
placeholder.style.minHeight = `${minHeight}px`
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = node.attrs.src
|
||||
img.alt = node.attrs.alt || ''
|
||||
|
|
@ -53,19 +80,46 @@ export const ImageResize = Image.extend({
|
|||
img.style.display = 'block'
|
||||
img.style.maxWidth = '100%'
|
||||
img.style.height = 'auto'
|
||||
img.classList.add('image-resize-img')
|
||||
img.style.opacity = '0'
|
||||
img.style.transition = 'opacity 0.15s ease-out'
|
||||
|
||||
if (node.attrs.width) {
|
||||
img.style.width = `${node.attrs.width}px`
|
||||
const hidePlaceholder = () => {
|
||||
img.style.opacity = '1'
|
||||
if (placeholder.parentNode) placeholder.remove()
|
||||
}
|
||||
if (node.attrs.height) {
|
||||
|
||||
img.onload = () => {
|
||||
hidePlaceholder()
|
||||
if (typeof getPos !== 'function' || !editor) return
|
||||
const pos = getPos()
|
||||
if (pos == null) return
|
||||
const n = editor.state.doc.nodeAt(pos)
|
||||
if (!n || n.type.name !== 'image') return
|
||||
if (n.attrs.naturalWidth === img.naturalWidth && n.attrs.naturalHeight === img.naturalHeight) return
|
||||
editor.chain().focus().setNodeSelection(pos).updateAttributes('image', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight }).run()
|
||||
}
|
||||
img.onerror = hidePlaceholder
|
||||
if (img.complete) hidePlaceholder()
|
||||
|
||||
if (node.attrs.width != null && node.attrs.height != null) {
|
||||
img.style.width = `${node.attrs.width}px`
|
||||
img.style.height = `${node.attrs.height}px`
|
||||
} else if (node.attrs.width != null) {
|
||||
img.style.width = `${node.attrs.width}px`
|
||||
img.style.height = 'auto'
|
||||
} else if (node.attrs.height != null) {
|
||||
img.style.width = 'auto'
|
||||
img.style.height = `${node.attrs.height}px`
|
||||
}
|
||||
|
||||
// Resize handle
|
||||
// Resize handle - larger for easier use
|
||||
const resizeHandle = document.createElement('div')
|
||||
resizeHandle.className = 'resize-handle'
|
||||
resizeHandle.innerHTML = '↘'
|
||||
resizeHandle.style.display = selected ? 'flex' : 'none'
|
||||
resizeHandle.setAttribute('role', 'button')
|
||||
resizeHandle.setAttribute('aria-label', 'Resize image')
|
||||
|
||||
let isResizing = false
|
||||
let startX = 0
|
||||
|
|
@ -87,48 +141,63 @@ export const ImageResize = Image.extend({
|
|||
}
|
||||
}
|
||||
|
||||
resizeHandle.addEventListener('mousedown', (e) => {
|
||||
const startResize = (clientX, clientY) => {
|
||||
isResizing = true
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
startX = clientX
|
||||
startY = clientY
|
||||
const rect = img.getBoundingClientRect()
|
||||
startWidth = rect.width
|
||||
startHeight = rect.height
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const doResize = (clientX, clientY) => {
|
||||
if (!isResizing) return
|
||||
|
||||
const deltaX = e.clientX - startX
|
||||
const deltaY = e.clientY - startY
|
||||
const deltaX = clientX - startX
|
||||
const aspectRatio = startHeight / startWidth
|
||||
const newWidth = Math.max(100, Math.min(1200, startWidth + deltaX))
|
||||
const newHeight = newWidth * aspectRatio
|
||||
|
||||
img.style.width = `${newWidth}px`
|
||||
img.style.height = `${newHeight}px`
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const endResize = () => {
|
||||
if (!isResizing) return
|
||||
|
||||
isResizing = false
|
||||
const width = parseInt(img.style.width, 10)
|
||||
const height = parseInt(img.style.height, 10)
|
||||
|
||||
if (typeof getPos === 'function' && editor) {
|
||||
editor.chain().setImage({ width, height }).run()
|
||||
editor.chain().focus().updateAttributes('image', { width, height }).run()
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => doResize(e.clientX, e.clientY)
|
||||
const handleMouseUp = endResize
|
||||
const handleTouchMove = (e) => {
|
||||
if (e.touches.length) doResize(e.touches[0].clientX, e.touches[0].clientY)
|
||||
}
|
||||
const handleTouchEnd = endResize
|
||||
|
||||
resizeHandle.addEventListener('mousedown', (e) => {
|
||||
startResize(e.clientX, e.clientY)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
})
|
||||
resizeHandle.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length) {
|
||||
startResize(e.touches[0].clientX, e.touches[0].clientY)
|
||||
e.preventDefault()
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}, { passive: false })
|
||||
|
||||
dom.appendChild(placeholder)
|
||||
dom.appendChild(img)
|
||||
updateResizeHandle()
|
||||
|
||||
|
|
@ -162,11 +231,24 @@ export const ImageResize = Image.extend({
|
|||
if (updatedNode.attrs.src !== node.attrs.src) {
|
||||
img.src = updatedNode.attrs.src
|
||||
}
|
||||
if (updatedNode.attrs.width !== node.attrs.width) {
|
||||
img.style.width = updatedNode.attrs.width ? `${updatedNode.attrs.width}px` : 'auto'
|
||||
if (updatedNode.attrs.width !== node.attrs.width || updatedNode.attrs.height !== node.attrs.height) {
|
||||
if (updatedNode.attrs.width != null && updatedNode.attrs.height != null) {
|
||||
img.style.width = `${updatedNode.attrs.width}px`
|
||||
img.style.height = `${updatedNode.attrs.height}px`
|
||||
} else if (updatedNode.attrs.width != null) {
|
||||
img.style.width = `${updatedNode.attrs.width}px`
|
||||
img.style.height = 'auto'
|
||||
} else if (updatedNode.attrs.height != null) {
|
||||
img.style.width = 'auto'
|
||||
img.style.height = `${updatedNode.attrs.height}px`
|
||||
} else {
|
||||
img.style.width = ''
|
||||
img.style.height = 'auto'
|
||||
}
|
||||
if (updatedNode.attrs.height !== node.attrs.height) {
|
||||
img.style.height = updatedNode.attrs.height ? `${updatedNode.attrs.height}px` : 'auto'
|
||||
}
|
||||
dom.classList.remove('align-left', 'align-center', 'align-right')
|
||||
if (updatedNode.attrs.align) {
|
||||
dom.classList.add(`align-${updatedNode.attrs.align}`)
|
||||
}
|
||||
node = updatedNode
|
||||
return true
|
||||
|
|
@ -175,6 +257,8 @@ export const ImageResize = Image.extend({
|
|||
editor.off('selectionUpdate', updateSelection)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,27 @@ body {
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper.align-left {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper.align-center {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper.align-right {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper.selected {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
|
|
@ -97,12 +118,24 @@ body {
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper .image-resize-placeholder {
|
||||
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: image-placeholder-pulse 1.2s ease-in-out infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes image-placeholder-pulse {
|
||||
0%, 100% { background-position: 200% 0; }
|
||||
50% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper .resize-handle {
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #3b82f6;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
|
|
@ -112,8 +145,8 @@ body {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.ProseMirror .image-resize-wrapper .resize-handle:hover {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ export default function EditorPage() {
|
|||
const autoSaveTimeoutRef = useRef(null)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
const currentPostIdRef = useRef(id)
|
||||
const sessionIdRef = useRef(null)
|
||||
if (!id && !sessionIdRef.current) {
|
||||
sessionIdRef.current = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `draft-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
currentPostIdRef.current = id
|
||||
|
|
@ -157,86 +163,85 @@ export default function EditorPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<nav className="bg-white shadow flex-shrink-0">
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col">
|
||||
{/* Top bar */}
|
||||
<header className="bg-white border-b border-gray-200 flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex justify-between items-center h-14">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="text-gray-700 hover:text-gray-900"
|
||||
className="text-gray-600 hover:text-gray-900 font-medium"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{saving && (
|
||||
<span className="text-sm text-gray-500">Saving...</span>
|
||||
<span className="text-sm text-amber-600 font-medium">Saving...</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showPreview
|
||||
? 'bg-indigo-100 text-indigo-600'
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
title="Toggle Mobile Preview"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={saving}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
|
||||
className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main Editor Section */}
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto transition-all duration-300 ${
|
||||
showPreview ? 'mr-0' : ''
|
||||
}`}
|
||||
>
|
||||
<main className={`flex-1 overflow-y-auto transition-all duration-300`}>
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Card-style editor block */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 pt-6 pb-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter post title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full text-3xl font-bold mb-6 p-2 border-0 border-b-2 border-gray-300 focus:outline-none focus:border-indigo-600 bg-transparent"
|
||||
className="w-full text-2xl font-bold p-2 border-0 border-b-2 border-transparent hover:border-gray-200 focus:outline-none focus:border-indigo-500 bg-transparent transition-colors"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide px-4 pt-4 pb-1">Content</label>
|
||||
<Editor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
postId={id || currentPostIdRef.current}
|
||||
sessionId={!id ? sessionIdRef.current : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Preview Sidebar */}
|
||||
<div
|
||||
className={`bg-gray-100 border-l border-gray-300 transition-all duration-300 overflow-hidden ${
|
||||
showPreview ? 'w-[375px]' : 'w-0'
|
||||
<aside
|
||||
className={`bg-gray-50 border-l border-gray-200 transition-all duration-300 overflow-hidden flex-shrink-0 ${
|
||||
showPreview ? 'w-[380px]' : 'w-0'
|
||||
}`}
|
||||
>
|
||||
{showPreview && (
|
||||
<div className="h-full p-4">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Mobile Preview</div>
|
||||
<MobilePreview
|
||||
title={title}
|
||||
content={content}
|
||||
|
|
@ -244,7 +249,7 @@ export default function EditorPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue