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,163 @@
import React, { useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
async function requestJson(url, method = 'POST') {
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',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Request failed')
}
return payload
}
function formatDate(value) {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown'
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export default function StudioActivity() {
const { props } = usePage()
const listing = props.listing || {}
const filters = listing.filters || {}
const items = listing.items || []
const meta = listing.meta || {}
const summary = listing.summary || {}
const typeOptions = listing.type_options || []
const moduleOptions = listing.module_options || []
const endpoints = props.endpoints || {}
const [marking, setMarking] = useState(false)
const updateFilters = (patch) => {
const next = { ...filters, ...patch }
if (patch.page == null) next.page = 1
trackStudioEvent('studio_activity_opened', {
surface: studioSurface(),
module: 'activity',
meta: patch,
})
router.get(window.location.pathname, next, {
preserveScroll: true,
preserveState: true,
replace: true,
})
}
const markAllRead = async () => {
setMarking(true)
try {
await requestJson(endpoints.markAllRead)
router.reload({ only: ['listing'] })
} catch (error) {
window.alert(error?.message || 'Unable to mark activity as read.')
} finally {
setMarking(false)
}
}
return (
<StudioLayout
title={props.title}
subtitle={props.description}
actions={
<button type="button" onClick={markAllRead} disabled={marking} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 disabled:opacity-50">
<i className="fa-solid fa-check-double" />
{marking ? 'Updating...' : 'Mark all read'}
</button>
}
>
<div className="space-y-6">
<section className="grid gap-4 md:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">New since last read</div>
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.new_items || 0).toLocaleString()}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Unread notifications</div>
<div className="mt-2 text-3xl font-semibold text-white">{Number(summary.unread_notifications || 0).toLocaleString()}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Last inbox reset</div>
<div className="mt-2 text-base font-semibold text-white">{summary.last_read_at ? formatDate(summary.last_read_at) : 'Not yet'}</div>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Search activity</span>
<input value={filters.q || ''} onChange={(event) => updateFilters({ q: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" placeholder="Message, actor, or module" />
</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">Type</span>
<select value={filters.type || 'all'} onChange={(event) => updateFilters({ type: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white">
{typeOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.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">Content type</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-white">
{moduleOptions.map((option) => <option key={option.value} value={option.value} className="bg-slate-900">{option.label}</option>)}
</select>
</label>
<div className="flex items-end">
<button type="button" onClick={() => updateFilters({ q: '', type: 'all', module: 'all' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
</div>
</div>
</section>
<section className="space-y-4">
{items.length > 0 ? items.map((item) => (
<article key={item.id} className={`rounded-[28px] border p-5 ${item.is_new ? 'border-sky-300/25 bg-sky-300/10' : 'border-white/10 bg-white/[0.03]'}`}>
<div className="flex gap-4">
{item.actor?.avatar_url ? (
<img src={item.actor.avatar_url} alt={item.actor.name || 'Activity actor'} className="h-12 w-12 rounded-2xl object-cover" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20 text-slate-400">
<i className="fa-solid fa-bell" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<span>{item.module_label}</span>
<span>{formatDate(item.created_at)}</span>
{item.is_new && <span className="rounded-full bg-sky-300/20 px-2 py-1 text-sky-100">New</span>}
</div>
<h2 className="mt-2 text-lg font-semibold text-white">{item.title}</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">{item.body}</p>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-400">
{item.actor?.name && <span>{item.actor.name}</span>}
<a href={item.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-slate-200">Open</a>
</div>
</div>
</div>
</article>
)) : <div className="rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400">No activity matches this filter.</div>}
</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="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Previous</button>
<span className="text-xs uppercase tracking-[0.18em] text-slate-500">Page {meta.current_page || 1} of {meta.last_page || 1}</span>
<button type="button" disabled={(meta.current_page || 1) >= (meta.last_page || 1)} onClick={() => updateFilters({ page: (meta.current_page || 1) + 1 })} className="rounded-full border border-white/10 px-4 py-2 disabled:opacity-40">Next</button>
</div>
</div>
</StudioLayout>
)
}