refactor: PostDetail cards as status dashboard, panel for creation
Reverted inline asset creation — the copy workflow (original → approve → translate → approve translations) needs the full panel, not a card form. PostDetail cards now: - "Create new" → creates asset (type pre-set, post linked) → opens panel - "Open" → opens panel for editing/reviewing - Card shows: title, status, preview, approval info (status dashboard) Panel handles: write copy, add translations, upload files, select reviewer, submit for review — the full workflow in its proper workspace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1185,6 +1185,20 @@
|
||||
"postDetail.createNew": "إنشاء جديد",
|
||||
"postDetail.open": "فتح",
|
||||
"postDetail.unlink": "إلغاء الربط",
|
||||
"postDetail.viewDetails": "عرض التفاصيل",
|
||||
"postDetail.reviewer": "المراجع",
|
||||
"postDetail.selectReviewer": "اختر المراجع",
|
||||
"postDetail.submitForReview": "إرسال للمراجعة",
|
||||
"postDetail.pendingReviewBy": "بانتظار مراجعة",
|
||||
"postDetail.approved": "تمت الموافقة",
|
||||
"postDetail.sourceLanguage": "اللغة المصدر",
|
||||
"postDetail.content": "المحتوى",
|
||||
"postDetail.contentPlaceholder": "اكتب النص...",
|
||||
"postDetail.files": "الملفات",
|
||||
"postDetail.dragDropFiles": "اسحب وأفلت أو انقر للرفع",
|
||||
"postDetail.addMoreFiles": "إضافة ملفات أخرى",
|
||||
"postDetail.createAndSubmit": "إنشاء وإرسال للمراجعة",
|
||||
"postDetail.create": "إنشاء",
|
||||
"finance.campaign": "الحملة",
|
||||
"finance.budgetAssigned": "الميزانية المخصصة",
|
||||
"finance.trackAllocated": "المسار المخصص",
|
||||
|
||||
@@ -1185,6 +1185,20 @@
|
||||
"postDetail.createNew": "Create new",
|
||||
"postDetail.open": "Open",
|
||||
"postDetail.unlink": "Unlink",
|
||||
"postDetail.viewDetails": "View details",
|
||||
"postDetail.reviewer": "Reviewer",
|
||||
"postDetail.selectReviewer": "Select reviewer",
|
||||
"postDetail.submitForReview": "Submit for Review",
|
||||
"postDetail.pendingReviewBy": "Pending review by",
|
||||
"postDetail.approved": "Approved",
|
||||
"postDetail.sourceLanguage": "Source Language",
|
||||
"postDetail.content": "Content",
|
||||
"postDetail.contentPlaceholder": "Write the copy text...",
|
||||
"postDetail.files": "Files",
|
||||
"postDetail.dragDropFiles": "Drag & drop or click to upload",
|
||||
"postDetail.addMoreFiles": "Add more files",
|
||||
"postDetail.createAndSubmit": "Create & Submit for Review",
|
||||
"postDetail.create": "Create",
|
||||
"finance.campaign": "Campaign",
|
||||
"finance.budgetAssigned": "Budget Assigned",
|
||||
"finance.trackAllocated": "Track Allocated",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { 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'
|
||||
@@ -172,46 +172,6 @@ export default function PostDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -231,6 +191,32 @@ export default function PostDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (type) => {
|
||||
const isCopy = type === 'caption' || type === 'body'
|
||||
try {
|
||||
if (isCopy) {
|
||||
const created = await api.post('/translations', {
|
||||
title: `${title} — ${type === 'caption' ? t('postDetail.captionCopy') : t('postDetail.bodyCopy')}`,
|
||||
source_language: 'AR',
|
||||
source_content: '',
|
||||
post_id: Number(id),
|
||||
copy_type: type,
|
||||
})
|
||||
setOpenTranslation(created)
|
||||
} else {
|
||||
const created = await api.post('/artefacts', {
|
||||
title: `${type === 'design' ? t('postDetail.design') : t('postDetail.video')} — ${title}`,
|
||||
type: type,
|
||||
post_id: Number(id),
|
||||
})
|
||||
setOpenArtefact(created)
|
||||
}
|
||||
loadComposition()
|
||||
} catch (err) {
|
||||
toast.error(t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rendering ───
|
||||
|
||||
if (loading) {
|
||||
@@ -371,14 +357,14 @@ export default function PostDetail() {
|
||||
label={t('postDetail.captionCopy')}
|
||||
icon={Type}
|
||||
piece={composition?.caption}
|
||||
onCreate={() => handleCreate('caption')}
|
||||
onOpen={() => handleOpenPiece('caption')}
|
||||
onUnlink={() => handleUnlink('caption')}
|
||||
onOpenPicker={() => openLinkPicker('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)}
|
||||
@@ -389,14 +375,14 @@ export default function PostDetail() {
|
||||
label={t('postDetail.bodyCopy')}
|
||||
icon={FileText}
|
||||
piece={composition?.body_copy}
|
||||
onCreate={() => handleCreate('body')}
|
||||
onOpen={() => handleOpenPiece('body')}
|
||||
onUnlink={() => handleUnlink('body')}
|
||||
onOpenPicker={() => openLinkPicker('body')}
|
||||
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)}
|
||||
@@ -407,14 +393,14 @@ export default function PostDetail() {
|
||||
label={t('postDetail.design')}
|
||||
icon={ImageIcon}
|
||||
piece={composition?.design}
|
||||
onCreate={() => handleCreate('design')}
|
||||
onOpen={() => handleOpenPiece('design')}
|
||||
onUnlink={() => handleUnlink('design')}
|
||||
onOpenPicker={() => openLinkPicker('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)}
|
||||
@@ -425,14 +411,14 @@ export default function PostDetail() {
|
||||
label={t('postDetail.video')}
|
||||
icon={Film}
|
||||
piece={composition?.video}
|
||||
onCreate={() => handleCreate('video')}
|
||||
onOpen={() => handleOpenPiece('video')}
|
||||
onUnlink={() => handleUnlink('video')}
|
||||
onOpenPicker={() => openLinkPicker('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)}
|
||||
@@ -494,21 +480,26 @@ export default function PostDetail() {
|
||||
|
||||
function AssetCard({
|
||||
type, label, icon: Icon, piece,
|
||||
activePicker, pickerSearch, filteredCandidates, linking,
|
||||
onOpen, onUnlink, onOpenPicker, onCreate, onLink,
|
||||
onPickerSearchChange, onClosePicker, t,
|
||||
onCreate, 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 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">{label}</h4>
|
||||
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium flex-1">{label}</h4>
|
||||
</div>
|
||||
|
||||
{piece ? (
|
||||
{/* ─── State 2: Linked ─── */}
|
||||
{piece && (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
{/* Thumbnail for design/video */}
|
||||
@@ -528,7 +519,7 @@ function AssetCard({
|
||||
<StatusBadge status={piece.status} size="xs" />
|
||||
</div>
|
||||
|
||||
{/* Copy: show content preview + languages */}
|
||||
{/* Copy: content preview + languages */}
|
||||
{isCopy && piece.content_preview && (
|
||||
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{piece.content_preview}</p>
|
||||
)}
|
||||
@@ -553,14 +544,32 @@ function AssetCard({
|
||||
{!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>
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
|
||||
|
||||
{/* 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 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
|
||||
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"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('postDetail.open')}
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{t('postDetail.viewDetails')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUnlink}
|
||||
@@ -571,7 +580,10 @@ function AssetCard({
|
||||
</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>
|
||||
@@ -597,7 +609,7 @@ function AssetCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Inline picker */}
|
||||
{/* 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">
|
||||
@@ -627,7 +639,6 @@ function AssetCard({
|
||||
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" />
|
||||
)}
|
||||
@@ -636,14 +647,12 @@ function AssetCard({
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user