Implement creator studio and upload updates
This commit is contained in:
438
resources/js/Pages/Studio/StudioComments.jsx
Normal file
438
resources/js/Pages/Studio/StudioComments.jsx
Normal file
@@ -0,0 +1,438 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user