Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View 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>
)
}