Blog post thumnaiul addition
This commit is contained in:
parent
f29c929717
commit
cb8b768d3c
|
|
@ -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'
|
||||||
// Per-blog folder: blogs/{postId}/images/ or blogs/draft/{sessionId}/images/ for new posts
|
let key
|
||||||
const folderPrefix = postId
|
if (isThumbnail) {
|
||||||
? `blogs/${postId}/images`
|
key = `blogs/${postId}/thumbnail.${ext}`
|
||||||
: `blogs/draft/${sessionId || 'temp'}/images`
|
} else {
|
||||||
const key = `${folderPrefix}/${uuid()}.${ext}`
|
// 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', {
|
logger.s3('GENERATING_PRESIGNED_URL', {
|
||||||
bucket: BUCKET_NAME,
|
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) => {
|
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 = 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', {
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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 (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' ? (
|
{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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue