updated for languagees

This commit is contained in:
chandresh 2026-02-11 22:18:36 +05:30
parent cb8b768d3c
commit 26a617e1d4
4 changed files with 1385 additions and 314 deletions

View File

@ -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()

View File

@ -19,7 +19,7 @@ function isValidExternalUrl(url) {
router.get('/', async (req, res) => {
try {
logger.transaction('FETCH_POSTS', { userId: req.user.id })
const query = 'SELECT id, title, slug, status, content_type, external_url, 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

View File

@ -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'
// 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
})
toast.success(`Post ${post.status === 'published' ? 'unpublished' : 'published'}`)
} 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() {
</div>
) : (
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<div key={post.id} className="bg-white rounded-lg shadow p-4 sm:p-6">
<div className="flex justify-between items-start gap-2 mb-2">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 line-clamp-2 min-w-0">
{post.title || 'Untitled'}
{posts.map((postGroup) => {
const post = postGroup.en || postGroup.hi
if (!post) return null
return (
<div key={postGroup.groupId} className="bg-white rounded-lg shadow p-4 sm:p-6">
{/* Display both language titles */}
<div className="mb-3 space-y-2">
<div className="flex items-start gap-2">
<span className={`px-2 py-1 text-xs rounded font-medium flex-shrink-0 ${
postGroup.en?.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-indigo-100 text-indigo-800'
}`}>
EN
</span>
<h3 className="text-sm font-semibold text-gray-900 line-clamp-2 flex-1">
{postGroup.en?.title || 'No English version'}
</h3>
<div className="flex flex-shrink-0 items-center gap-1.5 flex-wrap justify-end">
</div>
<div className="flex items-start gap-2">
<span className={`px-2 py-1 text-xs rounded font-medium flex-shrink-0 ${
postGroup.hi?.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-amber-100 text-amber-800'
}`}>
HI
</span>
<h3 className="text-sm font-semibold text-gray-900 line-clamp-2 flex-1">
{postGroup.hi?.title || 'No Hindi version'}
</h3>
</div>
</div>
{/* Status badges */}
<div className="space-y-2 mb-4">
<div className="flex flex-wrap items-center gap-2">
{post.content_type === 'link' && (
<span className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800">
<span className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800 font-medium">
Link
</span>
)}
{/* Show publish status for each language */}
<div className="flex items-center gap-1.5">
{postGroup.en && (
<span
className={`px-2 py-1 text-xs rounded ${
post.status === 'published'
className={`px-2 py-1 text-xs rounded font-medium ${
postGroup.en.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-600'
}`}
title={`English: ${postGroup.en.status}`}
>
{post.status}
EN: {postGroup.en.status === 'published' ? '✓' : '○'}
</span>
)}
{postGroup.hi && (
<span
className={`px-2 py-1 text-xs rounded font-medium ${
postGroup.hi.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}
title={`Hindi: ${postGroup.hi.status}`}
>
HI: {postGroup.hi.status === 'published' ? '✓' : '○'}
</span>
)}
</div>
<span className="text-xs text-gray-500 ml-auto">
{new Date(post.updated_at).toLocaleDateString()}
</span>
</div>
{/* Summary status message */}
<div className="text-xs font-medium">
{(() => {
const enPublished = postGroup.en?.status === 'published'
const hiPublished = postGroup.hi?.status === 'published'
if (enPublished && hiPublished) {
return <span className="text-green-700">📱 Published in: <strong>Both Languages</strong></span>
} else if (enPublished) {
return <span className="text-indigo-700">📱 Published in: <strong>English Only</strong></span>
} else if (hiPublished) {
return <span className="text-amber-700">📱 Published in: <strong>Hindi Only</strong></span>
} else {
return <span className="text-gray-600">📝 Draft - Not Published</span>
}
})()}
</div>
<p className="text-sm text-gray-500 mb-4">
{new Date(post.updated_at).toLocaleDateString()}
</p>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
<Link
to={`/editor/${post.id}`}
className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 text-center bg-indigo-600 text-white px-3 py-2 rounded-md hover:bg-indigo-700 text-sm"
to={`/editor/${postGroup.groupId}`}
className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 text-center bg-indigo-600 text-white px-3 py-2 rounded-md hover:bg-indigo-700 text-sm font-medium"
>
Edit
</Link>
{post.status === 'published' && (
post.content_type === 'link' && post.external_url ? (
{post.status === 'published' && postGroup.en && (
post.content_type === 'link' && postGroup.en.external_url ? (
<a
href={post.external_url}
href={postGroup.en.external_url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 text-center bg-gray-200 text-gray-700 px-3 py-2 rounded-md hover:bg-gray-300 text-sm"
>
View
View EN
</a>
) : (
<Link
to={`/blog/${post.slug}`}
to={`/blog/${postGroup.en.slug}`}
target="_blank"
className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 text-center bg-gray-200 text-gray-700 px-3 py-2 rounded-md hover:bg-gray-300 text-sm"
>
View
View EN
</Link>
)
)}
<button
onClick={() => handlePublish(post)}
onClick={() => handlePublish(postGroup)}
className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm"
>
{post.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
<button
onClick={() => handleDelete(post.id)}
onClick={() => handleDelete(postGroup.groupId, postGroup.en?.id, postGroup.hi?.id)}
className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm"
>
Delete
</button>
</div>
</div>
))}
)
})}
</div>
)}
</div>

View File

@ -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 {
// 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)
postId = res.data.id
currentPostIdRef.current = postId
window.history.replaceState({}, '', `/editor/${postId}`)
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')
// Validate based on publish mode
if (publishMode === 'en' || publishMode === 'both') {
// Validate English
if (!titleEn.trim()) {
toast.error('English title is required')
return
}
if (!excerpt?.trim()) {
toast.error('Please add a list description before publishing')
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 = externalUrl.trim()
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) {
toast.error('Please enter a valid URL (http:// or https://)')
const url = externalUrlEn.trim()
if (!url.startsWith('http://') && !url.startsWith('https://')) {
toast.error('English URL must start with http:// or https://')
return
}
} else {
if (!content) {
toast.error('Please add some content')
}
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)
} else {
const res = await api.post('/posts', postData)
postId = res.data.id
// 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'
}
toast.success('Post published!')
navigate('/dashboard')
// 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 {
// 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'}!`)
}
// 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 (
<div className="min-h-screen bg-gray-100 flex flex-col">
{/* Top bar - responsive */}
{/* Top bar */}
<header className="bg-white border-b border-gray-200 flex-shrink-0">
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2 min-h-14 py-2 sm:py-0">
@ -302,9 +573,51 @@ export default function EditorPage() {
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
{/* Main Editor Section */}
<main className={`flex-1 overflow-y-auto transition-all duration-300 min-h-0 ${showPreview ? 'lg:border-r lg:border-gray-200' : ''}`}>
<div className="max-w-4xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8">
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8">
{/* Publish mode selector and Post type selector */}
<div className="mb-4 flex flex-wrap gap-2 items-center">
{/* Publish Mode - What to publish */}
<div className="flex items-center gap-2 mr-4">
<label className="text-sm font-medium text-gray-700">Publish:</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPublishMode('en')}
className={`px-3 py-2 rounded-lg text-sm font-medium ${
publishMode === 'en'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
English Only
</button>
<button
type="button"
onClick={() => setPublishMode('hi')}
className={`px-3 py-2 rounded-lg text-sm font-medium ${
publishMode === 'hi'
? 'bg-green-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
Hindi Only
</button>
<button
type="button"
onClick={() => setPublishMode('both')}
className={`px-3 py-2 rounded-lg text-sm font-medium ${
publishMode === 'both'
? 'bg-purple-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
Both Languages
</button>
</div>
</div>
{/* Post type selector */}
<div className="mb-4 flex gap-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => setContentType('tiptap')}
@ -328,21 +641,13 @@ export default function EditorPage() {
Link
</button>
</div>
{/* Card-style editor block */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-visible">
<div className="px-4 sm:px-6 pt-4 sm:pt-6 pb-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title (required)</label>
<input
type="text"
placeholder="Enter post title..."
value={title}
onChange={(e) => 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"
/>
</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>
{/* Shared thumbnail section */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Post thumbnail (required - shared by both languages)
</label>
{!postIdForThumbnail ? (
<p className="text-sm text-gray-500 py-2">Save draft first to add a thumbnail. Required for publishing.</p>
) : (
@ -385,47 +690,175 @@ export default function EditorPage() {
</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>
{/* Always show dual-language layout - all content preserved */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* English Section */}
<div className={`bg-white rounded-xl shadow-sm border-2 overflow-visible ${
publishMode === 'en' || publishMode === 'both'
? 'border-indigo-500 ring-2 ring-indigo-200'
: 'border-gray-200 opacity-75'
}`}>
<div className={`px-4 py-3 border-b ${
publishMode === 'en' || publishMode === 'both'
? 'bg-indigo-50 border-indigo-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between">
<h3 className={`text-lg font-bold ${
publishMode === 'en' || publishMode === 'both'
? 'text-indigo-700'
: 'text-gray-500'
}`}>
English
</h3>
{(publishMode === 'en' || publishMode === 'both') && (
<span className="text-xs bg-indigo-600 text-white px-2 py-1 rounded">
Will Publish
</span>
)}
</div>
</div>
<div className="p-4">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Title (required)
</label>
<input
type="text"
placeholder="Enter English title..."
value={titleEn}
onChange={(e) => 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"
/>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
List description (required)
</label>
<textarea
placeholder="Short description for the blog list (12 lines). Required for publishing."
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder="Short English description for blog list (1-2 lines)..."
value={excerptEn}
onChange={(e) => setExcerptEn(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"
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 mb-2"
/>
<p className="mt-1 text-xs text-gray-500">{excerpt.length}/250</p>
</div>
<p className="text-xs text-gray-500 mb-4">{excerptEn.length}/250</p>
{contentType === 'link' ? (
<div className="px-4 sm:px-6 pb-6">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">URL</label>
<>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
English URL (required)
</label>
<input
type="url"
placeholder="https://..."
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/english-article"
value={externalUrlEn}
onChange={(e) => setExternalUrlEn(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"
/>
<p className="mt-1 text-xs text-gray-500">The page readers will see when they open this post.</p>
</div>
<p className="mt-1 text-xs text-gray-500">English readers will see this URL.</p>
</>
) : (
<div className="px-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide px-2 sm:px-4 pt-4 pb-1">Content</label>
<>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Content (required)
</label>
<Editor
content={content}
onChange={setContent}
postId={id || currentPostIdRef.current}
sessionId={!id ? sessionIdRef.current : null}
content={contentEn}
onChange={setContentEn}
postId={id || currentPostGroupIdRef.current}
sessionId={!id ? sessionIdRef.current + '-en' : null}
/>
</div>
</>
)}
</div>
</div>
{/* Hindi Section */}
<div className={`bg-white rounded-xl shadow-sm border-2 overflow-visible ${
publishMode === 'hi' || publishMode === 'both'
? 'border-green-500 ring-2 ring-green-200'
: 'border-gray-200 opacity-75'
}`}>
<div className={`px-4 py-3 border-b ${
publishMode === 'hi' || publishMode === 'both'
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between">
<h3 className={`text-lg font-bold ${
publishMode === 'hi' || publishMode === 'both'
? 'text-green-700'
: 'text-gray-500'
}`}>
ि (Hindi)
</h3>
{(publishMode === 'hi' || publishMode === 'both') && (
<span className="text-xs bg-green-600 text-white px-2 py-1 rounded">
Will Publish
</span>
)}
</div>
</div>
<div className="p-4">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
षक / Title (required)
</label>
<input
type="text"
placeholder="हिंदी शीर्षक दर्ज करें..."
value={titleHi}
onChange={(e) => 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"
/>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
िवरण / Description (required)
</label>
<textarea
placeholder="ब्लॉग सूची के लिए संक्षिप्त हिंदी विवरण (1-2 पंक्तियाँ)..."
value={excerptHi}
onChange={(e) => setExcerptHi(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-green-500 focus:border-transparent text-sm resize-none mb-2"
/>
<p className="text-xs text-gray-500 mb-4">{excerptHi.length}/250</p>
{contentType === 'link' ? (
<>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Hindi URL (required)
</label>
<input
type="url"
placeholder="https://example.com/hindi-article"
value={externalUrlHi}
onChange={(e) => setExternalUrlHi(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-500">Hindi readers will see this URL.</p>
</>
) : (
<>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
मग / Content (required)
</label>
<Editor
content={contentHi}
onChange={setContentHi}
postId={id || currentPostGroupIdRef.current}
sessionId={!id ? sessionIdRef.current + '-hi' : null}
/>
</>
)}
</div>
</div>
</div>
</div>
</main>
{/* Mobile Preview - sidebar on lg+, full-width panel on smaller screens */}
{/* Mobile Preview */}
{showPreview && (
<aside className="bg-gray-50 border-t lg:border-t-0 lg:border-l border-gray-200 flex-shrink-0 w-full lg:w-[380px] flex flex-col lg:min-h-0 lg:max-h-full overflow-hidden">
<div className="p-3 sm:p-4 overflow-auto">
@ -442,22 +875,105 @@ export default function EditorPage() {
</svg>
</button>
</div>
{/* Preview based on publish mode */}
{publishMode === 'en' ? (
/* English only preview */
<div>
<div className="text-xs font-medium text-indigo-600 mb-2 flex items-center gap-2">
ENGLISH PREVIEW
<span className="text-xs bg-indigo-600 text-white px-2 py-0.5 rounded">Will Publish</span>
</div>
{contentType === 'link' ? (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<span className="text-xs font-medium text-blue-600 uppercase tracking-wide">Link</span>
<p className="mt-2 font-semibold text-gray-900">{title || 'Untitled'}</p>
{externalUrl && (
<p className="mt-1 text-sm text-gray-500 break-all">Opens: {externalUrl}</p>
<p className="mt-2 font-semibold text-gray-900">{titleEn || 'Untitled'}</p>
{externalUrlEn && (
<p className="mt-1 text-sm text-gray-500 break-all">Opens: {externalUrlEn}</p>
)}
</div>
) : (
<MobilePreview
title={title}
content={content}
title={titleEn}
content={contentEn}
createdAt={createdAt}
/>
)}
</div>
) : publishMode === 'hi' ? (
/* Hindi only preview */
<div>
<div className="text-xs font-medium text-green-600 mb-2 flex items-center gap-2">
HINDI PREVIEW
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded">Will Publish</span>
</div>
{contentType === 'link' ? (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<span className="text-xs font-medium text-blue-600 uppercase tracking-wide">Link</span>
<p className="mt-2 font-semibold text-gray-900">{titleHi || 'शीर्षकहीन'}</p>
{externalUrlHi && (
<p className="mt-1 text-sm text-gray-500 break-all">Opens: {externalUrlHi}</p>
)}
</div>
) : (
<MobilePreview
title={titleHi}
content={contentHi}
createdAt={createdAt}
/>
)}
</div>
) : (
/* Both languages preview */
<>
{/* Preview English version */}
<div className="mb-4">
<div className="text-xs font-medium text-indigo-600 mb-2 flex items-center gap-2">
ENGLISH PREVIEW
<span className="text-xs bg-indigo-600 text-white px-2 py-0.5 rounded">Will Publish</span>
</div>
{contentType === 'link' ? (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<span className="text-xs font-medium text-blue-600 uppercase tracking-wide">Link</span>
<p className="mt-2 font-semibold text-gray-900">{titleEn || 'Untitled'}</p>
{externalUrlEn && (
<p className="mt-1 text-sm text-gray-500 break-all">Opens: {externalUrlEn}</p>
)}
</div>
) : (
<MobilePreview
title={titleEn}
content={contentEn}
createdAt={createdAt}
/>
)}
</div>
{/* Preview Hindi version */}
<div>
<div className="text-xs font-medium text-green-600 mb-2 flex items-center gap-2">
HINDI PREVIEW
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded">Will Publish</span>
</div>
{contentType === 'link' ? (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<span className="text-xs font-medium text-blue-600 uppercase tracking-wide">Link</span>
<p className="mt-2 font-semibold text-gray-900">{titleHi || 'शीर्षकहीन'}</p>
{externalUrlHi && (
<p className="mt-1 text-sm text-gray-500 break-all">Opens: {externalUrlHi}</p>
)}
</div>
) : (
<MobilePreview
title={titleHi}
content={contentHi}
createdAt={createdAt}
/>
)}
</div>
</>
)}
</div>
</aside>
)}
</div>