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} [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'
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`
const key = `${folderPrefix}/${uuid()}.${ext}`
key = `${folderPrefix}/${uuid()}.${ext}`
}
logger.s3('GENERATING_PRESIGNED_URL', {
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) => {
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 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.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)

View File

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

View File

@ -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 */}
<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">
<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
type="text"
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"
/>
</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' ? (
<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>