feat: slide panels, task calendar, team management, project editing, collapsible sections
- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel - Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView - Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects - Update Settings, Tasks, Team pages - Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components - Update server API utilities - Remove tracked server/node_modules (now properly gitignored)
This commit is contained in:
582
client/src/components/PostDetailPanel.jsx
Normal file
582
client/src/components/PostDetailPanel.jsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
||||
import CommentsSection from './CommentsSection'
|
||||
import Modal from './Modal'
|
||||
import SlidePanel from './SlidePanel'
|
||||
import CollapsibleSection from './CollapsibleSection'
|
||||
|
||||
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
||||
const { t, lang } = useLanguage()
|
||||
const fileInputRef = useRef(null)
|
||||
const [form, setForm] = useState({})
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Attachments state
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
const [availableAssets, setAvailableAssets] = useState([])
|
||||
const [assetSearch, setAssetSearch] = useState('')
|
||||
|
||||
const postId = post?._id || post?.id
|
||||
const isCreateMode = !postId
|
||||
|
||||
useEffect(() => {
|
||||
if (post) {
|
||||
setForm({
|
||||
title: post.title || '',
|
||||
description: post.description || '',
|
||||
brand_id: post.brandId || post.brand_id || '',
|
||||
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
||||
status: post.status || 'draft',
|
||||
assigned_to: post.assignedTo || post.assigned_to || '',
|
||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
|
||||
notes: post.notes || '',
|
||||
campaign_id: post.campaignId || post.campaign_id || '',
|
||||
publication_links: post.publication_links || post.publicationLinks || [],
|
||||
})
|
||||
setDirty(isCreateMode)
|
||||
setPublishError('')
|
||||
if (!isCreateMode) loadAttachments()
|
||||
}
|
||||
}, [post])
|
||||
|
||||
if (!post) return null
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: t('posts.status.draft') },
|
||||
{ value: 'in_review', label: t('posts.status.in_review') },
|
||||
{ value: 'approved', label: t('posts.status.approved') },
|
||||
{ value: 'scheduled', label: t('posts.status.scheduled') },
|
||||
{ value: 'published', label: t('posts.status.published') },
|
||||
]
|
||||
|
||||
const update = (field, value) => {
|
||||
setForm(f => ({ ...f, [field]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const updatePublicationLink = (platform, url) => {
|
||||
setForm(f => {
|
||||
const links = [...(f.publication_links || [])]
|
||||
const idx = links.findIndex(l => l.platform === platform)
|
||||
if (idx >= 0) {
|
||||
links[idx] = { ...links[idx], url }
|
||||
} else {
|
||||
links.push({ platform, url })
|
||||
}
|
||||
return { ...f, publication_links: links }
|
||||
})
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setPublishError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||
assigned_to: form.assigned_to ? Number(form.assigned_to) : null,
|
||||
status: form.status,
|
||||
platforms: form.platforms || [],
|
||||
scheduled_date: form.scheduled_date || null,
|
||||
notes: form.notes,
|
||||
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||
publication_links: form.publication_links || [],
|
||||
}
|
||||
|
||||
if (data.status === 'published' && data.platforms.length > 0) {
|
||||
const missingPlatforms = data.platforms.filter(platform => {
|
||||
const link = (data.publication_links || []).find(l => l.platform === platform)
|
||||
return !link || !link.url || !link.url.trim()
|
||||
})
|
||||
if (missingPlatforms.length > 0) {
|
||||
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
|
||||
setPublishError(`${t('posts.publishMissing')} ${names}`)
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(isCreateMode ? null : postId, data)
|
||||
setDirty(false)
|
||||
if (isCreateMode) onClose()
|
||||
} catch (err) {
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
setPublishError(err.message.replace(/.*: /, ''))
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
await onDelete(postId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// ─── Attachments ──────────────────────────────
|
||||
async function loadAttachments() {
|
||||
if (!postId) return
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch {
|
||||
setAttachments([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!postId || !files?.length) return
|
||||
setUploading(true)
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
await api.upload(`/posts/${postId}/attachments`, fd)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
}
|
||||
}
|
||||
setUploading(false)
|
||||
loadAttachments()
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attId) => {
|
||||
try {
|
||||
await api.delete(`/attachments/${attId}`)
|
||||
loadAttachments()
|
||||
} catch (err) {
|
||||
console.error('Delete attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openAssetPicker = async () => {
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch {
|
||||
setAvailableAssets([])
|
||||
}
|
||||
setAssetSearch('')
|
||||
setShowAssetPicker(true)
|
||||
}
|
||||
|
||||
const handleAttachAsset = async (assetId) => {
|
||||
if (!postId) return
|
||||
try {
|
||||
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
||||
loadAttachments()
|
||||
setShowAssetPicker(false)
|
||||
} catch (err) {
|
||||
console.error('Attach asset failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const brandName = (() => {
|
||||
if (form.brand_id) {
|
||||
const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id))
|
||||
return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null
|
||||
}
|
||||
return post.brand_name || post.brandName || null
|
||||
})()
|
||||
|
||||
const header = (
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => update('title', e.target.value)}
|
||||
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||
placeholder={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||
form.status === 'published' ? 'bg-emerald-100 text-emerald-700' :
|
||||
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
|
||||
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{statusOptions.find(s => s.value === form.status)?.label}
|
||||
</span>
|
||||
{brandName && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||
{/* Details Section */}
|
||||
<CollapsibleSection title={t('posts.details')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => update('description', 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 focus:border-brand-primary resize-none"
|
||||
placeholder={t('posts.postDescPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
|
||||
<select
|
||||
value={form.brand_id}
|
||||
onChange={e => update('brand_id', e.target.value)}
|
||||
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 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.selectBrand')}</option>
|
||||
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
|
||||
<select
|
||||
value={form.campaign_id}
|
||||
onChange={e => update('campaign_id', e.target.value)}
|
||||
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 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.noCampaign')}</option>
|
||||
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={form.assigned_to}
|
||||
onChange={e => update('assigned_to', e.target.value)}
|
||||
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 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={e => update('status', e.target.value)}
|
||||
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 focus:border-brand-primary"
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.scheduled_date}
|
||||
onChange={e => update('scheduled_date', e.target.value)}
|
||||
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 focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.notes}
|
||||
onChange={e => update('notes', e.target.value)}
|
||||
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 focus:border-brand-primary"
|
||||
placeholder={t('posts.additionalNotes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || saving}
|
||||
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||
>
|
||||
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && !isCreateMode && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Platforms & Links Section */}
|
||||
<CollapsibleSection title={t('posts.platformsLinks')}>
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.platforms')}</label>
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (form.platforms || []).includes(k)
|
||||
return (
|
||||
<label
|
||||
key={k}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||||
checked
|
||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
update('platforms', checked
|
||||
? form.platforms.filter(p => p !== k)
|
||||
: [...(form.platforms || []), k]
|
||||
)
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{v.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.platforms || []).length > 0 && (
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-1">
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
{t('posts.publicationLinks')}
|
||||
</div>
|
||||
{(form.platforms || []).map(platformKey => {
|
||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
|
||||
const linkUrl = existingLink?.url || ''
|
||||
return (
|
||||
<div key={platformKey} className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||
{platformInfo.label}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
{linkUrl && (
|
||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{form.status === 'published' && (form.platforms || []).some(p => {
|
||||
const link = (form.publication_links || []).find(l => l.platform === p)
|
||||
return !link || !link.url?.trim()
|
||||
}) && (
|
||||
<p className="text-xs text-amber-600 mt-1">{t('posts.publishRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Attachments Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection
|
||||
title={t('posts.attachments')}
|
||||
badge={attachments.length > 0 ? (
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||
{attachments.length}
|
||||
</span>
|
||||
) : null}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{attachments.map(att => {
|
||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
const attId = att.id || att._id
|
||||
return (
|
||||
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
<div className="h-20 relative">
|
||||
{isImage ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(attId)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
|
||||
/>
|
||||
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
{t('posts.attachFromAssets')}
|
||||
</button>
|
||||
|
||||
{showAssetPicker && (
|
||||
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={assetSearch}
|
||||
onChange={e => setAssetSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||
{availableAssets
|
||||
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
||||
.map(asset => {
|
||||
const isImage = asset.mime_type?.startsWith('image/')
|
||||
const assetUrl = `/api/uploads/${asset.filename}`
|
||||
const name = asset.original_name || asset.filename
|
||||
return (
|
||||
<button
|
||||
key={asset.id || asset._id}
|
||||
onClick={() => handleAttachAsset(asset.id || asset._id)}
|
||||
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
{isImage ? (
|
||||
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
||||
<FileText className="w-6 h-6 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
||||
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Discussion Section (hidden in create mode) */}
|
||||
{!isCreateMode && (
|
||||
<CollapsibleSection title={t('posts.discussion')} noBorder>
|
||||
<div className="px-5 pb-5">
|
||||
<CommentsSection entityType="post" entityId={postId} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</SlidePanel>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('posts.deletePost')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('posts.deletePost')}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user