Implement creator studio and upload updates
This commit is contained in:
290
resources/js/Pages/Studio/StudioAssets.jsx
Normal file
290
resources/js/Pages/Studio/StudioAssets.jsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unknown'
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown'
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function StudioAssets() {
|
||||
const { props } = usePage()
|
||||
const assets = props.assets || {}
|
||||
const items = assets.items || []
|
||||
const summary = assets.summary || []
|
||||
const highlights = assets.highlights || {}
|
||||
const filters = assets.filters || {}
|
||||
const meta = assets.meta || {}
|
||||
const typeOptions = assets.type_options || []
|
||||
const sourceOptions = assets.source_options || []
|
||||
const sortOptions = assets.sort_options || []
|
||||
|
||||
const trackReuse = (asset, destination) => {
|
||||
trackStudioEvent('studio_asset_reused', {
|
||||
surface: studioSurface(),
|
||||
module: 'assets',
|
||||
item_module: asset.source_key || 'assets',
|
||||
item_id: asset.numeric_id,
|
||||
meta: {
|
||||
asset_id: asset.id,
|
||||
asset_type: asset.type,
|
||||
destination,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilters = (patch) => {
|
||||
const next = {
|
||||
...filters,
|
||||
...patch,
|
||||
}
|
||||
|
||||
if (patch.page == null) {
|
||||
next.page = 1
|
||||
}
|
||||
|
||||
trackStudioEvent('studio_filter_used', {
|
||||
surface: studioSurface(),
|
||||
module: 'assets',
|
||||
meta: patch,
|
||||
})
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label className="space-y-2 text-sm text-slate-300 xl:col-span-2">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search assets</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filters.q || ''}
|
||||
onChange={(event) => updateFilters({ q: event.target.value })}
|
||||
placeholder="Title, source, or description"
|
||||
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">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-sm 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.2em] text-slate-500">Source</span>
|
||||
<select
|
||||
value={filters.source || 'all'}
|
||||
onChange={(event) => updateFilters({ source: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{sourceOptions.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.2em] text-slate-500">Sort</span>
|
||||
<select
|
||||
value={filters.sort || 'recent'}
|
||||
onChange={(event) => updateFilters({ sort: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Library volume</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{Number(meta.total || 0).toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-500">creator assets available</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{summary.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => updateFilters({ type: item.key })}
|
||||
className={`rounded-[24px] border p-5 text-left transition ${filters.type === item.key ? 'border-sky-300/25 bg-sky-300/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 text-slate-200">
|
||||
<i className={item.icon} />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{Number(item.count || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<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> assets
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateFilters({ type: 'all', source: 'all', sort: 'recent', q: '' })}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-slate-200"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-left" />
|
||||
Reset filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<section className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((asset) => (
|
||||
<article key={asset.id} className="overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] shadow-[0_18px_50px_rgba(3,7,18,0.18)]">
|
||||
<div className="relative aspect-[1.15/1] overflow-hidden bg-slate-950/70">
|
||||
{asset.image_url ? (
|
||||
<img src={asset.image_url} alt={asset.title} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-photo-film text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full border border-black/10 bg-black/45 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white backdrop-blur-md">
|
||||
<span>{asset.type_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">{asset.source_label}</p>
|
||||
<h2 className="mt-1 truncate text-lg font-semibold text-white">{asset.title}</h2>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{asset.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-500">
|
||||
<span>Used {Number(asset.usage_count || 0).toLocaleString()} times</span>
|
||||
<span>Updated {formatDate(asset.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{(asset.usage_references || []).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
{(asset.usage_references || []).slice(0, 2).map((reference) => (
|
||||
<a key={`${asset.id}-${reference.href}`} href={reference.href} className="rounded-full border border-white/10 px-2.5 py-1 transition hover:border-white/20 hover:text-white">
|
||||
{reference.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={asset.manage_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-pen-to-square" />
|
||||
Manage
|
||||
</a>
|
||||
<a href={asset.view_url} onClick={() => trackReuse(asset, asset.view_url)} 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-repeat" />
|
||||
Reuse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-6 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 assets match this view</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-400">Try another asset type or a broader search term. This library includes card backgrounds, story covers, collection covers, artwork previews, and profile branding.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-lg font-semibold text-white">Recent uploads</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(highlights.recent_uploads || []).slice(0, 5).map((asset) => (
|
||||
<a key={`${asset.id}-recent`} href={asset.manage_url} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
{asset.image_url ? <img src={asset.image_url} alt={asset.title} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 text-slate-500"><i className="fa-solid fa-photo-film" /></div>}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{asset.title}</div>
|
||||
<div className="text-xs text-slate-500">{asset.type_label}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-lg font-semibold text-white">Most reused</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(highlights.most_used || []).slice(0, 5).map((asset) => (
|
||||
<a key={`${asset.id}-used`} href={asset.manage_url} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{asset.title}</div>
|
||||
<div className="text-xs text-slate-500">{asset.source_label}</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{Number(asset.usage_count || 0).toLocaleString()}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<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">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="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>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user