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 ? (
-
-

-
-
- ) : 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 ? (
+
+
-
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 */}
-
-
-
- {contentType === 'link' ? (
-
-
-
setExternalUrl(e.target.value)}
- className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
- />
-
The page readers will see when they open this post.
-
- ) : (
-
-
-
)}
+
+ {/* Always show dual-language layout - all content preserved */}
+
+ {/* English Section */}
+
+
+
+
+ English
+
+ {(publishMode === 'en' || publishMode === 'both') && (
+
+ Will Publish
+
+ )}
+
+
+
+
+
setTitleEn(e.target.value)}
+ className="w-full text-xl 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 mb-4"
+ />
+
+
+
+
+
+ {/* Hindi Section */}
+
+
+
+
+ हिंदी (Hindi)
+
+ {(publishMode === 'hi' || publishMode === 'both') && (
+
+ Will Publish
+
+ )}
+
+
+
+
+
setTitleHi(e.target.value)}
+ className="w-full text-xl font-bold p-2 border-0 border-b-2 border-transparent hover:border-gray-200 focus:outline-none focus:border-green-500 bg-transparent transition-colors mb-4"
+ />
+
+
+
+
+
- {/* Mobile Preview - sidebar on lg+, full-width panel on smaller screens */}
+ {/* Mobile Preview */}
{showPreview && (