438 lines
18 KiB
JavaScript
438 lines
18 KiB
JavaScript
import React, { useMemo, useState } from 'react'
|
|
import { router, usePage } from '@inertiajs/react'
|
|
import StudioLayout from '../../Layouts/StudioLayout'
|
|
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
|
|
|
const reportReasons = [
|
|
{ value: 'spam', label: 'Spam or scam' },
|
|
{ value: 'harassment', label: 'Harassment' },
|
|
{ value: 'abuse', label: 'Abusive content' },
|
|
{ value: 'stolen', label: 'Stolen or impersonation' },
|
|
{ value: 'other', label: 'Other' },
|
|
]
|
|
|
|
function formatDate(value) {
|
|
if (!value) return 'Unknown'
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return 'Unknown'
|
|
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
async function requestJson(url, method, body) {
|
|
const response = await fetch(url, {
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload?.message || payload?.error || 'Request failed')
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
export default function StudioComments() {
|
|
const { props } = usePage()
|
|
const listing = props.listing || {}
|
|
const filters = listing.filters || {}
|
|
const items = listing.items || []
|
|
const meta = listing.meta || {}
|
|
const moduleOptions = listing.module_options || []
|
|
const endpoints = props.endpoints || {}
|
|
|
|
const [busyKey, setBusyKey] = useState(null)
|
|
const [replyFor, setReplyFor] = useState(null)
|
|
const [replyText, setReplyText] = useState('')
|
|
const [reportFor, setReportFor] = useState(null)
|
|
const [reportReason, setReportReason] = useState('spam')
|
|
const [reportDetails, setReportDetails] = useState('')
|
|
|
|
const visibleSummary = useMemo(() => {
|
|
return moduleOptions
|
|
.filter((option) => option.value !== 'all')
|
|
.map((option) => ({
|
|
...option,
|
|
count: items.filter((item) => item.module === option.value).length,
|
|
}))
|
|
}, [items, moduleOptions])
|
|
|
|
const updateFilters = (patch) => {
|
|
const next = {
|
|
...filters,
|
|
...patch,
|
|
}
|
|
|
|
if (patch.page == null) {
|
|
next.page = 1
|
|
}
|
|
|
|
trackStudioEvent('studio_filter_used', {
|
|
surface: studioSurface(),
|
|
module: 'comments',
|
|
meta: patch,
|
|
})
|
|
|
|
router.get(window.location.pathname, next, {
|
|
preserveScroll: true,
|
|
preserveState: true,
|
|
replace: true,
|
|
})
|
|
}
|
|
|
|
const buildUrl = (pattern, comment) => pattern
|
|
.replace('__MODULE__', comment.module)
|
|
.replace('__COMMENT__', String(comment.comment_id))
|
|
|
|
const submitReply = async (comment) => {
|
|
if (!replyText.trim()) {
|
|
window.alert('Reply cannot be empty.')
|
|
return
|
|
}
|
|
|
|
const key = `reply:${comment.id}`
|
|
setBusyKey(key)
|
|
|
|
try {
|
|
await requestJson(buildUrl(endpoints.replyPattern, comment), 'POST', {
|
|
content: replyText.trim(),
|
|
})
|
|
|
|
trackStudioEvent('studio_comment_replied', {
|
|
surface: studioSurface(),
|
|
module: comment.module,
|
|
item_module: comment.module,
|
|
item_id: comment.item_id,
|
|
})
|
|
|
|
setReplyFor(null)
|
|
setReplyText('')
|
|
router.reload({ preserveScroll: true, preserveState: true })
|
|
} catch (error) {
|
|
window.alert(error?.message || 'Unable to send reply.')
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
|
|
const moderateComment = async (comment) => {
|
|
if (!window.confirm('Remove this comment from the conversation stream?')) {
|
|
return
|
|
}
|
|
|
|
const key = `moderate:${comment.id}`
|
|
setBusyKey(key)
|
|
|
|
try {
|
|
await requestJson(buildUrl(endpoints.moderatePattern, comment), 'DELETE')
|
|
|
|
trackStudioEvent('studio_comment_moderated', {
|
|
surface: studioSurface(),
|
|
module: comment.module,
|
|
item_module: comment.module,
|
|
item_id: comment.item_id,
|
|
})
|
|
|
|
router.reload({ preserveScroll: true, preserveState: true })
|
|
} catch (error) {
|
|
window.alert(error?.message || 'Unable to remove comment.')
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
|
|
const submitReport = async (comment) => {
|
|
const key = `report:${comment.id}`
|
|
setBusyKey(key)
|
|
|
|
try {
|
|
await requestJson(buildUrl(endpoints.reportPattern, comment), 'POST', {
|
|
reason: reportReason,
|
|
details: reportDetails.trim() || null,
|
|
})
|
|
|
|
trackStudioEvent('studio_comment_reported', {
|
|
surface: studioSurface(),
|
|
module: comment.module,
|
|
item_module: comment.module,
|
|
item_id: comment.item_id,
|
|
})
|
|
|
|
setReportFor(null)
|
|
setReportReason('spam')
|
|
setReportDetails('')
|
|
window.alert('Report sent.')
|
|
} catch (error) {
|
|
window.alert(error?.message || 'Unable to report comment.')
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<StudioLayout title={props.title} subtitle={props.description}>
|
|
<div className="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
|
<aside className="space-y-5">
|
|
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<h2 className="text-lg font-semibold text-white">Moderation cockpit</h2>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">Search across modules, reply without leaving Studio, and escalate suspicious comments when removal is not enough.</p>
|
|
<div className="mt-5 space-y-3">
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
|
|
<input
|
|
type="search"
|
|
value={filters.q || ''}
|
|
onChange={(event) => updateFilters({ q: event.target.value })}
|
|
placeholder="Author, item, or comment"
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
|
|
/>
|
|
</label>
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
|
|
<select
|
|
value={filters.module || 'all'}
|
|
onChange={(event) => updateFilters({ module: event.target.value })}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
|
>
|
|
{moduleOptions.map((option) => (
|
|
<option key={option.value} value={option.value} className="bg-slate-900">
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<h2 className="text-lg font-semibold text-white">Visible on this page</h2>
|
|
<div className="mt-4 space-y-3">
|
|
{visibleSummary.map((item) => (
|
|
<div key={item.value} className="flex items-center justify-between rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
|
<span>{item.label}</span>
|
|
<span className="font-semibold text-white">{item.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
|
|
<section className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400">
|
|
<p>
|
|
Showing <span className="font-semibold text-white">{items.length}</span> of <span className="font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</span> comments
|
|
</p>
|
|
<p>Page {meta.current_page || 1} of {meta.last_page || 1}</p>
|
|
</div>
|
|
|
|
{items.length > 0 ? items.map((comment) => {
|
|
const replyBusy = busyKey === `reply:${comment.id}`
|
|
const moderateBusy = busyKey === `moderate:${comment.id}`
|
|
const reportBusy = busyKey === `report:${comment.id}`
|
|
|
|
return (
|
|
<article key={comment.id} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
|
<div className="flex min-w-0 gap-4">
|
|
{comment.author_avatar_url ? (
|
|
<img src={comment.author_avatar_url} alt={comment.author_name} className="h-12 w-12 rounded-2xl object-cover" />
|
|
) : (
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/30 text-slate-400">
|
|
<i className="fa-solid fa-user" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">
|
|
<span>{comment.module_label}</span>
|
|
<span className="text-slate-500">{comment.time_ago || formatDate(comment.created_at)}</span>
|
|
</div>
|
|
<p className="mt-2 text-sm text-white">
|
|
<span className="font-semibold text-sky-100">{comment.author_name}</span>
|
|
{' '}on{' '}
|
|
<span className="text-slate-300">{comment.item_title || 'Untitled item'}</span>
|
|
</p>
|
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-300">{comment.body}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 md:justify-end">
|
|
{comment.preview_url && (
|
|
<a href={comment.preview_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200">
|
|
<i className="fa-solid fa-eye" />
|
|
Preview
|
|
</a>
|
|
)}
|
|
<a href={comment.context_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200">
|
|
<i className="fa-solid fa-arrow-up-right-from-square" />
|
|
Context
|
|
</a>
|
|
{comment.reply_supported && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setReportFor(null)
|
|
setReplyFor(replyFor === comment.id ? null : comment.id)
|
|
setReplyText('')
|
|
}}
|
|
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100"
|
|
>
|
|
<i className="fa-solid fa-reply" />
|
|
Reply
|
|
</button>
|
|
)}
|
|
{comment.moderate_supported && (
|
|
<button
|
|
type="button"
|
|
onClick={() => moderateComment(comment)}
|
|
disabled={moderateBusy}
|
|
className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs text-rose-100 disabled:opacity-50"
|
|
>
|
|
<i className="fa-solid fa-trash" />
|
|
{moderateBusy ? 'Removing...' : 'Remove'}
|
|
</button>
|
|
)}
|
|
{comment.report_supported && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setReplyFor(null)
|
|
setReportFor(reportFor === comment.id ? null : comment.id)
|
|
}}
|
|
className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-xs text-amber-100"
|
|
>
|
|
<i className="fa-solid fa-flag" />
|
|
Report
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{replyFor === comment.id && (
|
|
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4">
|
|
<label className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reply as creator</label>
|
|
<textarea
|
|
value={replyText}
|
|
onChange={(event) => setReplyText(event.target.value)}
|
|
rows={4}
|
|
placeholder="Write a public reply"
|
|
className="mt-3 w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
|
|
/>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => submitReply(comment)}
|
|
disabled={replyBusy}
|
|
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-50"
|
|
>
|
|
<i className="fa-solid fa-paper-plane" />
|
|
{replyBusy ? 'Sending...' : 'Publish reply'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setReplyFor(null)}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{reportFor === comment.id && (
|
|
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4">
|
|
<div className="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</span>
|
|
<select
|
|
value={reportReason}
|
|
onChange={(event) => setReportReason(event.target.value)}
|
|
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white"
|
|
>
|
|
{reportReasons.map((reason) => (
|
|
<option key={reason.value} value={reason.value} className="bg-slate-900">
|
|
{reason.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="space-y-2 text-sm text-slate-300">
|
|
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Details</span>
|
|
<textarea
|
|
value={reportDetails}
|
|
onChange={(event) => setReportDetails(event.target.value)}
|
|
rows={3}
|
|
placeholder="Optional note for moderation"
|
|
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => submitReport(comment)}
|
|
disabled={reportBusy}
|
|
className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100 disabled:opacity-50"
|
|
>
|
|
<i className="fa-solid fa-flag" />
|
|
{reportBusy ? 'Sending...' : 'Send report'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setReportFor(null)}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</article>
|
|
)
|
|
}) : (
|
|
<section className="rounded-[28px] border border-dashed border-white/15 bg-white/[0.02] px-6 py-16 text-center">
|
|
<h3 className="text-xl font-semibold text-white">No comments match this view</h3>
|
|
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">Try another module or a broader search query.</p>
|
|
</section>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
|
<button
|
|
type="button"
|
|
disabled={(meta.current_page || 1) <= 1}
|
|
onClick={() => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) })}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
|
|
>
|
|
<i className="fa-solid fa-arrow-left" />
|
|
Previous
|
|
</button>
|
|
|
|
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Unified comments</span>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={(meta.current_page || 1) >= (meta.last_page || 1)}
|
|
onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })}
|
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40"
|
|
>
|
|
Next
|
|
<i className="fa-solid fa-arrow-right" />
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</StudioLayout>
|
|
)
|
|
} |