updated for languagees
This commit is contained in:
parent
cb8b768d3c
commit
26a617e1d4
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (1–2 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue