94ce012837
Deploy / deploy (push) Successful in 13s
- Auto-advance artefact to next working version on rejection/revision - Add post_id field to artefact creation - Add request timeout (20s) to NocoDB client - Add POST /api/admin/test-email for diagnosing SMTP issues - Fix FK column creation logging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
627 lines
25 KiB
React
627 lines
25 KiB
React
import { useState, useEffect, useContext, useCallback, useRef } 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, ExternalLink } 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 PortalSelect from '../components/PortalSelect'
|
|
import CommentsSection from '../components/CommentsSection'
|
|
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
|
|
import { useToast } from '../components/ToastContainer'
|
|
|
|
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
|
|
|
|
// Maps asset type key → composition field name
|
|
const PIECE_MAP = { caption: 'caption', body: 'body_copy', design: 'design', video: 'video' }
|
|
// Maps asset type key → i18n label key
|
|
const LABEL_KEYS = {
|
|
caption: 'postDetail.captionCopy',
|
|
body: 'postDetail.bodyCopy',
|
|
design: 'postDetail.design',
|
|
video: 'postDetail.video',
|
|
}
|
|
const ASSET_ICONS = { caption: Type, body: FileText, design: ImageIcon, video: Film }
|
|
const ASSET_TYPES = ['caption', 'body', 'design', 'video']
|
|
// Maps server-generated waiting_on labels → asset type key
|
|
const WAITING_TYPE_MAP = { Caption: 'caption', Copy: 'body', Design: 'design', Video: 'video' }
|
|
|
|
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 / create
|
|
const [creating, setCreating] = useState(false)
|
|
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
|
|
const [pickerSearch, setPickerSearch] = useState('')
|
|
const [linkCandidates, setLinkCandidates] = useState([])
|
|
const [linking, setLinking] = useState(false)
|
|
const allArtefactsRef = useRef(null)
|
|
|
|
// Sub-panels
|
|
const [openArtefact, setOpenArtefact] = useState(null)
|
|
|
|
const loadPost = useCallback(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)
|
|
}
|
|
}, [id])
|
|
|
|
useEffect(() => {
|
|
loadPost()
|
|
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
|
|
}, [loadPost])
|
|
|
|
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'))
|
|
// Update local post state — composition is unaffected by metadata changes
|
|
setPost(p => ({ ...p, title, status, brand_id: brandId, campaign_id: campaignId, assigned_to: assignedTo, platforms, scheduled_date: scheduledDate || null }))
|
|
} 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 TYPE_FILTERS = {
|
|
caption: a => a.type === 'copy' && a.copy_type === 'caption',
|
|
body: a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type),
|
|
video: a => a.type === 'video',
|
|
design: a => (a.type || 'design') === 'design',
|
|
}
|
|
|
|
const openLinkPicker = async (type) => {
|
|
setActivePicker(type)
|
|
setPickerSearch('')
|
|
try {
|
|
if (!allArtefactsRef.current) allArtefactsRef.current = await api.get('/artefacts')
|
|
const all = Array.isArray(allArtefactsRef.current) ? allArtefactsRef.current : []
|
|
setLinkCandidates(all.filter(a => {
|
|
const linkedTo = a.post_id || a.postId
|
|
return TYPE_FILTERS[type](a) && (!linkedTo || String(linkedTo) !== String(id))
|
|
}))
|
|
} catch {
|
|
setLinkCandidates([])
|
|
toast.error(t('common.error'))
|
|
}
|
|
}
|
|
|
|
const handleLink = async (itemId) => {
|
|
setLinking(true)
|
|
try {
|
|
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
|
allArtefactsRef.current = null
|
|
toast.success(t('posts.updated'))
|
|
setActivePicker(null)
|
|
loadComposition()
|
|
} catch {
|
|
toast.error(t('common.saveFailed'))
|
|
} finally {
|
|
setLinking(false)
|
|
}
|
|
}
|
|
|
|
const handleUnlink = async (type) => {
|
|
const piece = composition?.[PIECE_MAP[type]]
|
|
if (!piece) return
|
|
try {
|
|
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
|
|
allArtefactsRef.current = null
|
|
toast.success(t('posts.updated'))
|
|
loadComposition()
|
|
} catch {
|
|
toast.error(t('common.saveFailed'))
|
|
}
|
|
}
|
|
|
|
const handleOpenPiece = async (type) => {
|
|
const piece = composition?.[PIECE_MAP[type]]
|
|
if (!piece) return
|
|
try {
|
|
const full = await api.get(`/artefacts/${piece.id}`)
|
|
setOpenArtefact(full)
|
|
} catch { toast.error(t('common.saveFailed')) }
|
|
}
|
|
|
|
const handleCreate = async (type) => {
|
|
if (creating) return
|
|
setCreating(true)
|
|
try {
|
|
const created = await api.post('/artefacts', {
|
|
title: title.trim() ? `${t(LABEL_KEYS[type])} — ${title.trim()}` : t(LABEL_KEYS[type]),
|
|
type: type === 'caption' || type === 'body' ? 'copy' : type,
|
|
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
|
|
post_id: Number(id),
|
|
})
|
|
allArtefactsRef.current = null
|
|
setOpenArtefact(created)
|
|
loadComposition()
|
|
} catch {
|
|
toast.error(t('common.saveFailed'))
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
// ─── 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 isDirty = Boolean(post) && (
|
|
title !== (post.title || '') ||
|
|
status !== (post.status || 'draft') ||
|
|
String(brandId) !== String(post.brand_id || post.brandId || '') ||
|
|
String(campaignId) !== String(post.campaign_id || post.campaignId || '') ||
|
|
String(assignedTo) !== String(post.assigned_to || post.assignedTo || '') ||
|
|
JSON.stringify(platforms) !== JSON.stringify(Array.isArray(post.platforms) ? post.platforms : (post.platform ? [post.platform] : [])) ||
|
|
scheduledDate !== ((post.scheduled_date || post.scheduledDate) ? new Date(post.scheduled_date || post.scheduledDate).toISOString().slice(0, 10) : '')
|
|
)
|
|
|
|
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">
|
|
<PortalSelect
|
|
value={status}
|
|
onChange={val => setStatus(val)}
|
|
options={STATUS_OPTS.map(s => ({ value: s, label: t(`posts.status.${s}`) }))}
|
|
className="text-xs"
|
|
/>
|
|
|
|
<PortalSelect
|
|
value={brandId}
|
|
onChange={val => setBrandId(val)}
|
|
options={[
|
|
{ value: '', label: t('posts.selectBrand') },
|
|
...brands.map(b => ({ value: String(b._id), label: lang === 'ar' && b.name_ar ? b.name_ar : b.name }))
|
|
]}
|
|
placeholder={t('posts.selectBrand')}
|
|
className="text-xs"
|
|
/>
|
|
|
|
<PortalSelect
|
|
value={campaignId}
|
|
onChange={val => setCampaignId(val)}
|
|
options={[
|
|
{ value: '', label: t('posts.noCampaign') },
|
|
...campaigns.map(c => ({ value: String(c._id || c.id), label: c.name }))
|
|
]}
|
|
placeholder={t('posts.noCampaign')}
|
|
className="text-xs"
|
|
/>
|
|
|
|
<PortalSelect
|
|
value={assignedTo}
|
|
onChange={val => setAssignedTo(val)}
|
|
options={[
|
|
{ value: '', label: t('common.unassigned') },
|
|
...teamMembers.map(m => ({ value: String(m._id), label: m.name }))
|
|
]}
|
|
placeholder={t('common.unassigned')}
|
|
className="text-xs"
|
|
/>
|
|
</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 rounded-lg text-sm font-medium shadow-sm disabled:opacity-50 transition-colors ${
|
|
isDirty ? 'bg-amber-500 hover:bg-amber-600 text-white' : 'bg-brand-primary hover:bg-brand-primary-light text-white'
|
|
}`}
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{saving ? t('common.loading') : t('common.save')}
|
|
{isDirty && !saving && <span className="w-1.5 h-1.5 rounded-full bg-white/70 ms-0.5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── ASSET CARDS ─── */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{ASSET_TYPES.map(type => (
|
|
<AssetCard
|
|
key={type}
|
|
id={`asset-${type}`}
|
|
type={type}
|
|
label={t(LABEL_KEYS[type])}
|
|
icon={ASSET_ICONS[type]}
|
|
piece={composition?.[PIECE_MAP[type]]}
|
|
onCreate={() => handleCreate(type)}
|
|
creating={creating}
|
|
onOpen={() => handleOpenPiece(type)}
|
|
onUnlink={() => handleUnlink(type)}
|
|
onOpenPicker={() => openLinkPicker(type)}
|
|
activePicker={activePicker}
|
|
pickerSearch={pickerSearch}
|
|
filteredCandidates={filteredCandidates}
|
|
linking={linking}
|
|
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-start gap-2 text-amber-600">
|
|
<Clock className="w-5 h-5 shrink-0 mt-0.5" />
|
|
<div className="flex flex-wrap gap-1.5 items-center">
|
|
<span className="text-sm font-medium">{t('postDetail.waitingOn')}:</span>
|
|
{waitingOn.map(label => {
|
|
const type = WAITING_TYPE_MAP[label]
|
|
return type ? (
|
|
<button
|
|
key={label}
|
|
onClick={() => document.getElementById(`asset-${type}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
|
|
className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors font-medium"
|
|
>
|
|
{label}
|
|
</button>
|
|
) : <span key={label} className="text-sm">{label}</span>
|
|
})}
|
|
</div>
|
|
</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) ─── */}
|
|
{openArtefact && (
|
|
<ArtefactDetailPanel
|
|
key={openArtefact._id}
|
|
artefact={openArtefact}
|
|
onClose={() => { setOpenArtefact(null); loadComposition() }}
|
|
onUpdate={loadComposition}
|
|
onDelete={() => { setOpenArtefact(null); loadComposition() }}
|
|
assignableUsers={teamMembers}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Asset Card Component ───
|
|
|
|
function AssetCard({
|
|
id, type, label, icon: Icon, piece,
|
|
onCreate, creating, onOpen, onUnlink,
|
|
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
|
|
onLink, onPickerSearchChange, onClosePicker, t,
|
|
}) {
|
|
const isPickerOpen = activePicker === type
|
|
const isCopy = type === 'caption' || type === 'body'
|
|
|
|
const isPending = piece?.status === 'pending_review'
|
|
const isApproved = piece?.status === 'approved'
|
|
|
|
return (
|
|
<div id={id} className="bg-surface rounded-xl border border-border p-4 flex flex-col">
|
|
{/* Header */}
|
|
<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 flex-1">{label}</h4>
|
|
</div>
|
|
|
|
{/* ─── State 2: Linked ─── */}
|
|
{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: 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>
|
|
)}
|
|
|
|
{/* Approval info */}
|
|
<div className="mt-3 space-y-2">
|
|
{isPending && piece.approver_name && (
|
|
<p className="text-xs text-amber-600 flex items-center gap-1.5">
|
|
<Clock className="w-3.5 h-3.5" />
|
|
{t('postDetail.pendingReviewBy')} {piece.approver_name}
|
|
</p>
|
|
)}
|
|
{isApproved && (
|
|
<p className="text-xs text-emerald-600 flex items-center gap-1.5">
|
|
<CheckCircle className="w-3.5 h-3.5" />
|
|
{t('postDetail.approved')}{piece.approver_name ? ` — ${piece.approver_name}` : ''}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Open + Unlink */}
|
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border-light">
|
|
<button
|
|
onClick={onOpen}
|
|
className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
{t('postDetail.viewDetails')}
|
|
</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>
|
|
</>
|
|
)}
|
|
|
|
{/* ─── State 1: Empty (no asset) ─── */}
|
|
{!piece && (
|
|
<>
|
|
<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}
|
|
disabled={creating}
|
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
{creating ? t('common.loading') : t('postDetail.createNew')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Inline link 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"
|
|
>
|
|
{!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>
|
|
{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>
|
|
)}
|
|
{!isCopy && c.type && (
|
|
<p className="text-text-tertiary mt-0.5 capitalize">{c.type}</p>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|