e1d1c392eb
Audit & Quality: - RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties - A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons - Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens - Performance: useMemo on filters, loading="lazy" on 24 images - CSS: prefers-reduced-motion, removed dead animations Component Splits: - PostDetailPanel: 1332→623 lines + 4 sub-components - ArtefactDetailPanel: 972→590 lines + 1 sub-component Brand Identity — Rawaj (رواج): - New name, DM Sans font, deep teal palette (#0d9488) - Custom SVG logo, forest-tinted dark mode - All emails branded with app name in subject line Design Refinement: - Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats - Quieter: removed card lift, brand glow, gradient text, mesh backgrounds - CampaignDetail: prominent budget card, compact team avatars, Lucide icons - Consistent page titles via Header.jsx, standardized section headers - Finance page fully i18n'd (20+ hardcoded strings replaced) Budget Allocation Redesign: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Validation at all levels: main→campaign→track, expenses blocked if insufficient - Budget request workflow with CEO approval via public link - BudgetRequests table, CRUD routes, public approval page - Budget mutex for race condition prevention - Idempotent migration for existing campaign budgets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
19 KiB
React
430 lines
19 KiB
React
import { useState } from 'react'
|
|
import { Plus, Upload, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import Modal from './Modal'
|
|
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
|
|
|
|
const AVAILABLE_LANGUAGES = [
|
|
{ code: 'AR', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },
|
|
{ code: 'EN', label: 'English' },
|
|
{ code: 'FR', label: 'Fran\u00E7ais' },
|
|
{ code: 'ID', label: 'Bahasa Indonesia' },
|
|
]
|
|
|
|
export function ArtefactDetailVersionsTab({
|
|
artefact,
|
|
versions,
|
|
selectedVersion,
|
|
versionData,
|
|
uploading,
|
|
uploadProgress,
|
|
onSelectVersion,
|
|
onCreateVersion,
|
|
onAddLanguage,
|
|
onDeleteLanguage,
|
|
onFileUpload,
|
|
onDeleteAttachment,
|
|
onAddDriveVideo,
|
|
getDriveEmbedUrl,
|
|
}) {
|
|
const { t } = useLanguage()
|
|
|
|
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
|
|
const [newVersionNotes, setNewVersionNotes] = useState('')
|
|
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
|
|
const [creatingVersion, setCreatingVersion] = useState(false)
|
|
|
|
const [showLanguageModal, setShowLanguageModal] = useState(false)
|
|
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
|
const [savingLanguage, setSavingLanguage] = useState(false)
|
|
|
|
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
|
|
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
|
|
|
|
const [dragOver, setDragOver] = useState(false)
|
|
const [driveUrl, setDriveUrl] = useState('')
|
|
|
|
const handleCreateVersion = async () => {
|
|
setCreatingVersion(true)
|
|
try {
|
|
await onCreateVersion({
|
|
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
|
|
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
|
|
})
|
|
setShowNewVersionModal(false)
|
|
setNewVersionNotes('')
|
|
setCopyFromPrevious(false)
|
|
} finally {
|
|
setCreatingVersion(false)
|
|
}
|
|
}
|
|
|
|
const handleAddLanguage = async () => {
|
|
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
|
|
setSavingLanguage(true)
|
|
try {
|
|
await onAddLanguage(languageForm)
|
|
setShowLanguageModal(false)
|
|
setLanguageForm({ language_code: '', language_label: '', content: '' })
|
|
} finally {
|
|
setSavingLanguage(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteLanguage = async (textId) => {
|
|
await onDeleteLanguage(textId)
|
|
setConfirmDeleteLangId(null)
|
|
}
|
|
|
|
const handleDeleteAttachment = async (attId) => {
|
|
await onDeleteAttachment(attId)
|
|
setConfirmDeleteAttId(null)
|
|
}
|
|
|
|
const handleVideoDrop = (e) => {
|
|
e.preventDefault()
|
|
setDragOver(false)
|
|
const file = e.dataTransfer.files?.[0]
|
|
if (file && file.type.startsWith('video/')) {
|
|
onFileUpload(file)
|
|
}
|
|
}
|
|
|
|
const handleAddDriveVideo = async () => {
|
|
if (!driveUrl.trim()) return
|
|
await onAddDriveVideo(driveUrl)
|
|
setDriveUrl('')
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="p-6 space-y-5">
|
|
{/* Version Timeline */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
|
<button
|
|
onClick={() => setShowNewVersionModal(true)}
|
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t('artefacts.newVersion')}
|
|
</button>
|
|
</div>
|
|
<ArtefactVersionTimeline
|
|
versions={versions}
|
|
activeVersionId={selectedVersion?.Id}
|
|
onSelectVersion={onSelectVersion}
|
|
artefactType={artefact.type}
|
|
/>
|
|
</div>
|
|
|
|
{/* Type-specific content */}
|
|
{versionData && selectedVersion && (
|
|
<div className="border-t border-border pt-5">
|
|
{/* COPY TYPE: Language entries */}
|
|
{artefact.type === 'copy' && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.languages')}</h4>
|
|
<button
|
|
onClick={() => setShowLanguageModal(true)}
|
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t('artefacts.addLanguage')}
|
|
</button>
|
|
</div>
|
|
|
|
{versionData.texts && versionData.texts.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{versionData.texts.map(text => (
|
|
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
|
|
{text.language_code}
|
|
</span>
|
|
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setConfirmDeleteLangId(text.Id)}
|
|
className="text-red-600 hover:text-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
|
{text.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
|
<Globe className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
|
<p className="text-sm text-text-secondary">{t('artefacts.noLanguages')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* DESIGN TYPE: Image gallery */}
|
|
{artefact.type === 'design' && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
|
|
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
|
|
<Upload className="w-3 h-3" />
|
|
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
|
|
<input
|
|
type="file"
|
|
className="hidden"
|
|
accept="image/*"
|
|
onChange={onFileUpload}
|
|
disabled={uploading}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{versionData.attachments && versionData.attachments.length > 0 ? (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{versionData.attachments.map(att => (
|
|
<div key={att.Id} className="relative group">
|
|
<img
|
|
src={att.url}
|
|
alt={att.original_name}
|
|
className="w-full h-48 object-cover rounded-lg border border-border"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
|
|
<button
|
|
onClick={() => setConfirmDeleteAttId(att.Id)}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity px-3 py-2 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="mt-1 px-2 py-1 bg-surface-secondary rounded text-xs text-text-secondary truncate">
|
|
{att.original_name}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
|
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
|
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* VIDEO TYPE: Files and Drive links -- all inline */}
|
|
{artefact.type === 'video' && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
|
|
|
|
{/* Existing attachments */}
|
|
{versionData.attachments && versionData.attachments.length > 0 && (
|
|
<div className="space-y-3 mb-4">
|
|
{versionData.attachments.map(att => (
|
|
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
|
{att.drive_url ? (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
|
|
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
|
|
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<video src={att.url} controls className="w-full rounded border border-border" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Drag-and-drop / click-to-upload zone */}
|
|
<label
|
|
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
|
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
|
|
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleVideoDrop}
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
|
|
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
|
|
</div>
|
|
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-7 h-7 text-text-tertiary" />
|
|
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
|
|
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
|
|
</>
|
|
)}
|
|
<input type="file" className="hidden" accept="video/*" onChange={onFileUpload} disabled={uploading} />
|
|
</label>
|
|
|
|
{/* Google Drive URL inline input */}
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
|
|
<input
|
|
type="text"
|
|
value={driveUrl}
|
|
onChange={e => setDriveUrl(e.target.value)}
|
|
placeholder="https://drive.google.com/file/d/..."
|
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
|
|
/>
|
|
<button
|
|
onClick={handleAddDriveVideo}
|
|
disabled={uploading || !driveUrl.trim()}
|
|
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
|
|
>
|
|
{t('artefacts.addLink')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Language Modal */}
|
|
<Modal isOpen={showLanguageModal} onClose={() => setShowLanguageModal(false)} title={t('artefacts.addLanguage')} size="md">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
|
<select
|
|
value={languageForm.language_code}
|
|
onChange={e => {
|
|
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
|
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
|
|
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
|
|
}}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
>
|
|
<option value="">{t('artefacts.selectLanguage')}</option>
|
|
{AVAILABLE_LANGUAGES
|
|
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
|
|
.map(lang => (
|
|
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
|
|
))
|
|
}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
|
|
<textarea
|
|
value={languageForm.content}
|
|
onChange={e => setLanguageForm(f => ({ ...f, content: e.target.value }))}
|
|
rows={8}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
|
|
placeholder={t('artefacts.enterContent')}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
<button
|
|
onClick={() => setShowLanguageModal(false)}
|
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleAddLanguage}
|
|
disabled={savingLanguage}
|
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
|
>
|
|
{savingLanguage ? t('header.saving') : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* New Version Modal */}
|
|
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
|
|
<textarea
|
|
value={newVersionNotes}
|
|
onChange={e => setNewVersionNotes(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
placeholder={t('artefacts.whatChanged')}
|
|
/>
|
|
</div>
|
|
{artefact.type === 'copy' && versions.length > 0 && (
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={copyFromPrevious}
|
|
onChange={e => setCopyFromPrevious(e.target.checked)}
|
|
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
|
|
/>
|
|
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
|
|
</label>
|
|
)}
|
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
<button
|
|
onClick={() => setShowNewVersionModal(false)}
|
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleCreateVersion}
|
|
disabled={creatingVersion}
|
|
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
|
>
|
|
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Delete Language Confirmation */}
|
|
<Modal
|
|
isOpen={!!confirmDeleteLangId}
|
|
onClose={() => setConfirmDeleteLangId(null)}
|
|
title={t('artefacts.deleteLanguage')}
|
|
isConfirm
|
|
danger
|
|
onConfirm={() => handleDeleteLanguage(confirmDeleteLangId)}
|
|
confirmText={t('common.delete')}
|
|
>
|
|
{t('artefacts.deleteLanguageDesc')}
|
|
</Modal>
|
|
|
|
{/* Delete Attachment Confirmation */}
|
|
<Modal
|
|
isOpen={!!confirmDeleteAttId}
|
|
onClose={() => setConfirmDeleteAttId(null)}
|
|
title={t('artefacts.deleteAttachment')}
|
|
isConfirm
|
|
danger
|
|
onConfirm={() => handleDeleteAttachment(confirmDeleteAttId)}
|
|
confirmText={t('common.delete')}
|
|
>
|
|
{t('artefacts.deleteAttachmentDesc')}
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|