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>
196 lines
7.1 KiB
React
196 lines
7.1 KiB
React
import { useState, useEffect } from 'react'
|
|
import { Send, Trash2, MessageCircle, Pencil, Check, X } from 'lucide-react'
|
|
import { api, getInitials } from '../utils/api'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
|
|
function relativeTime(dateStr, t) {
|
|
const now = Date.now()
|
|
const then = new Date(dateStr).getTime()
|
|
const diffMs = now - then
|
|
const diffMin = Math.floor(diffMs / 60000)
|
|
if (diffMin < 1) return t('comments.justNow')
|
|
if (diffMin < 60) return t('comments.minutesAgo').replace('{n}', diffMin)
|
|
const diffHours = Math.floor(diffMin / 60)
|
|
if (diffHours < 24) return t('comments.hoursAgo').replace('{n}', diffHours)
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
return t('comments.daysAgo').replace('{n}', diffDays)
|
|
}
|
|
|
|
export default function CommentsSection({ entityType, entityId }) {
|
|
const { user } = useAuth()
|
|
const { t } = useLanguage()
|
|
const [comments, setComments] = useState([])
|
|
const [newComment, setNewComment] = useState('')
|
|
const [sending, setSending] = useState(false)
|
|
const [editingId, setEditingId] = useState(null)
|
|
const [editContent, setEditContent] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (entityType && entityId) loadComments()
|
|
}, [entityType, entityId])
|
|
|
|
const loadComments = async () => {
|
|
try {
|
|
const data = await api.get(`/comments/${entityType}/${entityId}`)
|
|
setComments(Array.isArray(data) ? data : [])
|
|
} catch (err) {
|
|
console.error('Failed to load comments:', err)
|
|
}
|
|
}
|
|
|
|
const handleSend = async () => {
|
|
if (!newComment.trim() || sending) return
|
|
setSending(true)
|
|
try {
|
|
await api.post(`/comments/${entityType}/${entityId}`, { content: newComment.trim() })
|
|
setNewComment('')
|
|
loadComments()
|
|
} catch (err) {
|
|
console.error('Failed to send comment:', err)
|
|
} finally {
|
|
setSending(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id) => {
|
|
try {
|
|
await api.delete(`/comments/${id}`)
|
|
loadComments()
|
|
} catch (err) {
|
|
console.error('Failed to delete comment:', err)
|
|
}
|
|
}
|
|
|
|
const startEdit = (comment) => {
|
|
setEditingId(comment.id)
|
|
setEditContent(comment.content)
|
|
}
|
|
|
|
const cancelEdit = () => {
|
|
setEditingId(null)
|
|
setEditContent('')
|
|
}
|
|
|
|
const saveEdit = async (id) => {
|
|
if (!editContent.trim()) return
|
|
try {
|
|
await api.patch(`/comments/${id}`, { content: editContent.trim() })
|
|
setEditingId(null)
|
|
setEditContent('')
|
|
loadComments()
|
|
} catch (err) {
|
|
console.error('Failed to edit comment:', err)
|
|
}
|
|
}
|
|
|
|
const canEdit = (comment) => {
|
|
if (!user) return false
|
|
return comment.user_id === user.id
|
|
}
|
|
|
|
const canDelete = (comment) => {
|
|
if (!user) return false
|
|
if (comment.user_id === user.id) return true
|
|
return user.role === 'superadmin' || user.role === 'manager'
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold text-text-primary flex items-center gap-1.5">
|
|
<MessageCircle className="w-4 h-4" />
|
|
{t('comments.title')}
|
|
{comments.length > 0 && (
|
|
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
|
{comments.length}
|
|
</span>
|
|
)}
|
|
</h4>
|
|
|
|
{comments.length === 0 && (
|
|
<p className="text-xs text-text-tertiary py-2">{t('comments.noComments')}</p>
|
|
)}
|
|
|
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
{comments.map(c => (
|
|
<div key={c.id} className="flex items-start gap-2 group">
|
|
<div className="w-7 h-7 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">
|
|
{c.user_avatar ? (
|
|
<img src={c.user_avatar} className="w-full h-full rounded-full object-cover" alt="" loading="lazy" />
|
|
) : (
|
|
getInitials(c.user_name)
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium text-text-primary">{c.user_name}</span>
|
|
<span className="text-[10px] text-text-tertiary">{relativeTime(c.created_at, t)}</span>
|
|
<div className="flex items-center gap-0.5 ms-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{canEdit(c) && editingId !== c.id && (
|
|
<button
|
|
onClick={() => startEdit(c)}
|
|
className="p-0.5 rounded text-text-tertiary hover:text-brand-primary"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
{canDelete(c) && (
|
|
<button
|
|
onClick={() => handleDelete(c.id)}
|
|
className="p-0.5 rounded text-text-tertiary hover:text-red-500"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{editingId === c.id ? (
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
<input
|
|
type="text"
|
|
value={editContent}
|
|
onChange={e => setEditContent(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') saveEdit(c.id)
|
|
if (e.key === 'Escape') cancelEdit()
|
|
}}
|
|
autoFocus
|
|
className="flex-1 px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-brand-primary/30"
|
|
/>
|
|
<button onClick={() => saveEdit(c.id)} className="p-0.5 rounded text-green-600 hover:bg-green-50">
|
|
<Check className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button onClick={cancelEdit} className="p-0.5 rounded text-text-tertiary hover:bg-surface-tertiary">
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-text-secondary whitespace-pre-wrap break-words">{c.content}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={newComment}
|
|
onChange={e => setNewComment(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
|
placeholder={t('comments.placeholder')}
|
|
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"
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!newComment.trim() || sending}
|
|
className="p-2 bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|