Blog post thumnaiul addition

This commit is contained in:
chandresh 2026-02-11 01:43:40 +05:30
parent f29c929717
commit cb8b768d3c
5 changed files with 243 additions and 26 deletions

View File

@ -41,9 +41,10 @@ export { ListObjectsV2Command }
* @param {string} contentType * @param {string} contentType
* @param {string} [postId] - Blog post ID for per-blog folder structure * @param {string} [postId] - Blog post ID for per-blog folder structure
* @param {string} [sessionId] - Session ID for draft posts (no postId yet) * @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) { export async function getPresignedUploadUrl(filename, contentType, postId, sessionId, purpose) {
logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType, postId, sessionId }) logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType, postId, sessionId, purpose })
if (!isS3Configured()) { if (!isS3Configured()) {
logger.error('S3', 'S3 not configured', null) 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.') 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 // 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'
let key
if (isThumbnail) {
key = `blogs/${postId}/thumbnail.${ext}`
} else {
// Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts // Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts
const folderPrefix = postId const folderPrefix = postId
? `blogs/${postId}/images` ? `blogs/${postId}/images`
: `blogs/draft/${sessionId || 'temp'}/images` : `blogs/draft/${sessionId || 'temp'}/images`
const key = `${folderPrefix}/${uuid()}.${ext}` key = `${folderPrefix}/${uuid()}.${ext}`
}
logger.s3('GENERATING_PRESIGNED_URL', { logger.s3('GENERATING_PRESIGNED_URL', {
bucket: BUCKET_NAME, bucket: BUCKET_NAME,

View File

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

View File

@ -19,7 +19,7 @@ function isValidExternalUrl(url) {
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
logger.transaction('FETCH_POSTS', { userId: req.user.id }) 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]) logger.db('SELECT', query, [req.user.id])
const result = await pool.query(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 // Create post
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { 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', { logger.transaction('CREATE_POST', {
userId: req.user.id, userId: req.user.id,
@ -128,10 +128,22 @@ router.post('/', async (req, res) => {
const contentJson = isLinkPost ? {} : content_json const contentJson = isLinkPost ? {} : content_json
const externalUrl = isLinkPost ? external_url.trim() : null const externalUrl = isLinkPost ? external_url.trim() : null
const query = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url) const thumbnailUrl = thumbnail_url && typeof thumbnail_url === 'string' ? thumbnail_url.trim() || null : null
VALUES ($1, $2, $3, $4, $5, $6, $7) 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 *` 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, [ const result = await pool.query(query, [
req.user.id, req.user.id,
@ -140,7 +152,9 @@ router.post('/', async (req, res) => {
slug, slug,
postStatus, postStatus,
contentType, contentType,
externalUrl externalUrl,
thumbnailUrl,
excerptVal
]) ])
logger.transaction('CREATE_POST_SUCCESS', { logger.transaction('CREATE_POST_SUCCESS', {
@ -158,7 +172,7 @@ router.post('/', async (req, res) => {
// Update post // Update post
router.put('/:id', async (req, res) => { router.put('/:id', async (req, res) => {
try { 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', { logger.transaction('UPDATE_POST', {
postId: req.params.id, postId: req.params.id,
@ -168,17 +182,21 @@ router.put('/:id', async (req, res) => {
content: content_json !== undefined, content: content_json !== undefined,
status: status !== undefined, status: status !== undefined,
content_type: content_type !== 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 // Check if post exists and belongs to user (fetch thumbnail/excerpt when publishing)
const checkQuery = 'SELECT id FROM posts WHERE id = $1 AND user_id = $2' 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]) logger.db('SELECT', checkQuery, [req.params.id, req.user.id])
const existingResult = await pool.query(checkQuery, [req.params.id, req.user.id])
const existingPost = existingResult.rows[0]
const existingPost = await pool.query(checkQuery, [req.params.id, req.user.id]) if (!existingPost) {
if (existingPost.rows.length === 0) {
logger.warn('POSTS', 'Post not found for update', { logger.warn('POSTS', 'Post not found for update', {
postId: req.params.id, postId: req.params.id,
userId: req.user.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' }) 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 // Build update query dynamically
const updates = [] const updates = []
const values = [] const values = []
@ -235,6 +269,16 @@ router.put('/:id', async (req, res) => {
values.push(slug) 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()`) updates.push(`updated_at = NOW()`)
values.push(req.params.id, req.user.id) values.push(req.params.id, req.user.id)

View File

@ -24,14 +24,15 @@ router.get('/media', async (req, res) => {
// 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, postId, sessionId } = req.body const { filename, contentType, postId, sessionId, purpose } = 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, postId,
sessionId sessionId,
purpose
}) })
if (!filename || !contentType) { 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' }) 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 // Validate content type
if (!contentType.startsWith('image/')) { if (!contentType.startsWith('image/')) {
logger.warn('UPLOAD', 'Invalid content type', { contentType }) logger.warn('UPLOAD', 'Invalid content type', { contentType })
@ -66,7 +74,7 @@ router.post('/presigned-url', async (req, res) => {
} }
const startTime = Date.now() 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 const duration = Date.now() - startTime
logger.s3('PRESIGNED_URL_GENERATED', { logger.s3('PRESIGNED_URL_GENERATED', {

View File

@ -13,8 +13,12 @@ export default function EditorPage() {
const [createdAt, setCreatedAt] = useState(null) const [createdAt, setCreatedAt] = useState(null)
const [contentType, setContentType] = useState('tiptap') // 'tiptap' | 'link' const [contentType, setContentType] = useState('tiptap') // 'tiptap' | 'link'
const [externalUrl, setExternalUrl] = useState('') const [externalUrl, setExternalUrl] = useState('')
const [thumbnailUrl, setThumbnailUrl] = useState('')
const [excerpt, setExcerpt] = useState('')
const [loading, setLoading] = useState(!!id) const [loading, setLoading] = useState(!!id)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploadingThumbnail, setUploadingThumbnail] = useState(false)
const thumbnailInputRef = useRef(null)
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const autoSaveTimeoutRef = useRef(null) const autoSaveTimeoutRef = useRef(null)
const isInitialLoadRef = useRef(true) const isInitialLoadRef = useRef(true)
@ -41,6 +45,8 @@ export default function EditorPage() {
const base = { const base = {
title: title?.trim() || 'Untitled', title: title?.trim() || 'Untitled',
status: overrides.status ?? 'draft', status: overrides.status ?? 'draft',
thumbnail_url: thumbnailUrl?.trim() || null,
excerpt: excerpt?.trim()?.slice(0, 250) || null,
} }
if (isLink) { if (isLink) {
return { return {
@ -56,7 +62,7 @@ export default function EditorPage() {
content_json: content || {}, content_json: content || {},
...overrides, ...overrides,
} }
}, [title, content, contentType, externalUrl]) }, [title, content, contentType, externalUrl, thumbnailUrl, excerpt])
// Debounced auto-save function // Debounced auto-save function
const handleAutoSave = useCallback(async () => { const handleAutoSave = useCallback(async () => {
@ -111,7 +117,7 @@ export default function EditorPage() {
clearTimeout(autoSaveTimeoutRef.current) clearTimeout(autoSaveTimeoutRef.current)
} }
} }
}, [title, content, contentType, externalUrl, handleAutoSave]) }, [title, content, contentType, externalUrl, thumbnailUrl, excerpt, handleAutoSave])
const fetchPost = async () => { const fetchPost = async () => {
try { try {
@ -122,6 +128,8 @@ export default function EditorPage() {
setCreatedAt(post.created_at || null) setCreatedAt(post.created_at || null)
setContentType(post.content_type === 'link' ? 'link' : 'tiptap') setContentType(post.content_type === 'link' ? 'link' : 'tiptap')
setExternalUrl(post.external_url || '') setExternalUrl(post.external_url || '')
setThumbnailUrl(post.thumbnail_url || '')
setExcerpt(post.excerpt || '')
isInitialLoadRef.current = true // Reset after loading isInitialLoadRef.current = true // Reset after loading
} catch (error) { } catch (error) {
toast.error('Failed to load post') 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 () => { const handlePublish = async () => {
if (!title.trim()) { if (!title.trim()) {
toast.error('Please enter a title') toast.error('Please enter a title')
return 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') { if (contentType === 'link') {
const url = externalUrl.trim() const url = externalUrl.trim()
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) {
@ -263,7 +331,7 @@ export default function EditorPage() {
{/* Card-style editor block */} {/* Card-style editor block */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-visible"> <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-visible">
<div className="px-4 sm:px-6 pt-4 sm:pt-6 pb-2"> <div className="px-4 sm:px-6 pt-4 sm:pt-6 pb-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title (required)</label>
<input <input
type="text" type="text"
placeholder="Enter post title..." placeholder="Enter post title..."
@ -272,6 +340,64 @@ export default function EditorPage() {
className="w-full text-xl sm: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" className="w-full text-xl sm:text-2xl font-bold p-2 border-0 border-b-2 border-transparent hover:border-gray-200 focus:outline-none focus:border-indigo-500 bg-transparent transition-colors"
/> />
</div> </div>
{/* Post thumbnail - below title, only when post exists */}
<div className="px-4 sm:px-6 py-4 border-t border-gray-100">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Post thumbnail (required)</label>
{!postIdForThumbnail ? (
<p className="text-sm text-gray-500 py-2">Save draft first to add a thumbnail. Required for publishing.</p>
) : (
<div className="flex flex-col sm:flex-row gap-4 items-start">
{thumbnailUrl ? (
<div className="relative flex-shrink-0">
<img src={thumbnailUrl} alt="Thumbnail" className="w-24 h-24 sm:w-28 sm:h-28 object-cover rounded-lg border border-gray-200" />
<button
type="button"
onClick={handleRemoveThumbnail}
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-red-500 text-white text-xs flex items-center justify-center hover:bg-red-600"
aria-label="Remove thumbnail"
>
×
</button>
</div>
) : null}
<div className="flex flex-col gap-1">
<input
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) handleThumbnailUpload(f)
e.target.value = ''
}}
/>
<button
type="button"
disabled={uploadingThumbnail}
onClick={() => thumbnailInputRef.current?.click()}
className="px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{uploadingThumbnail ? 'Uploading…' : thumbnailUrl ? 'Change thumbnail' : 'Choose image'}
</button>
<p className="text-xs text-gray-500">Square image recommended. Required for publishing.</p>
</div>
</div>
)}
</div>
{/* Optional excerpt for list description */}
<div className="px-4 sm:px-6 pb-4 border-t border-gray-100">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">List description (required)</label>
<textarea
placeholder="Short description for the blog list (12 lines). Required for publishing."
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
rows={2}
maxLength={250}
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm resize-none"
/>
<p className="mt-1 text-xs text-gray-500">{excerpt.length}/250</p>
</div>
{contentType === 'link' ? ( {contentType === 'link' ? (
<div className="px-4 sm:px-6 pb-6"> <div className="px-4 sm:px-6 pb-6">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">URL</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">URL</label>