From 26a617e1d48d30085a03b3747dce609ad8257ad0 Mon Sep 17 00:00:00 2001 From: chandresh Date: Wed, 11 Feb 2026 22:18:36 +0530 Subject: [PATCH] updated for languagees --- backend/migrations/add-language-column.js | 49 ++ backend/routes/posts.js | 419 +++++++++- frontend/src/pages/Dashboard.jsx | 259 ++++-- frontend/src/pages/Editor.jsx | 972 +++++++++++++++++----- 4 files changed, 1385 insertions(+), 314 deletions(-) create mode 100644 backend/migrations/add-language-column.js diff --git a/backend/migrations/add-language-column.js b/backend/migrations/add-language-column.js new file mode 100644 index 0000000..45319f5 --- /dev/null +++ b/backend/migrations/add-language-column.js @@ -0,0 +1,49 @@ +import { pool } from '../config/database.js' +import dotenv from 'dotenv' + +dotenv.config() + +async function up() { + try { + console.log('Running add-language-column migration...') + + // Add language column + await pool.query(` + ALTER TABLE posts + ADD COLUMN IF NOT EXISTS language VARCHAR(10) DEFAULT 'en' NOT NULL; + `) + console.log('✓ Added language column') + + // Add post_group_id to link English and Hindi versions + await pool.query(` + ALTER TABLE posts + ADD COLUMN IF NOT EXISTS post_group_id UUID NULL; + `) + console.log('✓ Added post_group_id column') + + // Create indexes + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_posts_language ON posts(language); + `) + console.log('✓ Created language index') + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_posts_group_id ON posts(post_group_id); + `) + console.log('✓ Created post_group_id index') + + // Backfill existing posts to English + await pool.query(` + UPDATE posts SET language = 'en' WHERE language IS NULL; + `) + console.log('✓ Backfilled existing posts with language=en') + + console.log('✓ add-language-column migration completed successfully') + 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 780c39f..68d263d 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, thumbnail_url, excerpt, 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, language, post_group_id, 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,13 +94,14 @@ router.get('/slug/:slug', async (req, res) => { // Create post router.post('/', async (req, res) => { try { - const { title, content_json, content_type, external_url, status, thumbnail_url, excerpt } = req.body + const { title, content_json, content_type, external_url, status, thumbnail_url, excerpt, language } = req.body logger.transaction('CREATE_POST', { userId: req.user.id, title: title?.substring(0, 50), status: status || 'draft', - content_type: content_type || 'tiptap' + content_type: content_type || 'tiptap', + language: language || 'en' }) const isLinkPost = content_type === 'link' @@ -127,6 +128,7 @@ router.post('/', async (req, res) => { const contentType = isLinkPost ? 'link' : (content_type || 'tiptap') const contentJson = isLinkPost ? {} : content_json const externalUrl = isLinkPost ? external_url.trim() : null + const postLanguage = language || 'en' 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 @@ -140,10 +142,10 @@ router.post('/', async (req, res) => { } } - 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) + const query = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url, thumbnail_url, excerpt, language) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *` - logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus, contentType, externalUrl, thumbnailUrl, excerptVal]) + logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus, contentType, externalUrl, thumbnailUrl, excerptVal, postLanguage]) const result = await pool.query(query, [ req.user.id, @@ -154,13 +156,15 @@ router.post('/', async (req, res) => { contentType, externalUrl, thumbnailUrl, - excerptVal + excerptVal, + postLanguage ]) logger.transaction('CREATE_POST_SUCCESS', { postId: result.rows[0].id, userId: req.user.id, - slug: result.rows[0].slug + slug: result.rows[0].slug, + language: postLanguage }) res.status(201).json(result.rows[0]) } catch (error) { @@ -172,7 +176,7 @@ router.post('/', async (req, res) => { // Update post router.put('/:id', async (req, res) => { try { - const { title, content_json, content_type, external_url, status, thumbnail_url, excerpt } = req.body + const { title, content_json, content_type, external_url, status, thumbnail_url, excerpt, language } = req.body logger.transaction('UPDATE_POST', { postId: req.params.id, @@ -184,7 +188,8 @@ router.put('/:id', async (req, res) => { content_type: content_type !== undefined, external_url: external_url !== undefined, thumbnail_url: thumbnail_url !== undefined, - excerpt: excerpt !== undefined + excerpt: excerpt !== undefined, + language: language !== undefined } }) @@ -279,6 +284,11 @@ router.put('/:id', async (req, res) => { values.push(excerpt != null && typeof excerpt === 'string' ? excerpt.trim().slice(0, 500) || null : null) } + if (language !== undefined) { + updates.push(`language = $${paramCount++}`) + values.push(language || 'en') + } + updates.push(`updated_at = NOW()`) values.push(req.params.id, req.user.id) @@ -330,4 +340,393 @@ router.delete('/:id', async (req, res) => { } }) +// Get post group by post_group_id (returns both EN and HI versions) +router.get('/group/:post_group_id', async (req, res) => { + try { + logger.transaction('FETCH_POST_GROUP', { + postGroupId: req.params.post_group_id, + userId: req.user.id + }) + const query = 'SELECT * FROM posts WHERE post_group_id = $1 AND user_id = $2 ORDER BY language' + logger.db('SELECT', query, [req.params.post_group_id, req.user.id]) + + const result = await pool.query(query, [req.params.post_group_id, req.user.id]) + + if (result.rows.length === 0) { + logger.warn('POSTS', 'Post group not found', { + postGroupId: req.params.post_group_id, + userId: req.user.id + }) + return res.status(404).json({ message: 'Post group not found' }) + } + + // Organize by language + const posts = { + post_group_id: req.params.post_group_id, + english: result.rows.find(p => p.language === 'en') || null, + hindi: result.rows.find(p => p.language === 'hi') || null + } + + logger.transaction('FETCH_POST_GROUP_SUCCESS', { + postGroupId: req.params.post_group_id, + userId: req.user.id + }) + res.json(posts) + } catch (error) { + logger.error('POSTS', 'Error fetching post group', error) + res.status(500).json({ message: 'Failed to fetch post group', error: error.message }) + } +}) + +// Create dual-language post (creates both EN and HI posts together) +router.post('/dual-language', async (req, res) => { + try { + const { + // English fields + title_en, excerpt_en, content_json_en, external_url_en, status_en, + // Hindi fields + title_hi, excerpt_hi, content_json_hi, external_url_hi, status_hi, + // Shared fields + content_type, thumbnail_url, status + } = req.body + + // Support both old format (status) and new format (status_en/status_hi) + const enStatus = status_en || status || 'draft' + const hiStatus = status_hi || status || 'draft' + + logger.transaction('CREATE_DUAL_LANGUAGE_POST', { + userId: req.user.id, + status_en: enStatus, + status_hi: hiStatus, + content_type: content_type || 'tiptap' + }) + + const isLinkPost = content_type === 'link' + + // Validation for publishing English + if (enStatus === 'published') { + if (!title_en?.trim() || !excerpt_en?.trim()) { + return res.status(400).json({ message: 'English title and excerpt required to publish' }) + } + if (isLinkPost && !external_url_en?.trim()) { + return res.status(400).json({ message: 'English URL required for link posts' }) + } + if (isLinkPost && !isValidExternalUrl(external_url_en)) { + return res.status(400).json({ message: 'Valid English external URL is required' }) + } + if (!isLinkPost && !content_json_en) { + return res.status(400).json({ message: 'English content required' }) + } + if (!thumbnail_url?.trim()) { + return res.status(400).json({ message: 'Thumbnail required to publish' }) + } + } + + // Validation for publishing Hindi + if (hiStatus === 'published') { + if (!title_hi?.trim() || !excerpt_hi?.trim()) { + return res.status(400).json({ message: 'Hindi title and excerpt required to publish' }) + } + if (isLinkPost && !external_url_hi?.trim()) { + return res.status(400).json({ message: 'Hindi URL required for link posts' }) + } + if (isLinkPost && !isValidExternalUrl(external_url_hi)) { + return res.status(400).json({ message: 'Valid Hindi external URL is required' }) + } + if (!isLinkPost && !content_json_hi) { + return res.status(400).json({ message: 'Hindi content required' }) + } + if (!thumbnail_url?.trim()) { + return res.status(400).json({ message: 'Thumbnail required to publish' }) + } + } + + // Generate shared post_group_id using crypto + const { randomUUID } = await import('crypto') + const postGroupId = randomUUID() + + const contentType = isLinkPost ? 'link' : (content_type || 'tiptap') + const thumbnailUrlVal = thumbnail_url && typeof thumbnail_url === 'string' ? thumbnail_url.trim() || null : null + + // Create English post + const slugEn = slugify(title_en || 'untitled', { lower: true, strict: true }) + '-' + Date.now() + const excerptEnVal = excerpt_en != null && typeof excerpt_en === 'string' ? excerpt_en.trim().slice(0, 500) || null : null + const contentEnVal = isLinkPost ? {} : (content_json_en || {}) + const externalUrlEnVal = isLinkPost && external_url_en ? external_url_en.trim() : null + + const enQuery = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url, thumbnail_url, excerpt, language, post_group_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *` + logger.db('INSERT', enQuery, [req.user.id, title_en, '[content_json]', slugEn, enStatus, contentType, externalUrlEnVal, thumbnailUrlVal, excerptEnVal, 'en', postGroupId]) + + const enResult = await pool.query(enQuery, [ + req.user.id, + title_en || 'Untitled', + JSON.stringify(contentEnVal), + slugEn, + enStatus, + contentType, + externalUrlEnVal, + thumbnailUrlVal, + excerptEnVal, + 'en', + postGroupId + ]) + + // Create Hindi post + const slugHi = slugify(title_hi || 'untitled', { lower: true, strict: true }) + '-' + Date.now() + '-hi' + const excerptHiVal = excerpt_hi != null && typeof excerpt_hi === 'string' ? excerpt_hi.trim().slice(0, 500) || null : null + const contentHiVal = isLinkPost ? {} : (content_json_hi || {}) + const externalUrlHiVal = isLinkPost && external_url_hi ? external_url_hi.trim() : null + + const hiQuery = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url, thumbnail_url, excerpt, language, post_group_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *` + logger.db('INSERT', hiQuery, [req.user.id, title_hi, '[content_json]', slugHi, hiStatus, contentType, externalUrlHiVal, thumbnailUrlVal, excerptHiVal, 'hi', postGroupId]) + + const hiResult = await pool.query(hiQuery, [ + req.user.id, + title_hi || 'शीर्षकहीन', + JSON.stringify(contentHiVal), + slugHi, + hiStatus, + contentType, + externalUrlHiVal, + thumbnailUrlVal, + excerptHiVal, + 'hi', + postGroupId + ]) + + logger.transaction('CREATE_DUAL_LANGUAGE_POST_SUCCESS', { + postGroupId: postGroupId, + userId: req.user.id, + englishId: enResult.rows[0].id, + hindiId: hiResult.rows[0].id + }) + + res.status(201).json({ + post_group_id: postGroupId, + english: enResult.rows[0], + hindi: hiResult.rows[0] + }) + } catch (error) { + logger.error('POSTS', 'Error creating dual-language post', error) + res.status(500).json({ message: 'Failed to create posts', error: error.message }) + } +}) + +// Update dual-language post (updates both EN and HI posts) +router.put('/dual-language/:post_group_id', async (req, res) => { + try { + const { + // English fields + title_en, excerpt_en, content_json_en, external_url_en, status_en, + // Hindi fields + title_hi, excerpt_hi, content_json_hi, external_url_hi, status_hi, + // Shared fields + content_type, thumbnail_url, status + } = req.body + + // Support both old format (status) and new format (status_en/status_hi) + const enStatus = status_en !== undefined ? status_en : status + const hiStatus = status_hi !== undefined ? status_hi : status + + logger.transaction('UPDATE_DUAL_LANGUAGE_POST', { + postGroupId: req.params.post_group_id, + userId: req.user.id, + status_en: enStatus, + status_hi: hiStatus + }) + + // Check if posts exist and belong to user + const checkQuery = 'SELECT id, language, thumbnail_url, excerpt, title FROM posts WHERE post_group_id = $1 AND user_id = $2' + logger.db('SELECT', checkQuery, [req.params.post_group_id, req.user.id]) + const existingResult = await pool.query(checkQuery, [req.params.post_group_id, req.user.id]) + + if (existingResult.rows.length === 0) { + logger.warn('POSTS', 'Post group not found for update', { + postGroupId: req.params.post_group_id, + userId: req.user.id + }) + return res.status(404).json({ message: 'Post group not found' }) + } + + const existingEn = existingResult.rows.find(p => p.language === 'en') + const existingHi = existingResult.rows.find(p => p.language === 'hi') + + const isLinkUpdate = content_type === 'link' + + // Validation for publishing English + if (enStatus === 'published') { + const finalTitleEn = title_en !== undefined ? title_en : existingEn?.title + const finalExcerptEn = excerpt_en !== undefined ? excerpt_en : existingEn?.excerpt + if (!finalTitleEn?.trim() || !finalExcerptEn?.trim()) { + return res.status(400).json({ message: 'English title and excerpt required to publish' }) + } + if (isLinkUpdate) { + const finalUrlEn = external_url_en !== undefined ? external_url_en : null + if (!finalUrlEn?.trim() || !isValidExternalUrl(finalUrlEn)) { + return res.status(400).json({ message: 'Valid English URL required for link posts' }) + } + } else if (content_json_en === undefined && !existingEn?.content_json) { + return res.status(400).json({ message: 'English content required' }) + } + const finalThumbnail = thumbnail_url !== undefined ? thumbnail_url : existingEn?.thumbnail_url + if (!finalThumbnail?.trim()) { + return res.status(400).json({ message: 'Thumbnail required to publish' }) + } + } + + // Validation for publishing Hindi + if (hiStatus === 'published') { + const finalTitleHi = title_hi !== undefined ? title_hi : existingHi?.title + const finalExcerptHi = excerpt_hi !== undefined ? excerpt_hi : existingHi?.excerpt + if (!finalTitleHi?.trim() || !finalExcerptHi?.trim()) { + return res.status(400).json({ message: 'Hindi title and excerpt required to publish' }) + } + if (isLinkUpdate) { + const finalUrlHi = external_url_hi !== undefined ? external_url_hi : null + if (!finalUrlHi?.trim() || !isValidExternalUrl(finalUrlHi)) { + return res.status(400).json({ message: 'Valid Hindi URL required for link posts' }) + } + } else if (content_json_hi === undefined && !existingHi?.content_json) { + return res.status(400).json({ message: 'Hindi content required' }) + } + const finalThumbnail = thumbnail_url !== undefined ? thumbnail_url : existingHi?.thumbnail_url + if (!finalThumbnail?.trim()) { + return res.status(400).json({ message: 'Thumbnail required to publish' }) + } + } + + // Update English post + if (existingEn) { + const updatesEn = [] + const valuesEn = [] + let paramCount = 1 + + if (title_en !== undefined) { + updatesEn.push(`title = $${paramCount++}`) + valuesEn.push(title_en || 'Untitled') + // Update slug if title changed + const slugEn = slugify(title_en || 'untitled', { lower: true, strict: true }) + '-' + Date.now() + updatesEn.push(`slug = $${paramCount++}`) + valuesEn.push(slugEn) + } + + if (content_json_en !== undefined) { + updatesEn.push(`content_json = $${paramCount++}`) + valuesEn.push(JSON.stringify(content_json_en)) + } + + if (enStatus !== undefined) { + updatesEn.push(`status = $${paramCount++}`) + valuesEn.push(enStatus) + } + + if (content_type !== undefined) { + updatesEn.push(`content_type = $${paramCount++}`) + valuesEn.push(content_type) + } + + if (external_url_en !== undefined) { + updatesEn.push(`external_url = $${paramCount++}`) + valuesEn.push(external_url_en?.trim() || null) + } + + if (thumbnail_url !== undefined) { + updatesEn.push(`thumbnail_url = $${paramCount++}`) + valuesEn.push(thumbnail_url?.trim() || null) + } + + if (excerpt_en !== undefined) { + updatesEn.push(`excerpt = $${paramCount++}`) + valuesEn.push(excerpt_en?.trim()?.slice(0, 500) || null) + } + + if (updatesEn.length > 0) { + updatesEn.push(`updated_at = NOW()`) + valuesEn.push(existingEn.id) + + const updateQueryEn = `UPDATE posts SET ${updatesEn.join(', ')} WHERE id = $${paramCount} RETURNING *` + logger.db('UPDATE', updateQueryEn, valuesEn) + await pool.query(updateQueryEn, valuesEn) + } + } + + // Update Hindi post + if (existingHi) { + const updatesHi = [] + const valuesHi = [] + let paramCount = 1 + + if (title_hi !== undefined) { + updatesHi.push(`title = $${paramCount++}`) + valuesHi.push(title_hi || 'शीर्षकहीन') + // Update slug if title changed + const slugHi = slugify(title_hi || 'untitled', { lower: true, strict: true }) + '-' + Date.now() + '-hi' + updatesHi.push(`slug = $${paramCount++}`) + valuesHi.push(slugHi) + } + + if (content_json_hi !== undefined) { + updatesHi.push(`content_json = $${paramCount++}`) + valuesHi.push(JSON.stringify(content_json_hi)) + } + + if (hiStatus !== undefined) { + updatesHi.push(`status = $${paramCount++}`) + valuesHi.push(hiStatus) + } + + if (content_type !== undefined) { + updatesHi.push(`content_type = $${paramCount++}`) + valuesHi.push(content_type) + } + + if (external_url_hi !== undefined) { + updatesHi.push(`external_url = $${paramCount++}`) + valuesHi.push(external_url_hi?.trim() || null) + } + + if (thumbnail_url !== undefined) { + updatesHi.push(`thumbnail_url = $${paramCount++}`) + valuesHi.push(thumbnail_url?.trim() || null) + } + + if (excerpt_hi !== undefined) { + updatesHi.push(`excerpt = $${paramCount++}`) + valuesHi.push(excerpt_hi?.trim()?.slice(0, 500) || null) + } + + if (updatesHi.length > 0) { + updatesHi.push(`updated_at = NOW()`) + valuesHi.push(existingHi.id) + + const updateQueryHi = `UPDATE posts SET ${updatesHi.join(', ')} WHERE id = $${paramCount} RETURNING *` + logger.db('UPDATE', updateQueryHi, valuesHi) + await pool.query(updateQueryHi, valuesHi) + } + } + + // Fetch updated posts + const result = await pool.query('SELECT * FROM posts WHERE post_group_id = $1 AND user_id = $2 ORDER BY language', + [req.params.post_group_id, req.user.id]) + + logger.transaction('UPDATE_DUAL_LANGUAGE_POST_SUCCESS', { + postGroupId: req.params.post_group_id, + userId: req.user.id + }) + + res.json({ + post_group_id: req.params.post_group_id, + english: result.rows.find(p => p.language === 'en') || null, + hindi: result.rows.find(p => p.language === 'hi') || null + }) + } catch (error) { + logger.error('POSTS', 'Error updating dual-language post', error) + res.status(500).json({ message: 'Failed to update posts', error: error.message }) + } +}) + export default router diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 49f266f..1667ac6 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -17,7 +17,17 @@ export default function Dashboard() { const fetchPosts = async () => { try { const res = await api.get('/posts') - setPosts(res.data) + // Group posts by post_group_id + const grouped = {} + res.data.forEach(post => { + const groupId = post.post_group_id || post.id + if (!grouped[groupId]) { + grouped[groupId] = { en: null, hi: null, groupId } + } + if (post.language === 'en') grouped[groupId].en = post + if (post.language === 'hi') grouped[groupId].hi = post + }) + setPosts(Object.values(grouped)) } catch (error) { toast.error('Failed to load posts') } finally { @@ -25,25 +35,51 @@ export default function Dashboard() { } } - const handleDelete = async (id) => { - if (!window.confirm('Are you sure you want to delete this post?')) return + const handleDelete = async (groupId, enId, hiId) => { + if (!window.confirm('Are you sure you want to delete both language versions of this post?')) return try { - await api.delete(`/posts/${id}`) - toast.success('Post deleted') + // Delete both English and Hindi versions + if (enId) await api.delete(`/posts/${enId}`) + if (hiId) await api.delete(`/posts/${hiId}`) + toast.success('Posts deleted') fetchPosts() } catch (error) { - toast.error('Failed to delete post') + toast.error('Failed to delete posts') } } - const handlePublish = async (post) => { + const handlePublish = async (postGroup) => { + const post = postGroup.en || postGroup.hi + if (!post) return + + const newStatus = post.status === 'published' ? 'draft' : 'published' + try { - await api.put(`/posts/${post.id}`, { - ...post, - status: post.status === 'published' ? 'draft' : 'published' - }) - toast.success(`Post ${post.status === 'published' ? 'unpublished' : 'published'}`) + // Update both posts via dual-language endpoint if they exist + if (postGroup.en && postGroup.hi) { + await api.put(`/posts/dual-language/${postGroup.groupId}`, { + title_en: postGroup.en.title, + title_hi: postGroup.hi.title, + excerpt_en: postGroup.en.excerpt, + excerpt_hi: postGroup.hi.excerpt, + content_json_en: postGroup.en.content_json, + content_json_hi: postGroup.hi.content_json, + external_url_en: postGroup.en.external_url, + external_url_hi: postGroup.hi.external_url, + content_type: post.content_type, + thumbnail_url: post.thumbnail_url, + status: newStatus + }) + } else { + // Fallback for single language posts (legacy) + const singlePost = postGroup.en || postGroup.hi + await api.put(`/posts/${singlePost.id}`, { + ...singlePost, + status: newStatus + }) + } + toast.success(`Post ${newStatus === 'published' ? 'published' : 'unpublished'}`) fetchPosts() } catch (error) { toast.error('Failed to update post') @@ -98,74 +134,145 @@ export default function Dashboard() { ) : (
- {posts.map((post) => ( -
-
-

- {post.title || 'Untitled'} -

-
- {post.content_type === 'link' && ( - - Link + {posts.map((postGroup) => { + const post = postGroup.en || postGroup.hi + if (!post) return null + + return ( +
+ {/* Display both language titles */} +
+
+ + EN - )} - + {postGroup.en?.title || 'No English version'} + +
+
+ + HI + +

+ {postGroup.hi?.title || 'No Hindi version'} +

+
+
+ + {/* Status badges */} +
+
+ {post.content_type === 'link' && ( + + Link + + )} + + {/* Show publish status for each language */} +
+ {postGroup.en && ( + + EN: {postGroup.en.status === 'published' ? '✓' : '○'} + + )} + {postGroup.hi && ( + + HI: {postGroup.hi.status === 'published' ? '✓' : '○'} + + )} +
+ + + {new Date(post.updated_at).toLocaleDateString()} + +
+ + {/* Summary status message */} +
+ {(() => { + const enPublished = postGroup.en?.status === 'published' + const hiPublished = postGroup.hi?.status === 'published' + + if (enPublished && hiPublished) { + return 📱 Published in: Both Languages + } else if (enPublished) { + return 📱 Published in: English Only + } else if (hiPublished) { + return 📱 Published in: Hindi Only + } else { + return 📝 Draft - Not Published + } + })()} +
+
+ + {/* Action buttons */} +
+ - {post.status} - + Edit + + {post.status === 'published' && postGroup.en && ( + post.content_type === 'link' && postGroup.en.external_url ? ( + + View EN + + ) : ( + + View EN + + ) + )} + +
-

- {new Date(post.updated_at).toLocaleDateString()} -

-
- - Edit - - {post.status === 'published' && ( - post.content_type === 'link' && post.external_url ? ( - - View - - ) : ( - - View - - ) - )} - - -
-
- ))} + ) + })}
)}
diff --git a/frontend/src/pages/Editor.jsx b/frontend/src/pages/Editor.jsx index e5f3d30..0c7c914 100644 --- a/frontend/src/pages/Editor.jsx +++ b/frontend/src/pages/Editor.jsx @@ -8,13 +8,26 @@ import toast from 'react-hot-toast' export default function EditorPage() { const { id } = useParams() const navigate = useNavigate() - const [title, setTitle] = useState('') - const [content, setContent] = useState(null) - const [createdAt, setCreatedAt] = useState(null) + + // Publish mode - what to publish when user clicks publish + const [publishMode, setPublishMode] = useState('both') // 'en', 'hi', or 'both' + + // Dual-language state + const [titleEn, setTitleEn] = useState('') + const [titleHi, setTitleHi] = useState('') + const [excerptEn, setExcerptEn] = useState('') + const [excerptHi, setExcerptHi] = useState('') + const [contentEn, setContentEn] = useState(null) + const [contentHi, setContentHi] = useState(null) + const [externalUrlEn, setExternalUrlEn] = useState('') + const [externalUrlHi, setExternalUrlHi] = useState('') + const [postGroupId, setPostGroupId] = useState(null) + const [singlePostId, setSinglePostId] = useState(null) // For single-language posts + + // Shared state const [contentType, setContentType] = useState('tiptap') // 'tiptap' | 'link' - const [externalUrl, setExternalUrl] = useState('') const [thumbnailUrl, setThumbnailUrl] = useState('') - const [excerpt, setExcerpt] = useState('') + const [createdAt, setCreatedAt] = useState(null) const [loading, setLoading] = useState(!!id) const [saving, setSaving] = useState(false) const [uploadingThumbnail, setUploadingThumbnail] = useState(false) @@ -22,8 +35,9 @@ export default function EditorPage() { const [showPreview, setShowPreview] = useState(false) const autoSaveTimeoutRef = useRef(null) const isInitialLoadRef = useRef(true) - const currentPostIdRef = useRef(id) + const currentPostGroupIdRef = useRef(id) const sessionIdRef = useRef(null) + if (!id && !sessionIdRef.current) { sessionIdRef.current = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() @@ -31,7 +45,7 @@ export default function EditorPage() { } useEffect(() => { - currentPostIdRef.current = id + currentPostGroupIdRef.current = id if (id) { fetchPost() } else { @@ -39,75 +53,207 @@ export default function EditorPage() { } }, [id]) - // Build post payload based on content type - const buildPostData = useCallback((overrides = {}) => { + // Build dual-language post payload + const buildDualLanguageData = useCallback((options = {}) => { + const { status_en, status_hi, ...overrides } = options const isLink = contentType === 'link' - const base = { - title: title?.trim() || 'Untitled', - status: overrides.status ?? 'draft', + const data = { + title_en: titleEn?.trim() || 'Untitled', + title_hi: titleHi?.trim() || 'शीर्षकहीन', + excerpt_en: excerptEn?.trim() || null, + excerpt_hi: excerptHi?.trim() || null, + content_json_en: isLink ? {} : (contentEn || {}), + content_json_hi: isLink ? {} : (contentHi || {}), + external_url_en: isLink ? externalUrlEn?.trim() : null, + external_url_hi: isLink ? externalUrlHi?.trim() : null, + content_type: contentType, thumbnail_url: thumbnailUrl?.trim() || null, - excerpt: excerpt?.trim()?.slice(0, 250) || null, + ...overrides } - if (isLink) { - return { - ...base, - content_type: 'link', - external_url: externalUrl.trim(), - content_json: {}, - ...overrides, - } - } - return { - ...base, - content_json: content || {}, - ...overrides, - } - }, [title, content, contentType, externalUrl, thumbnailUrl, excerpt]) + // Only include status fields if explicitly provided + if (status_en !== undefined) data.status_en = status_en + if (status_hi !== undefined) data.status_hi = status_hi + return data + }, [titleEn, titleHi, excerptEn, excerptHi, contentEn, contentHi, + externalUrlEn, externalUrlHi, contentType, thumbnailUrl]) - // Debounced auto-save function + // Fetch post (handles both single-language and dual-language) + const fetchPost = async () => { + try { + // First try to fetch as post group (dual-language) + let isGroupPost = false + let data = null + + try { + const res = await api.get(`/posts/group/${id}`) + data = res.data + isGroupPost = true + } catch (groupError) { + // Not a group post, will try single post + isGroupPost = false + } + + if (isGroupPost && data) { + // Dual-language post + setPostGroupId(data.post_group_id) + + // Load English version + if (data.english) { + setTitleEn(data.english.title || '') + setExcerptEn(data.english.excerpt || '') + setContentEn(data.english.content_json || null) + setExternalUrlEn(data.english.external_url || '') + setCreatedAt(data.english.created_at || null) + } + + // Load Hindi version + if (data.hindi) { + setTitleHi(data.hindi.title || '') + setExcerptHi(data.hindi.excerpt || '') + setContentHi(data.hindi.content_json || null) + setExternalUrlHi(data.hindi.external_url || '') + } + + // Load shared fields + const post = data.english || data.hindi + if (post) { + setContentType(post.content_type === 'link' ? 'link' : 'tiptap') + setThumbnailUrl(post.thumbnail_url || '') + } + + // Set publish mode based on which language(s) are PUBLISHED + const enPublished = data.english?.status === 'published' + const hiPublished = data.hindi?.status === 'published' + + if (enPublished && hiPublished) { + setPublishMode('both') + } else if (enPublished) { + setPublishMode('en') + } else if (hiPublished) { + setPublishMode('hi') + } else { + // Neither published, default based on what data exists + if (data.english && data.hindi) { + setPublishMode('both') + } else if (data.english) { + setPublishMode('en') + } else { + setPublishMode('hi') + } + } + } else { + // Single-language post - try fetching by post ID + const res = await api.get(`/posts/${id}`) + const post = res.data + + setSinglePostId(post.id) + + const lang = post.language || 'en' + + // Load into appropriate language state + if (lang === 'en') { + setTitleEn(post.title || '') + setExcerptEn(post.excerpt || '') + setContentEn(post.content_json || null) + setExternalUrlEn(post.external_url || '') + setPublishMode('en') + } else { + setTitleHi(post.title || '') + setExcerptHi(post.excerpt || '') + setContentHi(post.content_json || null) + setExternalUrlHi(post.external_url || '') + setPublishMode('hi') + } + + setContentType(post.content_type === 'link' ? 'link' : 'tiptap') + setThumbnailUrl(post.thumbnail_url || '') + setCreatedAt(post.created_at || null) + } + + isInitialLoadRef.current = true + } catch (error) { + console.error('Error loading post:', error) + toast.error('Failed to load post: ' + (error.response?.data?.message || error.message)) + navigate('/dashboard') + } finally { + setLoading(false) + setTimeout(() => { + isInitialLoadRef.current = false + }, 500) + } + } + + + // Debounced auto-save function - always saves all available data const handleAutoSave = useCallback(async () => { if (isInitialLoadRef.current) return - const isLink = contentType === 'link' - if (isLink) { - if (!title?.trim() || !externalUrl?.trim()) return - } else { - if (!title && !content) return - } + + const hasEnglishData = titleEn?.trim() || excerptEn?.trim() || + (contentType === 'link' ? externalUrlEn?.trim() : contentEn) + const hasHindiData = titleHi?.trim() || excerptHi?.trim() || + (contentType === 'link' ? externalUrlHi?.trim() : contentHi) + + if (!hasEnglishData && !hasHindiData) return try { setSaving(true) - const postData = buildPostData({ status: 'draft' }) - - let postId = currentPostIdRef.current - if (postId) { - await api.put(`/posts/${postId}`, postData) + + // If we have data for both languages, use dual-language endpoint + if (hasEnglishData && hasHindiData) { + const postData = buildDualLanguageData({ status_en: 'draft', status_hi: 'draft' }) + let groupId = currentPostGroupIdRef.current || postGroupId + + if (groupId) { + await api.put(`/posts/dual-language/${groupId}`, postData) + } else { + const res = await api.post('/posts/dual-language', postData) + groupId = res.data.post_group_id + currentPostGroupIdRef.current = groupId + setPostGroupId(groupId) + window.history.replaceState({}, '', `/editor/${groupId}`) + } } else { - const res = await api.post('/posts', postData) - postId = res.data.id - currentPostIdRef.current = postId - window.history.replaceState({}, '', `/editor/${postId}`) + // Only one language has data, use single-language endpoint + const lang = hasEnglishData ? 'en' : 'hi' + const postData = { + title: lang === 'en' ? (titleEn?.trim() || 'Untitled') : (titleHi?.trim() || 'शीर्षकहीन'), + excerpt: lang === 'en' ? (excerptEn?.trim() || null) : (excerptHi?.trim() || null), + content_json: contentType === 'link' ? {} : (lang === 'en' ? (contentEn || {}) : (contentHi || {})), + external_url: contentType === 'link' ? (lang === 'en' ? externalUrlEn?.trim() : externalUrlHi?.trim()) : null, + content_type: contentType, + thumbnail_url: thumbnailUrl?.trim() || null, + language: lang, + status: 'draft' + } + + if (singlePostId) { + await api.put(`/posts/${singlePostId}`, postData) + } else { + const res = await api.post('/posts', postData) + setSinglePostId(res.data.id) + window.history.replaceState({}, '', `/editor/${res.data.id}`) + } } } catch (error) { console.error('Auto-save failed:', error) } finally { setSaving(false) } - }, [title, content, contentType, externalUrl, buildPostData]) + }, [titleEn, titleHi, excerptEn, excerptHi, contentEn, contentHi, + externalUrlEn, externalUrlHi, contentType, thumbnailUrl, postGroupId, singlePostId, + buildDualLanguageData]) // Debounced save on content change useEffect(() => { - // Skip on initial load if (isInitialLoadRef.current) { isInitialLoadRef.current = false return } - // Clear existing timeout if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current) } - // Set new timeout for auto-save (2 seconds after last change) autoSaveTimeoutRef.current = setTimeout(() => { handleAutoSave() }, 2000) @@ -117,33 +263,10 @@ export default function EditorPage() { clearTimeout(autoSaveTimeoutRef.current) } } - }, [title, content, contentType, externalUrl, thumbnailUrl, excerpt, handleAutoSave]) + }, [titleEn, titleHi, excerptEn, excerptHi, contentEn, contentHi, + externalUrlEn, externalUrlHi, contentType, thumbnailUrl, handleAutoSave]) - const fetchPost = async () => { - try { - const res = await api.get(`/posts/${id}`) - const post = res.data - setTitle(post.title || '') - setContent(post.content_json || null) - 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') - navigate('/dashboard') - } finally { - setLoading(false) - // Allow saving after a short delay - setTimeout(() => { - isInitialLoadRef.current = false - }, 500) - } - } - - const postIdForThumbnail = id || currentPostIdRef.current + const postIdForThumbnail = singlePostId || id || currentPostGroupIdRef.current || postGroupId const handleThumbnailUpload = async (file) => { if (!file?.type?.startsWith('image/')) { @@ -177,7 +300,39 @@ export default function EditorPage() { throw new Error('Upload failed') } setThumbnailUrl(imageUrl) - await api.put(`/posts/${postIdForThumbnail}`, buildPostData({ thumbnail_url: imageUrl })) + + // Update posts with new thumbnail + if (postIdForThumbnail) { + // Determine which endpoint to use based on what exists + if (postGroupId) { + const postData = buildDualLanguageData({ thumbnail_url: imageUrl }) + await api.put(`/posts/dual-language/${postGroupId}`, postData) + } else if (singlePostId) { + // Check if we have both languages now + const hasEnglishData = titleEn?.trim() || excerptEn?.trim() + const hasHindiData = titleHi?.trim() || excerptHi?.trim() + + if (hasEnglishData && hasHindiData) { + const postData = buildDualLanguageData({ thumbnail_url: imageUrl, status_en: 'draft', status_hi: 'draft' }) + const res = await api.post('/posts/dual-language', postData) + setPostGroupId(res.data.post_group_id) + setSinglePostId(null) + } else { + const lang = hasEnglishData ? 'en' : 'hi' + const postData = { + title: lang === 'en' ? titleEn?.trim() : titleHi?.trim(), + excerpt: lang === 'en' ? excerptEn?.trim() : excerptHi?.trim(), + content_json: contentType === 'link' ? {} : (lang === 'en' ? (contentEn || {}) : (contentHi || {})), + external_url: contentType === 'link' ? (lang === 'en' ? externalUrlEn?.trim() : externalUrlHi?.trim()) : null, + content_type: contentType, + thumbnail_url: imageUrl, + language: lang + } + await api.put(`/posts/${singlePostId}`, postData) + } + } + } + toast.success('Thumbnail saved', { id: 'thumbnail' }) } catch (err) { toast.error(err.response?.data?.message || err.message || 'Failed to upload thumbnail', { id: 'thumbnail' }) @@ -189,53 +344,169 @@ export default function EditorPage() { const handleRemoveThumbnail = () => { setThumbnailUrl('') if (postIdForThumbnail) { - api.put(`/posts/${postIdForThumbnail}`, { ...buildPostData(), thumbnail_url: null }).catch(() => {}) + if (postGroupId) { + const postData = buildDualLanguageData({ thumbnail_url: null }) + api.put(`/posts/dual-language/${postGroupId}`, postData).catch(() => {}) + } else if (singlePostId) { + const hasEnglishData = titleEn?.trim() || excerptEn?.trim() + const hasHindiData = titleHi?.trim() || excerptHi?.trim() + + if (hasEnglishData && hasHindiData) { + const postData = buildDualLanguageData({ thumbnail_url: null }) + api.put(`/posts/dual-language/${postGroupId}`, postData).catch(() => {}) + } else { + const lang = hasEnglishData ? 'en' : 'hi' + const postData = { + title: lang === 'en' ? titleEn?.trim() : titleHi?.trim(), + excerpt: lang === 'en' ? excerptEn?.trim() : excerptHi?.trim(), + content_json: contentType === 'link' ? {} : (lang === 'en' ? (contentEn || {}) : (contentHi || {})), + external_url: contentType === 'link' ? (lang === 'en' ? externalUrlEn?.trim() : externalUrlHi?.trim()) : null, + content_type: contentType, + thumbnail_url: null, + language: lang + } + api.put(`/posts/${singlePostId}`, postData).catch(() => {}) + } + } } } const handlePublish = async () => { - if (!title.trim()) { - toast.error('Please enter a title') + if (!thumbnailUrl.trim()) { + toast.error('Thumbnail is required to publish') 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://'))) { - toast.error('Please enter a valid URL (http:// or https://)') + // Validate based on publish mode + if (publishMode === 'en' || publishMode === 'both') { + // Validate English + if (!titleEn.trim()) { + toast.error('English title is required') return } - } else { - if (!content) { - toast.error('Please add some content') + if (!excerptEn.trim()) { + toast.error('English excerpt is required') + return + } + if (contentType === 'link' && !externalUrlEn.trim()) { + toast.error('English URL is required for link posts') + return + } + if (contentType === 'link') { + const url = externalUrlEn.trim() + if (!url.startsWith('http://') && !url.startsWith('https://')) { + toast.error('English URL must start with http:// or https://') + return + } + } + if (contentType !== 'link' && !contentEn) { + toast.error('English content is required') + return + } + } + + if (publishMode === 'hi' || publishMode === 'both') { + // Validate Hindi + if (!titleHi.trim()) { + toast.error('Hindi title is required') + return + } + if (!excerptHi.trim()) { + toast.error('Hindi excerpt is required') + return + } + if (contentType === 'link' && !externalUrlHi.trim()) { + toast.error('Hindi URL is required for link posts') + return + } + if (contentType === 'link') { + const url = externalUrlHi.trim() + if (!url.startsWith('http://') && !url.startsWith('https://')) { + toast.error('Hindi URL must start with http:// or https://') + return + } + } + if (contentType !== 'link' && !contentHi) { + toast.error('Hindi content is required') return } } try { setSaving(true) - const postData = buildPostData({ status: 'published' }) - - let postId = currentPostIdRef.current || id - if (postId) { - await api.put(`/posts/${postId}`, postData) + + // Check if we have content in both languages + const hasEnglish = titleEn?.trim() || excerptEn?.trim() || contentEn || externalUrlEn?.trim() + const hasHindi = titleHi?.trim() || excerptHi?.trim() || contentHi || externalUrlHi?.trim() + + // If we have both languages OR already have a postGroupId, use dual-language endpoint + if ((hasEnglish && hasHindi) || postGroupId) { + // Determine status for each language based on publishMode + let enStatus = 'draft' + let hiStatus = 'draft' + + if (publishMode === 'both') { + enStatus = 'published' + hiStatus = 'published' + } else if (publishMode === 'en') { + enStatus = 'published' + hiStatus = 'draft' + } else if (publishMode === 'hi') { + enStatus = 'draft' + hiStatus = 'published' + } + + // Build data with appropriate statuses + const postData = buildDualLanguageData({ status_en: enStatus, status_hi: hiStatus }) + + // Only use PUT if we have a REAL post_group_id (not a single post ID) + // The postGroupId state is only set when we have actual linked posts + const actualGroupId = postGroupId || currentPostGroupIdRef.current + + if (actualGroupId && !singlePostId) { + // We have a real post group ID - update it + await api.put(`/posts/dual-language/${actualGroupId}`, postData) + } else { + // Creating new dual-language post or converting from single-language + const res = await api.post('/posts/dual-language', postData) + const newGroupId = res.data.post_group_id + setPostGroupId(newGroupId) + currentPostGroupIdRef.current = newGroupId + setSinglePostId(null) // Clear single post ID since we now have a group + window.history.replaceState({}, '', `/editor/${newGroupId}`) + } + + if (publishMode === 'both') { + toast.success('Posts published in both languages!') + } else { + toast.success(`${publishMode === 'en' ? 'English' : 'Hindi'} published! Other language saved as draft.`) + } } else { - const res = await api.post('/posts', postData) - postId = res.data.id + // Single language only, no dual-language post exists + const lang = publishMode + const postData = { + title: lang === 'en' ? titleEn?.trim() : titleHi?.trim(), + excerpt: lang === 'en' ? excerptEn?.trim() : excerptHi?.trim(), + content_json: contentType === 'link' ? {} : (lang === 'en' ? (contentEn || {}) : (contentHi || {})), + external_url: contentType === 'link' ? (lang === 'en' ? externalUrlEn?.trim() : externalUrlHi?.trim()) : null, + content_type: contentType, + thumbnail_url: thumbnailUrl?.trim() || null, + language: lang, + status: 'published' + } + + if (singlePostId) { + await api.put(`/posts/${singlePostId}`, postData) + } else { + const res = await api.post('/posts', postData) + setSinglePostId(res.data.id) + } + + toast.success(`Post published in ${lang === 'en' ? 'English' : 'Hindi'}!`) } - - toast.success('Post published!') - navigate('/dashboard') + + // Don't navigate immediately - let user see the success message + setTimeout(() => navigate('/dashboard'), 1000) } catch (error) { toast.error(error.response?.data?.message || 'Failed to publish post') } finally { @@ -258,7 +529,7 @@ export default function EditorPage() { return (
- {/* Top bar - responsive */} + {/* Top bar */}
@@ -302,130 +573,292 @@ export default function EditorPage() {
{/* Main Editor Section */}
-
- {/* Post type selector */} -
- - -
- {/* Card-style editor block */} -
-
- - setTitle(e.target.value)} - 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" - /> +
+ {/* Publish mode selector and Post type selector */} +
+ {/* Publish Mode - What to publish */} +
+ +
+ + + +
- {/* Post thumbnail - below title, only when post exists */} -
- - {!postIdForThumbnail ? ( -

Save draft first to add a thumbnail. Required for publishing.

- ) : ( -
- {thumbnailUrl ? ( -
- Thumbnail - -
- ) : null} -
- { - const f = e.target.files?.[0] - if (f) handleThumbnailUpload(f) - e.target.value = '' - }} - /> + + {/* Post type selector */} +
+ + +
+
+ + {/* Shared thumbnail section */} +
+ + {!postIdForThumbnail ? ( +

Save draft first to add a thumbnail. Required for publishing.

+ ) : ( +
+ {thumbnailUrl ? ( +
+ Thumbnail -

Square image recommended. Required for publishing.

+ ) : null} +
+ { + const f = e.target.files?.[0] + if (f) handleThumbnailUpload(f) + e.target.value = '' + }} + /> + +

Square image recommended. Required for publishing.

- )} -
- {/* Optional excerpt for list description */} -
- -