feat: post composition redesign + budget allocation + brand identity (Rawaj)
Post Workflow: - PostDetail full page (/posts/:id) replaces slide panel approach - Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video - copy_type field on Translations (caption/body) - Composition endpoint returns rich data (content preview, languages, thumbnails) - Stage auto-advances on translation/artefact changes (both link and unlink) - "Translations" renamed to "Copy" in navigation - GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added - PostProduction: "New Post" creates → navigates to full page - CampaignDetail: click post → navigates to full page - Inline link picker (no modals) with search + rich item display - PostComposition sub-components for caption, copy, designs, video, formats, readiness Budget Allocation: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Budget mutex for race conditions - Validation at all levels (main → campaign → track, expenses) - CEO approval workflow: BudgetRequests table, public approval page - Finance page: request budget UI, budget requests section - Settings: CEO email field - All emails branded with "Rawaj —" prefix Brand Identity: - Name: Rawaj (رواج) — trending/virality - Deep teal palette (#0d9488), forest-tinted dark mode - DM Sans font, custom SVG logo - Consistent across login, sidebar, emails, public pages Approval Workflow: - Single reviewer per artefact (not multi-select) - Reviewer redirect on public review page - Server blocks submit-review without reviewer - Review URLs use APP_URL (not server URL) UI/UX: - Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured to avoid overflow-y-auto clipping native select dropdowns - section-card overflow-hidden → overflow-clip - All page titles via Header.jsx (removed duplicate h1s) - CampaignDetail redesigned: prominent budget card, compact team Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import PlatformIcon from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
||||
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
|
||||
|
||||
export default function PostDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { brands, getBrandName, teamMembers } = useContext(AppContext)
|
||||
const { t, lang } = useLanguage()
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const [post, setPost] = useState(null)
|
||||
const [composition, setComposition] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
|
||||
// Editable form fields
|
||||
const [title, setTitle] = useState('')
|
||||
const [status, setStatus] = useState('draft')
|
||||
const [brandId, setBrandId] = useState('')
|
||||
const [campaignId, setCampaignId] = useState('')
|
||||
const [assignedTo, setAssignedTo] = useState('')
|
||||
const [platforms, setPlatforms] = useState([])
|
||||
const [scheduledDate, setScheduledDate] = useState('')
|
||||
|
||||
// Link pickers
|
||||
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
|
||||
const [pickerSearch, setPickerSearch] = useState('')
|
||||
const [linkCandidates, setLinkCandidates] = useState([])
|
||||
const [linking, setLinking] = useState(false)
|
||||
|
||||
// Sub-panels
|
||||
const [openTranslation, setOpenTranslation] = useState(null)
|
||||
const [openArtefact, setOpenArtefact] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadPost()
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
||||
}, [id])
|
||||
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
const [p, comp] = await Promise.all([
|
||||
api.get(`/posts/${id}`),
|
||||
api.get(`/posts/${id}/composition`),
|
||||
])
|
||||
setPost(p)
|
||||
setComposition(comp)
|
||||
setTitle(p.title || '')
|
||||
setStatus(p.status || 'draft')
|
||||
setBrandId(p.brand_id || p.brandId || '')
|
||||
setCampaignId(p.campaign_id || p.campaignId || '')
|
||||
setAssignedTo(p.assigned_to || p.assignedTo || '')
|
||||
const plats = p.platforms || (p.platform ? [p.platform] : [])
|
||||
setPlatforms(Array.isArray(plats) ? plats : [])
|
||||
const sd = p.scheduled_date || p.scheduledDate
|
||||
setScheduledDate(sd ? new Date(sd).toISOString().slice(0, 10) : '')
|
||||
} catch (err) {
|
||||
console.error('Failed to load post:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadComposition = useCallback(async () => {
|
||||
try {
|
||||
setComposition(await api.get(`/posts/${id}/composition`))
|
||||
} catch (err) {
|
||||
console.error('Failed to load composition:', err)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.patch(`/posts/${id}`, {
|
||||
title,
|
||||
status,
|
||||
brand_id: brandId ? Number(brandId) : null,
|
||||
campaign_id: campaignId ? Number(campaignId) : null,
|
||||
assigned_to: assignedTo ? Number(assignedTo) : null,
|
||||
platforms,
|
||||
scheduled_date: scheduledDate || null,
|
||||
})
|
||||
toast.success(t('posts.updated'))
|
||||
loadPost()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlatform = (key) => {
|
||||
setPlatforms(prev => prev.includes(key) ? prev.filter(p => p !== key) : [...prev, key])
|
||||
}
|
||||
|
||||
// ─── Link / Unlink / Create ───
|
||||
|
||||
const openLinkPicker = async (type) => {
|
||||
setActivePicker(type)
|
||||
setPickerSearch('')
|
||||
try {
|
||||
if (type === 'caption' || type === 'body') {
|
||||
const all = await api.get('/translations')
|
||||
// Show all translations not already linked to THIS post
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(t => {
|
||||
const linkedTo = t.post_id || t.postId
|
||||
return !linkedTo || String(linkedTo) !== String(id)
|
||||
}))
|
||||
} else {
|
||||
const all = await api.get('/artefacts')
|
||||
const at = type === 'video' ? 'video' : 'design'
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
|
||||
const linkedTo = a.post_id || a.postId
|
||||
const matchesType = (a.type || 'design') === at
|
||||
return matchesType && (!linkedTo || String(linkedTo) !== String(id))
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
setLinkCandidates([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleLink = async (itemId) => {
|
||||
setLinking(true)
|
||||
try {
|
||||
const copyType = activePicker === 'caption' || activePicker === 'body' ? activePicker : null
|
||||
if (activePicker === 'caption' || activePicker === 'body') {
|
||||
await api.patch(`/translations/${itemId}`, { post_id: Number(id), copy_type: copyType })
|
||||
} else {
|
||||
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
||||
}
|
||||
toast.success(t('posts.updated'))
|
||||
setActivePicker(null)
|
||||
loadComposition()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setLinking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlink = async (type) => {
|
||||
const piece = type === 'caption' ? composition?.caption
|
||||
: type === 'body' ? composition?.body_copy
|
||||
: type === 'design' ? composition?.design
|
||||
: composition?.video
|
||||
if (!piece) return
|
||||
try {
|
||||
const endpoint = (type === 'caption' || type === 'body') ? `/translations/${piece.id}` : `/artefacts/${piece.id}`
|
||||
await api.patch(endpoint, { post_id: null })
|
||||
toast.success(t('posts.updated'))
|
||||
loadComposition()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (type) => {
|
||||
try {
|
||||
if (type === 'caption') {
|
||||
await api.post('/translations', {
|
||||
post_id: Number(id),
|
||||
copy_type: 'caption',
|
||||
title: (title || 'Post') + ' - Caption',
|
||||
source_language: 'EN',
|
||||
source_content: ' ',
|
||||
})
|
||||
} else if (type === 'body') {
|
||||
await api.post('/translations', {
|
||||
post_id: Number(id),
|
||||
copy_type: 'body',
|
||||
title: (title || 'Post') + ' - Copy',
|
||||
source_language: 'EN',
|
||||
source_content: ' ',
|
||||
})
|
||||
} else if (type === 'design') {
|
||||
await api.post('/artefacts', {
|
||||
post_id: Number(id),
|
||||
type: 'design',
|
||||
title: (title || 'Post') + ' - Design',
|
||||
status: 'draft',
|
||||
})
|
||||
} else if (type === 'video') {
|
||||
await api.post('/artefacts', {
|
||||
post_id: Number(id),
|
||||
type: 'video',
|
||||
title: (title || 'Post') + ' - Video',
|
||||
status: 'draft',
|
||||
})
|
||||
}
|
||||
toast.success(t('posts.created'))
|
||||
loadComposition()
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenPiece = async (type) => {
|
||||
const piece = type === 'caption' ? composition?.caption
|
||||
: type === 'body' ? composition?.body_copy
|
||||
: type === 'design' ? composition?.design
|
||||
: composition?.video
|
||||
if (!piece) return
|
||||
if (type === 'caption' || type === 'body') {
|
||||
try {
|
||||
const full = await api.get(`/translations/${piece.id}`)
|
||||
setOpenTranslation(full)
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
} else {
|
||||
try {
|
||||
const full = await api.get(`/artefacts/${piece.id}`)
|
||||
setOpenArtefact(full)
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rendering ───
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-8 h-8 bg-surface-tertiary rounded-lg"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-6 bg-surface-tertiary rounded w-64 mb-2"></div>
|
||||
<div className="h-4 bg-surface-tertiary rounded w-96"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1,2,3,4].map(i => <div key={i} className="h-40 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-tertiary">
|
||||
{t('common.noResults')}{' '}
|
||||
<button onClick={() => navigate('/posts')} className="text-brand-primary underline">{t('common.goBack')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredCandidates = linkCandidates.filter(c => {
|
||||
if (!pickerSearch) return true
|
||||
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
|
||||
})
|
||||
|
||||
const waitingOn = composition?.waiting_on || []
|
||||
const piecesReady = composition?.pieces_ready || false
|
||||
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* ─── HEADER ─── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/posts')} className="p-1.5 hover:bg-surface-tertiary rounded-lg">
|
||||
<ArrowLeft className="w-5 h-5 text-text-secondary" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="flex-1 text-xl font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 placeholder:text-text-tertiary"
|
||||
placeholder={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
{STATUS_OPTS.map(s => (
|
||||
<option key={s} value={s}>{t(`posts.status.${s}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={brandId}
|
||||
onChange={e => setBrandId(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.selectBrand')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={campaignId}
|
||||
onChange={e => setCampaignId(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.noCampaign')}</option>
|
||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={assignedTo}
|
||||
onChange={e => setAssignedTo(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Platforms */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{Object.entries(PLATFORMS).map(([key, p]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => togglePlatform(key)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors ${
|
||||
platforms.includes(key)
|
||||
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
|
||||
: 'border-border text-text-tertiary hover:border-brand-primary/40'
|
||||
}`}
|
||||
>
|
||||
<PlatformIcon platform={key} size={14} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date + Save */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledDate}
|
||||
onChange={e => setScheduledDate(e.target.value)}
|
||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── ASSET CARDS ─── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AssetCard
|
||||
type="caption"
|
||||
label={t('postDetail.captionCopy')}
|
||||
icon={Type}
|
||||
piece={composition?.caption}
|
||||
activePicker={activePicker}
|
||||
pickerSearch={pickerSearch}
|
||||
filteredCandidates={filteredCandidates}
|
||||
linking={linking}
|
||||
onOpen={() => handleOpenPiece('caption')}
|
||||
onUnlink={() => handleUnlink('caption')}
|
||||
onOpenPicker={() => openLinkPicker('caption')}
|
||||
onCreate={() => handleCreate('caption')}
|
||||
onLink={handleLink}
|
||||
onPickerSearchChange={setPickerSearch}
|
||||
onClosePicker={() => setActivePicker(null)}
|
||||
t={t}
|
||||
/>
|
||||
<AssetCard
|
||||
type="body"
|
||||
label={t('postDetail.bodyCopy')}
|
||||
icon={FileText}
|
||||
piece={composition?.body_copy}
|
||||
activePicker={activePicker}
|
||||
pickerSearch={pickerSearch}
|
||||
filteredCandidates={filteredCandidates}
|
||||
linking={linking}
|
||||
onOpen={() => handleOpenPiece('body')}
|
||||
onUnlink={() => handleUnlink('body')}
|
||||
onOpenPicker={() => openLinkPicker('body')}
|
||||
onCreate={() => handleCreate('body')}
|
||||
onLink={handleLink}
|
||||
onPickerSearchChange={setPickerSearch}
|
||||
onClosePicker={() => setActivePicker(null)}
|
||||
t={t}
|
||||
/>
|
||||
<AssetCard
|
||||
type="design"
|
||||
label={t('postDetail.design')}
|
||||
icon={ImageIcon}
|
||||
piece={composition?.design}
|
||||
activePicker={activePicker}
|
||||
pickerSearch={pickerSearch}
|
||||
filteredCandidates={filteredCandidates}
|
||||
linking={linking}
|
||||
onOpen={() => handleOpenPiece('design')}
|
||||
onUnlink={() => handleUnlink('design')}
|
||||
onOpenPicker={() => openLinkPicker('design')}
|
||||
onCreate={() => handleCreate('design')}
|
||||
onLink={handleLink}
|
||||
onPickerSearchChange={setPickerSearch}
|
||||
onClosePicker={() => setActivePicker(null)}
|
||||
t={t}
|
||||
/>
|
||||
<AssetCard
|
||||
type="video"
|
||||
label={t('postDetail.video')}
|
||||
icon={Film}
|
||||
piece={composition?.video}
|
||||
activePicker={activePicker}
|
||||
pickerSearch={pickerSearch}
|
||||
filteredCandidates={filteredCandidates}
|
||||
linking={linking}
|
||||
onOpen={() => handleOpenPiece('video')}
|
||||
onUnlink={() => handleUnlink('video')}
|
||||
onOpenPicker={() => openLinkPicker('video')}
|
||||
onCreate={() => handleCreate('video')}
|
||||
onLink={handleLink}
|
||||
onPickerSearchChange={setPickerSearch}
|
||||
onClosePicker={() => setActivePicker(null)}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── READINESS ─── */}
|
||||
<div className="bg-surface rounded-xl border border-border p-5">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('postDetail.readiness')}</h3>
|
||||
{!hasPieces ? (
|
||||
<p className="text-sm text-text-tertiary">{t('postDetail.noAssets')}</p>
|
||||
) : piecesReady ? (
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<Clock className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{t('postDetail.waitingOn')}: {waitingOn.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── COMMENTS ─── */}
|
||||
<div className="bg-surface rounded-xl border border-border p-5">
|
||||
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('posts.discussion')}</h3>
|
||||
<CommentsSection entityType="post" entityId={Number(id)} />
|
||||
</div>
|
||||
|
||||
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
|
||||
{openTranslation && (
|
||||
<TranslationDetailPanel
|
||||
translation={openTranslation}
|
||||
onClose={() => { setOpenTranslation(null); loadComposition() }}
|
||||
onUpdate={loadComposition}
|
||||
onDelete={() => { setOpenTranslation(null); loadComposition() }}
|
||||
assignableUsers={teamMembers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openArtefact && (
|
||||
<ArtefactDetailPanel
|
||||
artefact={openArtefact}
|
||||
onClose={() => { setOpenArtefact(null); loadComposition() }}
|
||||
onUpdate={loadComposition}
|
||||
onDelete={() => { setOpenArtefact(null); loadComposition() }}
|
||||
assignableUsers={teamMembers}
|
||||
projects={[]}
|
||||
campaigns={campaigns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Asset Card Component ───
|
||||
|
||||
function AssetCard({
|
||||
type, label, icon: Icon, piece,
|
||||
activePicker, pickerSearch, filteredCandidates, linking,
|
||||
onOpen, onUnlink, onOpenPicker, onCreate, onLink,
|
||||
onPickerSearchChange, onClosePicker, t,
|
||||
}) {
|
||||
const isPickerOpen = activePicker === type
|
||||
const isCopy = type === 'caption' || type === 'body'
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className="w-4 h-4 text-text-tertiary" />
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</h4>
|
||||
</div>
|
||||
|
||||
{piece ? (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
{/* Thumbnail for design/video */}
|
||||
{!isCopy && piece.thumbnail_url && (
|
||||
<div className="mb-3 rounded-lg overflow-hidden border border-border-light bg-surface-secondary">
|
||||
<img src={piece.thumbnail_url} alt={piece.title} className="w-full h-32 object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{!isCopy && !piece.thumbnail_url && (
|
||||
<div className="mb-3 rounded-lg border border-border-light bg-surface-secondary flex items-center justify-center h-24">
|
||||
<Icon className="w-8 h-8 text-text-tertiary/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-text-primary truncate">{piece.title}</span>
|
||||
<StatusBadge status={piece.status} size="xs" />
|
||||
</div>
|
||||
|
||||
{/* Copy: show content preview + languages */}
|
||||
{isCopy && piece.content_preview && (
|
||||
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{piece.content_preview}</p>
|
||||
)}
|
||||
{isCopy && piece.languages && piece.languages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{piece.languages.map((l, i) => (
|
||||
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
l.status === 'approved' ? 'bg-emerald-100 text-emerald-700' :
|
||||
l.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-surface-tertiary text-text-tertiary'
|
||||
}`}>
|
||||
{l.language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isCopy && (!piece.languages || piece.languages.length === 0) && piece.language && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{piece.language}</p>
|
||||
)}
|
||||
|
||||
{/* Design/Video: version info */}
|
||||
{!isCopy && piece.current_version && (
|
||||
<p className="text-xs text-text-tertiary mt-1">v{piece.current_version}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('postDetail.open')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUnlink}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Unlink className="w-3.5 h-3.5" />
|
||||
{t('postDetail.unlink')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 flex items-center justify-center py-4">
|
||||
<p className="text-sm text-text-tertiary">{t('postDetail.notLinked')}</p>
|
||||
</div>
|
||||
{!isPickerOpen && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
|
||||
<button
|
||||
onClick={onOpenPicker}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('postDetail.linkExisting')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('postDetail.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Inline picker */}
|
||||
{isPickerOpen && (
|
||||
<div className="mt-3 pt-3 border-t border-border-light animate-fade-in">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute start-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
value={pickerSearch}
|
||||
onChange={e => onPickerSearchChange(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full ps-7 pe-2 py-1.5 text-xs border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button onClick={onClosePicker} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<X className="w-3.5 h-3.5 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{filteredCandidates.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary text-center py-2">{t('common.noResults')}</p>
|
||||
) : (
|
||||
filteredCandidates.slice(0, 10).map(c => (
|
||||
<button
|
||||
key={c._id || c.id}
|
||||
onClick={() => onLink(c._id || c.id)}
|
||||
disabled={linking}
|
||||
className="w-full text-start px-2 py-2 text-xs rounded-lg hover:bg-surface-secondary transition-colors flex items-start gap-2 disabled:opacity-50"
|
||||
>
|
||||
{/* Thumbnail for artefacts */}
|
||||
{!isCopy && (c.thumbnail_url || c.file_url) && (
|
||||
<img src={c.thumbnail_url || c.file_url} alt="" className="w-10 h-10 rounded object-cover shrink-0" loading="lazy" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-text-primary font-medium">{c.title || t('common.untitled')}</span>
|
||||
<StatusBadge status={c.status} size="xs" />
|
||||
</div>
|
||||
{/* Copy: show source language + content preview */}
|
||||
{isCopy && (
|
||||
<p className="text-text-tertiary mt-0.5 truncate">
|
||||
{c.source_language && <span className="uppercase">{c.source_language} · </span>}
|
||||
{(c.source_content || '').slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
{/* Artefact: show type */}
|
||||
{!isCopy && c.type && (
|
||||
<p className="text-text-tertiary mt-0.5 capitalize">{c.type}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user