diff --git a/backend/config/s3.js b/backend/config/s3.js index 9e04f7e..38bb31c 100644 --- a/backend/config/s3.js +++ b/backend/config/s3.js @@ -41,9 +41,10 @@ export { ListObjectsV2Command } * @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) + * @param {string} [purpose] - 'thumbnail' for post thumbnail (one per post at blogs/{postId}/thumbnail.{ext}); otherwise in-content image */ -export async function getPresignedUploadUrl(filename, contentType, postId, sessionId) { - logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType, postId, sessionId }) +export async function getPresignedUploadUrl(filename, contentType, postId, sessionId, purpose) { + logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType, postId, sessionId, purpose }) if (!isS3Configured()) { logger.error('S3', 'S3 not configured', null) @@ -60,13 +61,23 @@ export async function getPresignedUploadUrl(filename, contentType, postId, sessi throw new Error('S3 bucket name is not configured. Please set S3_BUCKET_NAME or AWS_BUCKET_NAME in .env file.') } + const isThumbnail = purpose === 'thumbnail' + if (isThumbnail && !postId) { + throw new Error('Post ID is required to upload a thumbnail. Save the post as draft first.') + } + // Extract file extension from filename or content type const ext = filename.split('.').pop() || contentType.split('/')[1] || 'jpg' - // Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts - const folderPrefix = postId - ? `blogs/${postId}/images` - : `blogs/draft/${sessionId || 'temp'}/images` - const key = `${folderPrefix}/${uuid()}.${ext}` + let key + if (isThumbnail) { + key = `blogs/${postId}/thumbnail.${ext}` + } else { + // Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts + const folderPrefix = postId + ? `blogs/${postId}/images` + : `blogs/draft/${sessionId || 'temp'}/images` + key = `${folderPrefix}/${uuid()}.${ext}` + } logger.s3('GENERATING_PRESIGNED_URL', { bucket: BUCKET_NAME, diff --git a/backend/migrations/add-thumbnail-and-excerpt.js b/backend/migrations/add-thumbnail-and-excerpt.js new file mode 100644 index 0000000..4979de0 --- /dev/null +++ b/backend/migrations/add-thumbnail-and-excerpt.js @@ -0,0 +1,28 @@ +import { pool } from '../config/database.js' +import dotenv from 'dotenv' + +dotenv.config() + +async function up() { + try { + console.log('Running add-thumbnail-and-excerpt migration...') + + await pool.query(` + ALTER TABLE posts + ADD COLUMN IF NOT EXISTS thumbnail_url TEXT NULL; + `) + + await pool.query(` + ALTER TABLE posts + ADD COLUMN IF NOT EXISTS excerpt VARCHAR(500) NULL; + `) + + console.log('✓ add-thumbnail-and-excerpt: thumbnail_url, excerpt added') + process.exit(0) + } catch (err) { + console.error('Migration failed:', err) + process.exit(1) + } +} + +up() diff --git a/backend/routes/posts.js b/backend/routes/posts.js index 3ba2fb4..780c39f 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -19,7 +19,7 @@ function isValidExternalUrl(url) { router.get('/', async (req, res) => { try { logger.transaction('FETCH_POSTS', { userId: req.user.id }) - const query = 'SELECT id, title, slug, status, content_type, external_url, created_at, updated_at FROM posts WHERE user_id = $1 ORDER BY updated_at DESC' + const query = 'SELECT id, title, slug, status, content_type, external_url, thumbnail_url, excerpt, created_at, updated_at FROM posts WHERE user_id = $1 ORDER BY updated_at DESC' logger.db('SELECT', query, [req.user.id]) const result = await pool.query(query, [req.user.id]) @@ -94,7 +94,7 @@ router.get('/slug/:slug', async (req, res) => { // Create post router.post('/', async (req, res) => { try { - const { title, content_json, content_type, external_url, status } = req.body + const { title, content_json, content_type, external_url, status, thumbnail_url, excerpt } = req.body logger.transaction('CREATE_POST', { userId: req.user.id, @@ -128,10 +128,22 @@ router.post('/', async (req, res) => { const contentJson = isLinkPost ? {} : content_json const externalUrl = isLinkPost ? external_url.trim() : null - const query = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url) - VALUES ($1, $2, $3, $4, $5, $6, $7) + const thumbnailUrl = thumbnail_url && typeof thumbnail_url === 'string' ? thumbnail_url.trim() || null : null + const excerptVal = excerpt != null && typeof excerpt === 'string' ? excerpt.trim().slice(0, 500) || null : null + + if (postStatus === 'published') { + if (!thumbnailUrl) { + return res.status(400).json({ message: 'Thumbnail is required to publish. Add a post thumbnail first.' }) + } + if (!excerptVal) { + return res.status(400).json({ message: 'List description (excerpt) is required to publish.' }) + } + } + + const query = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url, thumbnail_url, excerpt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *` - logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus, contentType, externalUrl]) + logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus, contentType, externalUrl, thumbnailUrl, excerptVal]) const result = await pool.query(query, [ req.user.id, @@ -140,7 +152,9 @@ router.post('/', async (req, res) => { slug, postStatus, contentType, - externalUrl + externalUrl, + thumbnailUrl, + excerptVal ]) logger.transaction('CREATE_POST_SUCCESS', { @@ -158,7 +172,7 @@ router.post('/', async (req, res) => { // Update post router.put('/:id', async (req, res) => { try { - const { title, content_json, content_type, external_url, status } = req.body + const { title, content_json, content_type, external_url, status, thumbnail_url, excerpt } = req.body logger.transaction('UPDATE_POST', { postId: req.params.id, @@ -168,17 +182,21 @@ router.put('/:id', async (req, res) => { content: content_json !== undefined, status: status !== undefined, content_type: content_type !== undefined, - external_url: external_url !== undefined + external_url: external_url !== undefined, + thumbnail_url: thumbnail_url !== undefined, + excerpt: excerpt !== undefined } }) - // Check if post exists and belongs to user - const checkQuery = 'SELECT id FROM posts WHERE id = $1 AND user_id = $2' + // Check if post exists and belongs to user (fetch thumbnail/excerpt when publishing) + const checkQuery = status === 'published' + ? 'SELECT id, thumbnail_url, excerpt FROM posts WHERE id = $1 AND user_id = $2' + : 'SELECT id FROM posts WHERE id = $1 AND user_id = $2' logger.db('SELECT', checkQuery, [req.params.id, req.user.id]) - - const existingPost = await pool.query(checkQuery, [req.params.id, req.user.id]) + const existingResult = await pool.query(checkQuery, [req.params.id, req.user.id]) + const existingPost = existingResult.rows[0] - if (existingPost.rows.length === 0) { + if (!existingPost) { logger.warn('POSTS', 'Post not found for update', { postId: req.params.id, userId: req.user.id @@ -194,6 +212,22 @@ router.put('/:id', async (req, res) => { return res.status(400).json({ message: 'external_url is required when content_type is link' }) } + // When publishing, require thumbnail and excerpt (use existing if not in body) + if (status === 'published') { + const finalThumbnail = thumbnail_url !== undefined + ? (thumbnail_url && typeof thumbnail_url === 'string' ? thumbnail_url.trim() || null : null) + : (existingPost.thumbnail_url ?? null) + const finalExcerpt = excerpt !== undefined + ? (excerpt != null && typeof excerpt === 'string' ? excerpt.trim().slice(0, 500) || null : null) + : (existingPost.excerpt ?? null) + if (!finalThumbnail) { + return res.status(400).json({ message: 'Thumbnail is required to publish. Add a post thumbnail first.' }) + } + if (!finalExcerpt) { + return res.status(400).json({ message: 'List description (excerpt) is required to publish.' }) + } + } + // Build update query dynamically const updates = [] const values = [] @@ -235,6 +269,16 @@ router.put('/:id', async (req, res) => { values.push(slug) } + if (thumbnail_url !== undefined) { + updates.push(`thumbnail_url = $${paramCount++}`) + values.push(thumbnail_url && typeof thumbnail_url === 'string' ? thumbnail_url.trim() || null : null) + } + + if (excerpt !== undefined) { + updates.push(`excerpt = $${paramCount++}`) + values.push(excerpt != null && typeof excerpt === 'string' ? excerpt.trim().slice(0, 500) || null : null) + } + updates.push(`updated_at = NOW()`) values.push(req.params.id, req.user.id) diff --git a/backend/routes/upload.js b/backend/routes/upload.js index bc247db..23b5111 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -24,14 +24,15 @@ router.get('/media', async (req, res) => { // Note: authenticateToken middleware is applied at server level router.post('/presigned-url', async (req, res) => { try { - const { filename, contentType, postId, sessionId } = req.body + const { filename, contentType, postId, sessionId, purpose } = req.body logger.transaction('GENERATE_PRESIGNED_URL', { userId: req.user.id, filename, contentType, postId, - sessionId + sessionId, + purpose }) if (!filename || !contentType) { @@ -42,6 +43,13 @@ router.post('/presigned-url', async (req, res) => { return res.status(400).json({ message: 'Filename and content type are required' }) } + // Thumbnail upload requires postId (post must be saved first) + if (purpose === 'thumbnail' && !postId) { + return res.status(400).json({ + message: 'Post ID is required to upload a thumbnail. Save the post as draft first.' + }) + } + // Validate content type if (!contentType.startsWith('image/')) { logger.warn('UPLOAD', 'Invalid content type', { contentType }) @@ -66,7 +74,7 @@ router.post('/presigned-url', async (req, res) => { } const startTime = Date.now() - const { uploadUrl, imageUrl, key } = await getPresignedUploadUrl(filename, contentType, postId, sessionId) + const { uploadUrl, imageUrl, key } = await getPresignedUploadUrl(filename, contentType, postId, sessionId, purpose) const duration = Date.now() - startTime logger.s3('PRESIGNED_URL_GENERATED', { diff --git a/frontend/src/pages/Editor.jsx b/frontend/src/pages/Editor.jsx index c237d2a..e5f3d30 100644 --- a/frontend/src/pages/Editor.jsx +++ b/frontend/src/pages/Editor.jsx @@ -13,8 +13,12 @@ export default function EditorPage() { const [createdAt, setCreatedAt] = useState(null) const [contentType, setContentType] = useState('tiptap') // 'tiptap' | 'link' const [externalUrl, setExternalUrl] = useState('') + const [thumbnailUrl, setThumbnailUrl] = useState('') + const [excerpt, setExcerpt] = useState('') const [loading, setLoading] = useState(!!id) const [saving, setSaving] = useState(false) + const [uploadingThumbnail, setUploadingThumbnail] = useState(false) + const thumbnailInputRef = useRef(null) const [showPreview, setShowPreview] = useState(false) const autoSaveTimeoutRef = useRef(null) const isInitialLoadRef = useRef(true) @@ -41,6 +45,8 @@ export default function EditorPage() { const base = { title: title?.trim() || 'Untitled', status: overrides.status ?? 'draft', + thumbnail_url: thumbnailUrl?.trim() || null, + excerpt: excerpt?.trim()?.slice(0, 250) || null, } if (isLink) { return { @@ -56,7 +62,7 @@ export default function EditorPage() { content_json: content || {}, ...overrides, } - }, [title, content, contentType, externalUrl]) + }, [title, content, contentType, externalUrl, thumbnailUrl, excerpt]) // Debounced auto-save function const handleAutoSave = useCallback(async () => { @@ -111,7 +117,7 @@ export default function EditorPage() { clearTimeout(autoSaveTimeoutRef.current) } } - }, [title, content, contentType, externalUrl, handleAutoSave]) + }, [title, content, contentType, externalUrl, thumbnailUrl, excerpt, handleAutoSave]) const fetchPost = async () => { try { @@ -122,6 +128,8 @@ export default function EditorPage() { setCreatedAt(post.created_at || null) setContentType(post.content_type === 'link' ? 'link' : 'tiptap') setExternalUrl(post.external_url || '') + setThumbnailUrl(post.thumbnail_url || '') + setExcerpt(post.excerpt || '') isInitialLoadRef.current = true // Reset after loading } catch (error) { toast.error('Failed to load post') @@ -135,12 +143,72 @@ export default function EditorPage() { } } + const postIdForThumbnail = id || currentPostIdRef.current + + const handleThumbnailUpload = async (file) => { + if (!file?.type?.startsWith('image/')) { + toast.error('Please select an image file') + return + } + if (file.size > 5 * 1024 * 1024) { + toast.error('Image must be under 5MB') + return + } + if (!postIdForThumbnail) { + toast.error('Save draft first to add a thumbnail') + return + } + try { + setUploadingThumbnail(true) + toast.loading('Uploading thumbnail...', { id: 'thumbnail' }) + const res = await api.post('/upload/presigned-url', { + filename: file.name, + contentType: file.type, + postId: postIdForThumbnail, + purpose: 'thumbnail', + }) + const { uploadUrl, imageUrl } = res.data + const putRes = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }) + if (!putRes.ok) { + throw new Error('Upload failed') + } + setThumbnailUrl(imageUrl) + await api.put(`/posts/${postIdForThumbnail}`, buildPostData({ thumbnail_url: imageUrl })) + toast.success('Thumbnail saved', { id: 'thumbnail' }) + } catch (err) { + toast.error(err.response?.data?.message || err.message || 'Failed to upload thumbnail', { id: 'thumbnail' }) + } finally { + setUploadingThumbnail(false) + } + } + + const handleRemoveThumbnail = () => { + setThumbnailUrl('') + if (postIdForThumbnail) { + api.put(`/posts/${postIdForThumbnail}`, { ...buildPostData(), thumbnail_url: null }).catch(() => {}) + } + } + const handlePublish = async () => { if (!title.trim()) { toast.error('Please enter a title') return } + if (!thumbnailUrl?.trim()) { + toast.error('Please add a post thumbnail before publishing') + return + } + + if (!excerpt?.trim()) { + toast.error('Please add a list description before publishing') + return + } + if (contentType === 'link') { const url = externalUrl.trim() if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { @@ -263,7 +331,7 @@ export default function EditorPage() { {/* Card-style editor block */}
Save draft first to add a thumbnail. Required for publishing.
+ ) : ( +Square image recommended. Required for publishing.
+