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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Upload, Trash2, Globe, Image as ImageIcon } from 'lucide-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'
|
||||
@@ -172,29 +174,17 @@ export function ArtefactDetailVersionsTab({
|
||||
{/* 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>
|
||||
<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">
|
||||
{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
|
||||
@@ -210,12 +200,16 @@ export function ArtefactDetailVersionsTab({
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -256,30 +250,14 @@ export function ArtefactDetailVersionsTab({
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<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">
|
||||
@@ -311,23 +289,21 @@ export function ArtefactDetailVersionsTab({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
|
||||
<select
|
||||
<PortalSelect
|
||||
value={languageForm.language_code}
|
||||
onChange={e => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user