Blog post thumnaiul addition
This commit is contained in:
parent
f29c929717
commit
cb8b768d3c
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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 (1–2 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue