This commit is contained in:
chandresh 2026-02-09 21:01:28 +05:30
parent 7613e66da8
commit d512196fb7
14 changed files with 1170 additions and 224 deletions

View File

@ -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 })
}

View File

@ -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

View File

@ -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",

View File

@ -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>
)
}

View File

@ -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>
) )
} }

View File

@ -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>
)
}

View File

@ -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&apos;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>
)}
</>
)
}

View File

@ -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&apos;s folder.
</div>
</div>
</div>
)
}

View File

@ -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>
)
}

View File

@ -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">

View File

@ -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>

View File

@ -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)
}, },
} }
} }

View File

@ -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 {

View File

@ -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>
) )