All checks were successful
Deploy / deploy (push) Successful in 11s
- Multi-select bulk delete in all 5 list views (Artefacts, Posts, Tasks, Issues, Assets) with cascade deletes and confirmation modals - Team-based issue dispatch: team picker on public issue form, team filter on Issues page, copy public link from Team page and Issues header, team assignment in IssueDetailPanel - Month/Week toggle on PostCalendar and TaskCalendarView - Month/Week/Day zoom on project and campaign timelines (InteractiveTimeline) and ProjectDetail GanttView, with Month as default - Custom timeline bar colors: clickable color dot with 12-color palette popover on project, campaign, and task timeline bars - Artefacts default view changed to list - BulkSelectBar reusable component - i18n keys for all new features (en + ar) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
532 lines
23 KiB
JavaScript
532 lines
23 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { useParams } from 'react-router-dom'
|
|
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { useToast } from '../components/ToastContainer'
|
|
import Modal from '../components/Modal'
|
|
|
|
const STATUS_ICONS = {
|
|
copy: FileText,
|
|
design: ImageIcon,
|
|
video: Film,
|
|
other: Sparkles,
|
|
}
|
|
|
|
export default function PublicReview() {
|
|
const { token } = useParams()
|
|
const { t } = useLanguage()
|
|
const toast = useToast()
|
|
const [artefact, setArtefact] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [success, setSuccess] = useState('')
|
|
const [reviewerName, setReviewerName] = useState('')
|
|
const [feedback, setFeedback] = useState('')
|
|
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
|
const [pendingAction, setPendingAction] = useState(null)
|
|
|
|
useEffect(() => {
|
|
loadArtefact()
|
|
}, [token])
|
|
|
|
const loadArtefact = async () => {
|
|
try {
|
|
const res = await fetch(`/api/public/review/${token}`)
|
|
if (!res.ok) {
|
|
const err = await res.json()
|
|
setError(err.error || t('review.loadFailed'))
|
|
setLoading(false)
|
|
return
|
|
}
|
|
const data = await res.json()
|
|
setArtefact(data)
|
|
// Auto-set reviewer name if there's exactly one approver
|
|
if (data.approvers?.length === 1 && data.approvers[0].name) {
|
|
setReviewerName(data.approvers[0].name)
|
|
}
|
|
} catch (err) {
|
|
setError(t('review.loadFailed'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleAction = (action) => {
|
|
if (!reviewerName.trim()) {
|
|
toast.error(t('review.enterName'))
|
|
return
|
|
}
|
|
|
|
if (action === 'revision' && !feedback.trim()) {
|
|
toast.error(t('review.feedbackRequired'))
|
|
return
|
|
}
|
|
|
|
if (action === 'approve' || action === 'reject') {
|
|
setPendingAction(action)
|
|
return
|
|
}
|
|
|
|
executeAction(action)
|
|
}
|
|
|
|
const executeAction = async (action) => {
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch(`/api/public/review/${token}/${action}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
approved_by_name: reviewerName,
|
|
feedback: feedback || undefined,
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json()
|
|
setError(err.error || t('review.actionFailed'))
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
|
|
const data = await res.json()
|
|
setSuccess(data.message || t('review.actionCompleted'))
|
|
setTimeout(() => {
|
|
loadArtefact()
|
|
}, 1500)
|
|
} catch (err) {
|
|
setError(t('review.actionFailed'))
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const extractDriveFileId = (url) => {
|
|
const patterns = [
|
|
/\/file\/d\/([^\/]+)/,
|
|
/id=([^&]+)/,
|
|
/\/d\/([^\/]+)/,
|
|
]
|
|
|
|
for (const pattern of patterns) {
|
|
const match = url.match(pattern)
|
|
if (match) return match[1]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const getDriveEmbedUrl = (url) => {
|
|
const fileId = extractDriveFileId(url)
|
|
return fileId ? `https://drive.google.com/file/d/${fileId}/preview` : url
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-surface-secondary flex items-center justify-center">
|
|
<div className="max-w-3xl w-full mx-auto px-4 space-y-6 animate-pulse">
|
|
<div className="bg-surface rounded-2xl overflow-hidden">
|
|
<div className="h-24 bg-surface-tertiary"></div>
|
|
<div className="p-8 space-y-4">
|
|
<div className="h-6 bg-surface-tertiary rounded w-2/3"></div>
|
|
<div className="h-4 bg-surface-tertiary rounded w-1/2"></div>
|
|
<div className="h-32 bg-surface-tertiary rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
|
|
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
|
<XCircle className="w-8 h-8 text-red-600" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
|
|
<p className="text-text-secondary">{error}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (success) {
|
|
return (
|
|
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
|
|
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
|
|
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
|
|
<p className="text-text-secondary">{success}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!artefact) return null
|
|
|
|
const TypeIcon = STATUS_ICONS[artefact.type] || Sparkles
|
|
const isImage = (url) => /\.(jpg|jpeg|png|gif|webp)$/i.test(url)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-surface-secondary py-12 px-4">
|
|
<div className="max-w-3xl mx-auto">
|
|
{/* Header */}
|
|
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden mb-6">
|
|
<div className="bg-brand-primary px-8 py-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
|
<Sparkles className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">{t('review.contentReview')}</h1>
|
|
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-8">
|
|
{/* Artefact Info */}
|
|
<div className="flex items-start gap-4 mb-6">
|
|
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 flex items-center justify-center shrink-0">
|
|
<TypeIcon className="w-6 h-6 text-brand-primary" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h2 className="text-2xl font-bold text-text-primary mb-1">{artefact.title}</h2>
|
|
{artefact.description && (
|
|
<p className="text-text-secondary mb-2">{artefact.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-3 text-sm text-text-tertiary">
|
|
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full capitalize">{artefact.type}</span>
|
|
{artefact.brand_name && <span>• {artefact.brand_name}</span>}
|
|
{artefact.version_number && <span>• {t('review.version')} {artefact.version_number}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* COPY TYPE: Multilingual Content */}
|
|
{artefact.type === 'copy' && artefact.texts && artefact.texts.length > 0 && (
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Globe className="w-4 h-4 text-text-tertiary" />
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.contentLanguages')}</h3>
|
|
</div>
|
|
|
|
{/* Language tabs */}
|
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
|
{artefact.texts.map((text, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => setSelectedLanguage(idx)}
|
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
selectedLanguage === idx
|
|
? 'bg-brand-primary text-white shadow-sm'
|
|
: 'bg-surface-tertiary text-text-secondary hover:bg-surface-tertiary/70'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-mono">{text.language_code}</span>
|
|
<span className="text-sm">{text.language_label}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Selected language content */}
|
|
<div className="bg-surface-secondary rounded-xl p-6 border border-border">
|
|
<div className="mb-2 text-xs font-semibold text-text-tertiary uppercase">
|
|
{artefact.texts[selectedLanguage].language_label} {t('review.content')}
|
|
</div>
|
|
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
|
|
{artefact.texts[selectedLanguage].content}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legacy content field (for backward compatibility) */}
|
|
{artefact.content && (!artefact.texts || artefact.texts.length === 0) && (
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-2">{t('review.content')}</h3>
|
|
<div className="bg-surface-secondary rounded-xl p-4 border border-border">
|
|
<pre className="text-text-primary whitespace-pre-wrap font-sans text-sm leading-relaxed">
|
|
{artefact.content}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* DESIGN TYPE: Image Gallery */}
|
|
{artefact.type === 'design' && artefact.attachments && artefact.attachments.length > 0 && (
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<ImageIcon className="w-4 h-4 text-text-tertiary" />
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.designFiles')}</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{artefact.attachments.map((att, idx) => (
|
|
<a
|
|
key={idx}
|
|
href={att.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm hover:shadow-sm"
|
|
>
|
|
<img
|
|
src={att.url}
|
|
alt={att.original_name || `Design ${idx + 1}`}
|
|
className="w-full h-64 object-cover"
|
|
/>
|
|
{att.original_name && (
|
|
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
|
|
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
|
|
</div>
|
|
)}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* VIDEO TYPE: Video Player or Drive Embed */}
|
|
{artefact.type === 'video' && artefact.attachments && artefact.attachments.length > 0 && (
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Film className="w-4 h-4 text-text-tertiary" />
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('review.videos')}</h3>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{artefact.attachments.map((att, idx) => (
|
|
<div key={idx} className="bg-surface-secondary rounded-xl overflow-hidden border border-border">
|
|
{att.drive_url ? (
|
|
<div>
|
|
<div className="px-4 py-2 bg-surface border-b border-border flex items-center gap-2">
|
|
<Globe className="w-4 h-4 text-text-tertiary" />
|
|
<span className="text-sm font-medium text-text-secondary">{t('review.googleDriveVideo')}</span>
|
|
</div>
|
|
<iframe
|
|
src={getDriveEmbedUrl(att.drive_url)}
|
|
className="w-full h-96"
|
|
allow="autoplay"
|
|
title={`Video ${idx + 1}`}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{att.original_name && (
|
|
<div className="px-4 py-2 bg-surface border-b border-border">
|
|
<span className="text-sm font-medium text-text-secondary">{att.original_name}</span>
|
|
</div>
|
|
)}
|
|
<video
|
|
src={att.url}
|
|
controls
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* OTHER TYPE: Generic Attachments */}
|
|
{artefact.type === 'other' && artefact.attachments && artefact.attachments.length > 0 && (
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.attachments')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{artefact.attachments.map((att, idx) => (
|
|
<div key={idx}>
|
|
{isImage(att.url) ? (
|
|
<a
|
|
href={att.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block rounded-xl overflow-hidden border border-border hover:border-brand-primary transition-colors"
|
|
>
|
|
<img
|
|
src={att.url}
|
|
alt={att.original_name}
|
|
className="w-full h-48 object-cover"
|
|
/>
|
|
<div className="bg-surface-secondary px-3 py-2 border-t border-border">
|
|
<p className="text-xs text-text-secondary truncate">{att.original_name}</p>
|
|
</div>
|
|
</a>
|
|
) : (
|
|
<a
|
|
href={att.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-3 p-4 bg-surface-secondary rounded-xl border border-border hover:border-brand-primary transition-colors"
|
|
>
|
|
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
|
{att.size && (
|
|
<p className="text-xs text-text-tertiary">
|
|
{(att.size / 1024).toFixed(1)} KB
|
|
</p>
|
|
)}
|
|
</div>
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Comments */}
|
|
{artefact.comments && artefact.comments.length > 0 && (
|
|
<div className="mb-6">
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('review.previousComments')}</h3>
|
|
<div className="space-y-3">
|
|
{artefact.comments.map((comment, idx) => (
|
|
<div key={idx} className="bg-surface-secondary rounded-lg p-3 border border-border">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-sm font-medium text-text-primary">
|
|
{comment.user_name || comment.author_name || 'Anonymous'}
|
|
</span>
|
|
{comment.CreatedAt && (
|
|
<span className="text-xs text-text-tertiary">
|
|
• {new Date(comment.CreatedAt).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-text-secondary whitespace-pre-wrap">{comment.content}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Review Form */}
|
|
{artefact.status === 'pending_review' && (
|
|
<div className="border-t border-border pt-6">
|
|
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
|
|
|
<div className="space-y-4 mb-6">
|
|
{/* Reviewer identity */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
|
|
{artefact.approvers?.length === 1 ? (
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
|
|
<User className="w-4 h-4 text-text-tertiary" />
|
|
<span className="text-sm text-text-primary">{artefact.approvers[0].name}</span>
|
|
</div>
|
|
) : artefact.approvers?.length > 1 ? (
|
|
<select
|
|
value={reviewerName}
|
|
onChange={e => setReviewerName(e.target.value)}
|
|
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
|
>
|
|
<option value="">{t('review.selectYourName')}</option>
|
|
{artefact.approvers.map(a => (
|
|
<option key={a.id} value={a.name}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={reviewerName}
|
|
onChange={e => setReviewerName(e.target.value)}
|
|
placeholder={t('review.enterYourName')}
|
|
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackOptional')}</label>
|
|
<textarea
|
|
value={feedback}
|
|
onChange={e => setFeedback(e.target.value)}
|
|
rows={4}
|
|
placeholder={t('review.feedbackPlaceholder')}
|
|
className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<button
|
|
onClick={() => handleAction('approve')}
|
|
disabled={submitting}
|
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
>
|
|
<CheckCircle className="w-5 h-5" />
|
|
{t('review.approve')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleAction('revision')}
|
|
disabled={submitting}
|
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
>
|
|
<AlertCircle className="w-5 h-5" />
|
|
{t('review.requestRevision')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleAction('reject')}
|
|
disabled={submitting}
|
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
>
|
|
<XCircle className="w-5 h-5" />
|
|
{t('review.reject')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Already Reviewed */}
|
|
{artefact.status !== 'pending_review' && (
|
|
<div className="border-t border-border pt-6">
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
|
|
<p className="text-blue-900 font-medium">
|
|
{t('review.alreadyReviewed')}
|
|
</p>
|
|
<p className="text-blue-700 text-sm mt-1">
|
|
{t('review.statusLabel')}: <span className="font-semibold capitalize">{artefact.status.replace('_', ' ')}</span>
|
|
</p>
|
|
{artefact.approved_by_name && (
|
|
<p className="text-blue-700 text-sm mt-1">
|
|
{t('review.reviewedBy')}: <span className="font-semibold">{artefact.approved_by_name}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="text-center text-text-tertiary text-sm">
|
|
<p>{t('review.poweredBy')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Approve / Reject Confirmation */}
|
|
<Modal
|
|
isOpen={!!pendingAction}
|
|
onClose={() => setPendingAction(null)}
|
|
title={pendingAction === 'approve' ? t('review.confirmApprove') : t('review.confirmReject')}
|
|
isConfirm
|
|
danger={pendingAction === 'reject'}
|
|
onConfirm={() => {
|
|
const action = pendingAction
|
|
setPendingAction(null)
|
|
executeAction(action)
|
|
}}
|
|
confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
|
|
>
|
|
{pendingAction === 'approve' ? t('review.confirmApproveDesc') : t('review.confirmRejectDesc')}
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|