336 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|