fix
This commit is contained in:
parent
7613e66da8
commit
d512196fb7
|
|
@ -1,6 +1,6 @@
|
||||||
import { S3Client } from '@aws-sdk/client-s3'
|
import { S3Client } from '@aws-sdk/client-s3'
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
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 { v4 as uuid } from 'uuid'
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import logger from '../utils/logger.js'
|
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 for health checks (only requires s3:ListBucket permission)
|
||||||
export { ListObjectsV2Command }
|
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()) {
|
if (!isS3Configured()) {
|
||||||
logger.error('S3', 'S3 not configured', null)
|
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
|
// Extract file extension from filename or content type
|
||||||
const ext = filename.split('.').pop() || contentType.split('/')[1] || 'jpg'
|
const ext = filename.split('.').pop() || contentType.split('/')[1] || 'jpg'
|
||||||
// Use UUID for unique file names (matching api-v1 pattern)
|
// Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts
|
||||||
const key = `images/${uuid()}.${ext}`
|
const folderPrefix = postId
|
||||||
|
? `blogs/${postId}/images`
|
||||||
|
: `blogs/draft/${sessionId || 'temp'}/images`
|
||||||
|
const key = `${folderPrefix}/${uuid()}.${ext}`
|
||||||
|
|
||||||
logger.s3('GENERATING_PRESIGNED_URL', {
|
logger.s3('GENERATING_PRESIGNED_URL', {
|
||||||
bucket: BUCKET_NAME,
|
bucket: BUCKET_NAME,
|
||||||
|
|
@ -86,3 +95,62 @@ export async function getPresignedUploadUrl(filename, contentType) {
|
||||||
|
|
||||||
return { uploadUrl, imageUrl, key }
|
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 express from 'express'
|
||||||
import { getPresignedUploadUrl } from '../config/s3.js'
|
import { getPresignedUploadUrl, listBlogImages, deleteBlogImage } from '../config/s3.js'
|
||||||
import logger from '../utils/logger.js'
|
import logger from '../utils/logger.js'
|
||||||
|
|
||||||
const router = express.Router()
|
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
|
// Get presigned URL for image upload
|
||||||
// Note: authenticateToken middleware is applied at server level
|
// Note: authenticateToken middleware is applied at server level
|
||||||
router.post('/presigned-url', async (req, res) => {
|
router.post('/presigned-url', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { filename, contentType } = req.body
|
const { filename, contentType, postId, sessionId } = req.body
|
||||||
|
|
||||||
logger.transaction('GENERATE_PRESIGNED_URL', {
|
logger.transaction('GENERATE_PRESIGNED_URL', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
filename,
|
filename,
|
||||||
contentType
|
contentType,
|
||||||
|
postId,
|
||||||
|
sessionId
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!filename || !contentType) {
|
if (!filename || !contentType) {
|
||||||
|
|
@ -48,7 +66,7 @@ router.post('/presigned-url', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now()
|
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
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
logger.s3('PRESIGNED_URL_GENERATED', {
|
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
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^3.19.0",
|
||||||
"@tiptap/extension-color": "^2.1.13",
|
"@tiptap/extension-color": "^2.1.13",
|
||||||
"@tiptap/extension-image": "^2.1.13",
|
"@tiptap/extension-image": "^2.1.13",
|
||||||
"@tiptap/extension-text-style": "^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 { useEditor, EditorContent } from '@tiptap/react'
|
||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import TextStyle from '@tiptap/extension-text-style'
|
import TextStyle from '@tiptap/extension-text-style'
|
||||||
import Color from '@tiptap/extension-color'
|
import Color from '@tiptap/extension-color'
|
||||||
|
|
@ -7,128 +7,125 @@ import Underline from '@tiptap/extension-underline'
|
||||||
import { FontSize } from '../extensions/FontSize'
|
import { FontSize } from '../extensions/FontSize'
|
||||||
import { ImageResize } from '../extensions/ImageResize'
|
import { ImageResize } from '../extensions/ImageResize'
|
||||||
import Toolbar from './Toolbar'
|
import Toolbar from './Toolbar'
|
||||||
|
import ImageBubbleMenu from './ImageBubbleMenu'
|
||||||
|
import MediaLibraryModal from './MediaLibraryModal'
|
||||||
|
import AltTextModal from './AltTextModal'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import toast from 'react-hot-toast'
|
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 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
|
const editor = editorRef.current
|
||||||
if (!editor) {
|
if (insert && !editor) {
|
||||||
toast.error('Editor not ready')
|
throw new Error('Editor not ready')
|
||||||
return
|
}
|
||||||
|
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 {
|
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' })
|
toast.loading('Uploading image...', { id: 'image-upload' })
|
||||||
|
await performUpload(file, { insert: true })
|
||||||
// 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()
|
|
||||||
toast.success('Image uploaded successfully!', { id: 'image-upload' })
|
toast.success('Image uploaded successfully!', { id: 'image-upload' })
|
||||||
|
|
||||||
if (onImageUpload) {
|
|
||||||
onImageUpload(imageUrl)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Image upload failed:', error)
|
const msg = error.response?.data?.message || error.message || 'Failed to upload image.'
|
||||||
const errorMessage = error.response?.data?.message ||
|
toast.error(msg, { id: 'image-upload', duration: 5000 })
|
||||||
error.message ||
|
throw error
|
||||||
'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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}, [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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
|
|
@ -199,8 +196,50 @@ export default function Editor({ content, onChange, onImageUpload }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
<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" />
|
<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>
|
</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 }) {
|
function ImageNode({ attrs }) {
|
||||||
const { src, alt = '', title = null } = attrs
|
const { src, alt = '', title = null, width, height, align } = attrs
|
||||||
|
|
||||||
if (!src) return null
|
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 (
|
return (
|
||||||
<div className="mb-4">
|
<div className={`mb-4 block ${alignClass}`}>
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className="w-full rounded-lg"
|
className="max-w-full h-auto rounded-lg"
|
||||||
|
style={Object.keys(style).length ? style : undefined}
|
||||||
/>
|
/>
|
||||||
{title && (
|
{title && (
|
||||||
<p className="text-sm text-gray-500 text-center mt-2">
|
<p className="text-sm text-gray-500 text-center mt-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
const Toolbar = ({ editor, onImageUpload }) => {
|
const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, postId, sessionId }) => {
|
||||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
const [showFontSize, setShowFontSize] = 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 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 (
|
return (
|
||||||
<div className="border-b border-gray-300 bg-gray-50 p-2 flex flex-wrap items-center gap-2">
|
<div className="border-b border-gray-300 bg-gray-50 p-2 flex flex-wrap items-center gap-2">
|
||||||
{/* Text Formatting */}
|
{/* Text Formatting */}
|
||||||
|
|
@ -189,27 +181,15 @@ const Toolbar = ({ editor, onImageUpload }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Upload */}
|
{/* Media Library - Insert Image */}
|
||||||
<div>
|
<div>
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageInput}
|
|
||||||
className="hidden"
|
|
||||||
id="image-upload-input"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onOpenMediaLibrary?.()}
|
||||||
const input = document.getElementById('image-upload-input')
|
|
||||||
if (input) {
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`p-2 rounded hover:bg-gray-200 ${
|
className={`p-2 rounded hover:bg-gray-200 ${
|
||||||
editor.isActive('image') ? 'bg-gray-300' : ''
|
editor.isActive('image') ? 'bg-gray-300' : ''
|
||||||
}`}
|
}`}
|
||||||
title="Insert Image"
|
title="Insert Image (Media Library)"
|
||||||
>
|
>
|
||||||
🖼️
|
🖼️
|
||||||
</button>
|
</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 }) => {
|
return ({ node, HTMLAttributes, getPos, editor, selected }) => {
|
||||||
const dom = document.createElement('div')
|
const dom = document.createElement('div')
|
||||||
dom.className = 'image-resize-wrapper'
|
dom.className = 'image-resize-wrapper'
|
||||||
|
if (node.attrs.align) {
|
||||||
|
dom.classList.add(`align-${node.attrs.align}`)
|
||||||
|
}
|
||||||
if (selected) {
|
if (selected) {
|
||||||
dom.classList.add('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')
|
const img = document.createElement('img')
|
||||||
img.src = node.attrs.src
|
img.src = node.attrs.src
|
||||||
img.alt = node.attrs.alt || ''
|
img.alt = node.attrs.alt || ''
|
||||||
|
|
@ -53,19 +80,46 @@ export const ImageResize = Image.extend({
|
||||||
img.style.display = 'block'
|
img.style.display = 'block'
|
||||||
img.style.maxWidth = '100%'
|
img.style.maxWidth = '100%'
|
||||||
img.style.height = 'auto'
|
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) {
|
const hidePlaceholder = () => {
|
||||||
img.style.width = `${node.attrs.width}px`
|
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`
|
img.style.height = `${node.attrs.height}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize handle
|
// Resize handle - larger for easier use
|
||||||
const resizeHandle = document.createElement('div')
|
const resizeHandle = document.createElement('div')
|
||||||
resizeHandle.className = 'resize-handle'
|
resizeHandle.className = 'resize-handle'
|
||||||
resizeHandle.innerHTML = '↘'
|
resizeHandle.innerHTML = '↘'
|
||||||
resizeHandle.style.display = selected ? 'flex' : 'none'
|
resizeHandle.style.display = selected ? 'flex' : 'none'
|
||||||
|
resizeHandle.setAttribute('role', 'button')
|
||||||
|
resizeHandle.setAttribute('aria-label', 'Resize image')
|
||||||
|
|
||||||
let isResizing = false
|
let isResizing = false
|
||||||
let startX = 0
|
let startX = 0
|
||||||
|
|
@ -87,48 +141,63 @@ export const ImageResize = Image.extend({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resizeHandle.addEventListener('mousedown', (e) => {
|
const startResize = (clientX, clientY) => {
|
||||||
isResizing = true
|
isResizing = true
|
||||||
startX = e.clientX
|
startX = clientX
|
||||||
startY = e.clientY
|
startY = clientY
|
||||||
const rect = img.getBoundingClientRect()
|
const rect = img.getBoundingClientRect()
|
||||||
startWidth = rect.width
|
startWidth = rect.width
|
||||||
startHeight = rect.height
|
startHeight = rect.height
|
||||||
e.preventDefault()
|
}
|
||||||
e.stopPropagation()
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleMouseMove = (e) => {
|
const doResize = (clientX, clientY) => {
|
||||||
if (!isResizing) return
|
if (!isResizing) return
|
||||||
|
const deltaX = clientX - startX
|
||||||
const deltaX = e.clientX - startX
|
|
||||||
const deltaY = e.clientY - startY
|
|
||||||
const aspectRatio = startHeight / startWidth
|
const aspectRatio = startHeight / startWidth
|
||||||
const newWidth = Math.max(100, Math.min(1200, startWidth + deltaX))
|
const newWidth = Math.max(100, Math.min(1200, startWidth + deltaX))
|
||||||
const newHeight = newWidth * aspectRatio
|
const newHeight = newWidth * aspectRatio
|
||||||
|
|
||||||
img.style.width = `${newWidth}px`
|
img.style.width = `${newWidth}px`
|
||||||
img.style.height = `${newHeight}px`
|
img.style.height = `${newHeight}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const endResize = () => {
|
||||||
if (!isResizing) return
|
if (!isResizing) return
|
||||||
|
|
||||||
isResizing = false
|
isResizing = false
|
||||||
const width = parseInt(img.style.width, 10)
|
const width = parseInt(img.style.width, 10)
|
||||||
const height = parseInt(img.style.height, 10)
|
const height = parseInt(img.style.height, 10)
|
||||||
|
|
||||||
if (typeof getPos === 'function' && editor) {
|
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('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
const handleMouseMove = (e) => doResize(e.clientX, e.clientY)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
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)
|
dom.appendChild(img)
|
||||||
updateResizeHandle()
|
updateResizeHandle()
|
||||||
|
|
||||||
|
|
@ -162,11 +231,24 @@ export const ImageResize = Image.extend({
|
||||||
if (updatedNode.attrs.src !== node.attrs.src) {
|
if (updatedNode.attrs.src !== node.attrs.src) {
|
||||||
img.src = updatedNode.attrs.src
|
img.src = updatedNode.attrs.src
|
||||||
}
|
}
|
||||||
if (updatedNode.attrs.width !== node.attrs.width) {
|
if (updatedNode.attrs.width !== node.attrs.width || updatedNode.attrs.height !== node.attrs.height) {
|
||||||
img.style.width = updatedNode.attrs.width ? `${updatedNode.attrs.width}px` : 'auto'
|
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) {
|
dom.classList.remove('align-left', 'align-center', 'align-right')
|
||||||
img.style.height = updatedNode.attrs.height ? `${updatedNode.attrs.height}px` : 'auto'
|
if (updatedNode.attrs.align) {
|
||||||
|
dom.classList.add(`align-${updatedNode.attrs.align}`)
|
||||||
}
|
}
|
||||||
node = updatedNode
|
node = updatedNode
|
||||||
return true
|
return true
|
||||||
|
|
@ -175,6 +257,8 @@ export const ImageResize = Image.extend({
|
||||||
editor.off('selectionUpdate', updateSelection)
|
editor.off('selectionUpdate', updateSelection)
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,27 @@ body {
|
||||||
max-width: 100%;
|
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 {
|
.ProseMirror .image-resize-wrapper.selected {
|
||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #3b82f6;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|
@ -97,12 +118,24 @@ body {
|
||||||
cursor: default;
|
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 {
|
.ProseMirror .image-resize-wrapper .resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -8px;
|
bottom: -10px;
|
||||||
right: -8px;
|
right: -10px;
|
||||||
width: 24px;
|
width: 32px;
|
||||||
height: 24px;
|
height: 32px;
|
||||||
background-color: #3b82f6;
|
background-color: #3b82f6;
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
@ -112,8 +145,8 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror .image-resize-wrapper .resize-handle:hover {
|
.ProseMirror .image-resize-wrapper .resize-handle:hover {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ export default function EditorPage() {
|
||||||
const autoSaveTimeoutRef = useRef(null)
|
const autoSaveTimeoutRef = useRef(null)
|
||||||
const isInitialLoadRef = useRef(true)
|
const isInitialLoadRef = useRef(true)
|
||||||
const currentPostIdRef = useRef(id)
|
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(() => {
|
useEffect(() => {
|
||||||
currentPostIdRef.current = id
|
currentPostIdRef.current = id
|
||||||
|
|
@ -157,86 +163,85 @@ export default function EditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
<div className="min-h-screen bg-gray-100 flex flex-col">
|
||||||
<nav className="bg-white shadow flex-shrink-0">
|
{/* 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="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
|
<button
|
||||||
onClick={() => navigate('/dashboard')}
|
onClick={() => navigate('/dashboard')}
|
||||||
className="text-gray-700 hover:text-gray-900"
|
className="text-gray-600 hover:text-gray-900 font-medium"
|
||||||
>
|
>
|
||||||
← Back to Dashboard
|
← Back to Dashboard
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center gap-4">
|
||||||
{saving && (
|
{saving && (
|
||||||
<span className="text-sm text-gray-500">Saving...</span>
|
<span className="text-sm text-amber-600 font-medium">Saving...</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
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
|
showPreview
|
||||||
? 'bg-indigo-100 text-indigo-600'
|
? 'bg-indigo-100 text-indigo-700'
|
||||||
: 'text-gray-600 hover:bg-gray-100'
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
title="Toggle Mobile Preview"
|
title="Toggle Mobile Preview"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-5 h-5"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
|
Preview
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={saving}
|
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
|
Publish
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Main Editor Section */}
|
{/* Main Editor Section */}
|
||||||
<div
|
<main className={`flex-1 overflow-y-auto transition-all duration-300`}>
|
||||||
className={`flex-1 overflow-y-auto transition-all duration-300 ${
|
|
||||||
showPreview ? 'mr-0' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<input
|
{/* Card-style editor block */}
|
||||||
type="text"
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
placeholder="Enter post title..."
|
<div className="px-6 pt-6 pb-2">
|
||||||
value={title}
|
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title</label>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
<input
|
||||||
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"
|
type="text"
|
||||||
/>
|
placeholder="Enter post title..."
|
||||||
|
value={title}
|
||||||
<Editor
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
content={content}
|
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"
|
||||||
onChange={setContent}
|
/>
|
||||||
/>
|
</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>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{/* Mobile Preview Sidebar */}
|
{/* Mobile Preview Sidebar */}
|
||||||
<div
|
<aside
|
||||||
className={`bg-gray-100 border-l border-gray-300 transition-all duration-300 overflow-hidden ${
|
className={`bg-gray-50 border-l border-gray-200 transition-all duration-300 overflow-hidden flex-shrink-0 ${
|
||||||
showPreview ? 'w-[375px]' : 'w-0'
|
showPreview ? 'w-[380px]' : 'w-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="h-full p-4">
|
<div className="h-full p-4">
|
||||||
|
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Mobile Preview</div>
|
||||||
<MobilePreview
|
<MobilePreview
|
||||||
title={title}
|
title={title}
|
||||||
content={content}
|
content={content}
|
||||||
|
|
@ -244,7 +249,7 @@ export default function EditorPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue