video preview version
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink, FolderOpen } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
@@ -8,6 +8,7 @@ import KanbanBoard from '../components/KanbanBoard'
|
||||
import PostCard from '../components/PostCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Modal from '../components/Modal'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
|
||||
const EMPTY_POST = {
|
||||
title: '', description: '', brand_id: '', platforms: [],
|
||||
@@ -33,7 +34,11 @@ export default function PostProduction() {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [moveError, setMoveError] = useState('')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
const [availableAssets, setAvailableAssets] = useState([])
|
||||
const [assetSearch, setAssetSearch] = useState('')
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,7 +111,8 @@ export default function PostProduction() {
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
alert('Cannot publish: all platform publication links must be filled first.')
|
||||
setMoveError(t('posts.publishRequired'))
|
||||
setTimeout(() => setMoveError(''), 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +158,30 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const openAssetPicker = async () => {
|
||||
try {
|
||||
const data = await api.get('/assets')
|
||||
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load assets:', err)
|
||||
setAvailableAssets([])
|
||||
}
|
||||
setAssetSearch('')
|
||||
setShowAssetPicker(true)
|
||||
}
|
||||
|
||||
const handleAttachAsset = async (assetId) => {
|
||||
if (!editingPost) return
|
||||
const postId = editingPost._id || editingPost.id
|
||||
try {
|
||||
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
||||
loadAttachments(postId)
|
||||
setShowAssetPicker(false)
|
||||
} catch (err) {
|
||||
console.error('Attach asset failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
|
||||
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
|
||||
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
|
||||
@@ -297,6 +327,16 @@ export default function PostProduction() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Move error banner */}
|
||||
{moveError && (
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
|
||||
<span>{moveError}</span>
|
||||
<button onClick={() => setMoveError('')} className="p-0.5 hover:bg-amber-100 rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{view === 'kanban' ? (
|
||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||
@@ -534,27 +574,29 @@ export default function PostProduction() {
|
||||
const name = att.original_name || att.originalName || att.filename
|
||||
return (
|
||||
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
{isImage ? (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`http://localhost:3001${attUrl}`}
|
||||
alt={name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm"
|
||||
title={t('posts.deleteAttachment')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="h-24 relative">
|
||||
{isImage ? (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||
<img
|
||||
src={`http://localhost:3001${attUrl}`}
|
||||
alt={name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm z-10"
|
||||
title={t('posts.deleteAttachment')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{name}
|
||||
</div>
|
||||
@@ -589,6 +631,65 @@ export default function PostProduction() {
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
|
||||
</div>
|
||||
|
||||
{/* Attach from Assets button */}
|
||||
<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>
|
||||
|
||||
{/* Asset picker */}
|
||||
{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 sm:grid-cols-4 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}
|
||||
onClick={() => handleAttachAsset(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={`http://localhost:3001${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>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploading && (
|
||||
<div className="mt-2">
|
||||
@@ -607,6 +708,11 @@ export default function PostProduction() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments (only for existing posts) */}
|
||||
{editingPost && (
|
||||
<CommentsSection entityType="post" entityId={editingPost._id || editingPost.id} />
|
||||
)}
|
||||
|
||||
{/* Publish validation error */}
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
|
||||
Reference in New Issue
Block a user