Files
marketing-app/client/src/components/ArtefactDetailVersionsTab.jsx
T
fahed 49e1a796ed fix: code review — security, dead code, performance, consistency
Critical fixes:
- XSS: escapeHtml() on all user-supplied text in email notifications
- Budget PATCH: added mutex lock + availability validation (prevents corruption)
- batchResolveNames: fixed wrong signature for budget request earmark names

Dead code cleanup:
- Deleted 8 unused PostComposition* files (replaced by PostDetail full page)

Performance:
- budget-helpers: single-fetch with computeFromEntries(), optional prefetch param
- post-composition: parallelized text + thumbnail fetches with Promise.all

Consistency:
- PostDetail.jsx: native <select> → PortalSelect (matches all panels)
- Finance.jsx: 11 hardcoded English table headers → t() with i18n keys
- PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys
- App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback)
- UploadZone: proper useRef pattern, no vanilla JS document.createElement
- All file inputs: className="hidden" → absolute w-0 h-0 opacity-0
- ArtefactDetailPanel: removed campaign/project selects (inherited from post)
- TranslationDetailPanel: removed brand/linked post selects (inherited from post)
- ApproverMultiSelect: portal-based dropdown (fixes clipping in modals)
- Thumbnail fix: post-composition constructs URL from filename (was undefined)
- Upload fix: UploadZone with drag-and-drop for design + video artefacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:17:08 +03:00

406 lines
18 KiB
React

import { useState } from 'react'
import { Plus, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
import PortalSelect from './PortalSelect'
import UploadZone from './UploadZone'
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>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.imagesLabel')}</h4>
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-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"
loading="lazy"
/>
<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>
)}
<UploadZone
onUpload={onFileUpload}
accept="image/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickImage') || 'Drop images here or click to upload'}
hint={t('artefacts.imageFormats') || 'PNG, JPG, WebP'}
compact={versionData.attachments?.length > 0}
/>
</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 */}
<UploadZone
onUpload={onFileUpload}
accept="video/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickVideo')}
hint={t('artefacts.videoFormats')}
/>
{/* 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>
<PortalSelect
value={languageForm.language_code}
onChange={val => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === val)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
options={[
{ value: '', label: t('artefacts.selectLanguage') },
...AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => ({ value: lang.code, label: `${lang.label} (${lang.code})` }))
]}
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"
/>
</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>
</>
)
}