336 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import {
filterAssets,
loadAdminAssets,
loadAssetAccessEvents,
loadAssetClaims,
updateAssetStatus,
type AssetAccessEvent,
type AssetClaim,
type AssetRecord,
type AssetStatus,
} from '../admin-assets'
type LoadState = 'loading' | 'ready' | 'error'
const STATUS_FILTERS: Array<{ value: 'all' | AssetStatus; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
{ value: 'quarantined', label: 'Quarantined' },
{ value: 'disabled', label: 'Disabled' },
{ value: 'deleted', label: 'Deleted' },
]
function statusClassName(status: AssetStatus): string {
if (status === 'active') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (status === 'quarantined') return 'border-amber-200 bg-amber-50 text-amber-700'
if (status === 'disabled' || status === 'deleted') return 'border-rose-200 bg-rose-50 text-rose-700'
if (status === 'archived') return 'border-zinc-200 bg-zinc-100 text-zinc-700'
return 'border-zinc-200 bg-zinc-100 text-zinc-600'
}
function formatDate(value: number | null): string {
if (value === null) return 'Unknown'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value)
}
function claimStatusClassName(status: string): string {
const normalized = status.toLowerCase()
if (normalized.includes('resolved')) return 'text-emerald-700'
if (normalized.includes('rejected')) return 'text-zinc-700'
if (normalized.includes('review') || normalized.includes('received')) return 'text-amber-700'
if (normalized.includes('disabled')) return 'text-rose-700'
return 'text-zinc-600'
}
export function AdminAssetsPage() {
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [assets, setAssets] = useState<AssetRecord[]>([])
const [statusFilter, setStatusFilter] = useState<'all' | AssetStatus>('all')
const [query, setQuery] = useState('')
const [selectedAssetID, setSelectedAssetID] = useState<string | null>(null)
const [accessEvents, setAccessEvents] = useState<AssetAccessEvent[]>([])
const [claims, setClaims] = useState<AssetClaim[]>([])
const [claimsPlaceholder, setClaimsPlaceholder] = useState('')
const [loadingDetail, setLoadingDetail] = useState(false)
const loadAssets = async () => {
try {
setState('loading')
setError('')
const rows = await loadAdminAssets()
setAssets(rows)
setSelectedAssetID(current => current ?? rows[0]?.id ?? null)
setState('ready')
} catch (err) {
setAssets([])
setSelectedAssetID(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load asset library')
}
}
const selectedAsset = useMemo(() => {
return assets.find(asset => asset.id === selectedAssetID) ?? null
}, [assets, selectedAssetID])
const filteredAssets = useMemo(() => {
return filterAssets(assets, statusFilter, query)
}, [assets, query, statusFilter])
useEffect(() => {
void loadAssets()
}, [])
useEffect(() => {
const assetID = selectedAsset?.id
if (!assetID) {
setAccessEvents([])
setClaims([])
setClaimsPlaceholder('')
return
}
const loadDetails = async () => {
setLoadingDetail(true)
try {
const [events, claimRows] = await Promise.all([
loadAssetAccessEvents(assetID),
loadAssetClaims(assetID),
])
setAccessEvents(events)
setClaims(claimRows)
setClaimsPlaceholder(claimRows.length === 0 ? 'No copyright claims are recorded for this asset yet.' : '')
} catch {
setAccessEvents([])
setClaims([])
setClaimsPlaceholder('Copyright claims are currently unavailable for this asset.')
} finally {
setLoadingDetail(false)
}
}
void loadDetails()
}, [selectedAsset?.id])
const onStatusAction = async (asset: AssetRecord, action: 'quarantine' | 'disable' | 'restore') => {
try {
const updated = await updateAssetStatus(asset.id, action)
setAssets(current => current.map(row => row.id === updated.id ? updated : row))
setSelectedAssetID(updated.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update asset status')
setState('error')
}
}
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-assets-loading">
<h2 className="text-lg font-semibold text-zinc-900">Asset Library</h2>
<p className="mt-2 text-sm text-zinc-600">Loading protected assets and copyright queue...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-assets-error">
<h2 className="text-lg font-semibold text-zinc-900">Asset Library</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void loadAssets() }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-5 p-6" data-testid="admin-assets-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Asset Library</h2>
<p className="mt-1 text-sm text-zinc-600">
Review asset protection posture, copyright metadata, access events, and enforcement actions.
</p>
</header>
<section className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Asset status filter">
{STATUS_FILTERS.map(filter => (
<button
key={filter.value}
type="button"
onClick={() => setStatusFilter(filter.value)}
className={`rounded-full border px-3 py-1 text-xs font-medium ${statusFilter === filter.value ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50'}`}
>
{filter.label}
</button>
))}
</div>
<label className="text-xs text-zinc-600">
Search assets
<input
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="title, owner, licence, linked resource"
className="ml-2 w-full min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900 xl:w-[320px]"
/>
</label>
</section>
<section className="grid gap-3 xl:grid-cols-[1.35fr_1fr]">
<div className="overflow-x-auto rounded-lg border border-zinc-200 bg-white" data-testid="admin-assets-table">
<table className="responsive-table min-w-full text-sm">
<thead>
<tr className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
<th className="px-3 py-2 font-semibold">Asset</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Visibility</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Licence</th>
<th className="px-3 py-2 font-semibold">Protection</th>
<th className="px-3 py-2 font-semibold">Related</th>
<th className="px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredAssets.map(asset => (
<tr
key={asset.id}
onClick={() => setSelectedAssetID(asset.id)}
className={`cursor-pointer border-t border-zinc-100 ${selectedAsset?.id === asset.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`}
>
<td data-primary="true" data-label="Asset" className="px-3 py-2">
<p className="font-medium text-zinc-900">{asset.title}</p>
<p className="text-xs text-zinc-500">{asset.id}</p>
</td>
<td data-label="Status" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName(asset.status)}`}>
{asset.status}
</span>
</td>
<td data-label="Visibility" className="px-3 py-2 text-zinc-700">{asset.visibility}</td>
<td data-label="Owner" className="px-3 py-2 text-zinc-700">{asset.owner}</td>
<td data-label="Licence" className="px-3 py-2 text-zinc-700">{asset.licence}</td>
<td data-label="Protection" className="px-3 py-2 text-zinc-700">{asset.protectionLevel}</td>
<td data-label="Related" className="px-3 py-2 text-zinc-700">{asset.relatedResource}</td>
<td data-label="Actions" className="px-3 py-2">
<div className="flex flex-wrap gap-1" onClick={event => event.stopPropagation()}>
{asset.status !== 'quarantined' ? (
<button
type="button"
onClick={() => { void onStatusAction(asset, 'quarantine') }}
className="rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
>
Quarantine
</button>
) : null}
{asset.status !== 'disabled' ? (
<button
type="button"
onClick={() => { void onStatusAction(asset, 'disable') }}
className="rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700"
>
Disable
</button>
) : null}
{asset.status !== 'active' ? (
<button
type="button"
onClick={() => { void onStatusAction(asset, 'restore') }}
className="rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700"
>
Restore
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredAssets.length === 0 ? (
<div className="border-t border-zinc-100 p-6 text-center text-sm text-zinc-600" data-testid="admin-assets-empty">
No assets matched this filter.
</div>
) : null}
</div>
<aside className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4" data-testid="admin-assets-detail">
{selectedAsset ? (
<>
<div>
<h3 className="text-base font-semibold text-zinc-900">{selectedAsset.title}</h3>
<p className="text-xs text-zinc-500">{selectedAsset.id}</p>
</div>
<section>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Copyright metadata</h4>
<pre className="mt-1 overflow-x-auto rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-700">
{JSON.stringify(selectedAsset.copyrightMetadata, null, 2)}
</pre>
</section>
<section>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Access events</h4>
{loadingDetail ? (
<p className="mt-1 text-xs text-zinc-600">Loading asset detail...</p>
) : accessEvents.length === 0 ? (
<p className="mt-1 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
No access events are available for this asset.
</p>
) : (
<ul className="mt-1 space-y-1 text-xs">
{accessEvents.slice(0, 8).map(event => (
<li key={event.id} className="rounded-md border border-zinc-200 p-2">
<p className="font-medium text-zinc-900">{event.action} · {event.result}</p>
<p className="text-zinc-600">{event.actor} · {event.reason} · {formatDate(event.createdAt)}</p>
</li>
))}
</ul>
)}
</section>
<section>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Copyright review queue</h4>
{claimsPlaceholder ? (
<p className="mt-1 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">{claimsPlaceholder}</p>
) : claims.length === 0 ? (
<p className="mt-1 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
No claims are currently associated with this asset.
</p>
) : (
<ul className="mt-1 space-y-1 text-xs">
{claims.slice(0, 8).map(claim => (
<li key={claim.id} className="rounded-md border border-zinc-200 p-2">
<p className="font-medium text-zinc-900">{claim.claimType}</p>
<p className={`font-medium ${claimStatusClassName(claim.status)}`}>{claim.status}</p>
<p className="text-zinc-600">{claim.claimant} · {formatDate(claim.submittedAt)}</p>
</li>
))}
</ul>
)}
</section>
</>
) : (
<p className="text-sm text-zinc-600">Select an asset to inspect metadata, events, and claims.</p>
)}
</aside>
</section>
</div>
)
}