200 lines
8.2 KiB
TypeScript
200 lines
8.2 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Listbox } from '@headlessui/react'
|
|
import { getObjects, getObjectDefs, createObject } from '../api'
|
|
import { formatRelativeTime } from '../utils'
|
|
import { Spinner, EmptyState, Pagination } from './Common'
|
|
|
|
export default function Objects() {
|
|
const [data, setData] = useState<Array<{ id: string; type: string; created_at: string }>>([])
|
|
const [types, setTypes] = useState<string[]>([])
|
|
const [filter, setFilter] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [total, setTotal] = useState(0)
|
|
const [limit, setLimit] = useState(50)
|
|
const [offset, setOffset] = useState(0)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [createType, setCreateType] = useState('')
|
|
const [creating, setCreating] = useState(false)
|
|
const [createMsg, setCreateMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
|
|
|
useEffect(() => {
|
|
getObjectDefs()
|
|
.then((res) => setTypes(res.object_defs.map((d) => d.name)))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
getObjects(filter || undefined, limit, offset)
|
|
.then((res) => {
|
|
setData(res.objects)
|
|
setTotal(res.total)
|
|
})
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false))
|
|
}, [filter, limit, offset])
|
|
|
|
const handleFilterChange = (newFilter: string) => {
|
|
setFilter(newFilter)
|
|
setOffset(0) // reset to first page
|
|
}
|
|
|
|
const refresh = () => {
|
|
setLoading(true)
|
|
getObjects(filter || undefined, limit, offset)
|
|
.then((res) => { setData(res.objects); setTotal(res.total) })
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
if (!createType) return
|
|
setCreating(true)
|
|
setCreateMsg(null)
|
|
try {
|
|
const obj = await createObject(createType)
|
|
setCreateMsg({ ok: true, text: `Created object #${obj.id} (${obj.type})` })
|
|
setShowCreate(false)
|
|
setCreateType('')
|
|
refresh()
|
|
setTimeout(() => setCreateMsg(null), 3000)
|
|
} catch (err: any) {
|
|
setCreateMsg({ ok: false, text: err.message || 'Failed to create object' })
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return <Spinner />
|
|
if (error) return <div className="text-red-500">Error: {error}</div>
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-2xl font-bold">Objects</h2>
|
|
{!showCreate ? (
|
|
<button
|
|
onClick={() => { setShowCreate(true); setCreateType(types[0] || '') }}
|
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-sm rounded-lg transition-colors"
|
|
disabled={types.length === 0}
|
|
>
|
|
+ Create
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={createType}
|
|
onChange={(e) => setCreateType(e.target.value)}
|
|
className="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-blue-500 focus:outline-none"
|
|
>
|
|
{types.map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={creating || !createType}
|
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 text-white text-sm rounded-lg transition-colors"
|
|
>
|
|
{creating ? '...' : '✓'}
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowCreate(false); setCreateMsg(null) }}
|
|
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
{createMsg && (
|
|
<span className={`text-sm ${createMsg.ok ? 'text-green-400' : 'text-red-400'}`}>
|
|
{createMsg.text}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Listbox value={filter} onChange={handleFilterChange}>
|
|
<div className="relative w-64">
|
|
<Listbox.Button className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-left focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all">
|
|
<span className={filter ? 'text-gray-100' : 'text-gray-500'}>{filter || 'All Types'}</span>
|
|
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
</Listbox.Button>
|
|
<Listbox.Options className="absolute z-10 mt-1 w-full bg-gray-800 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
|
|
<Listbox.Option
|
|
value=""
|
|
className={({ active }) =>
|
|
`cursor-pointer select-none px-4 py-2 transition-colors ${
|
|
active ? 'bg-blue-600 text-white' : 'text-gray-100'
|
|
}`
|
|
}
|
|
>
|
|
{({ selected }) => <span className={selected ? 'font-semibold' : 'font-normal'}>All Types</span>}
|
|
</Listbox.Option>
|
|
{types.map((t) => (
|
|
<Listbox.Option
|
|
key={t}
|
|
value={t}
|
|
className={({ active }) =>
|
|
`cursor-pointer select-none px-4 py-2 transition-colors ${
|
|
active ? 'bg-blue-600 text-white' : 'text-gray-100'
|
|
}`
|
|
}
|
|
>
|
|
{({ selected }) => <span className={selected ? 'font-semibold' : 'font-normal'}>{t}</span>}
|
|
</Listbox.Option>
|
|
))}
|
|
</Listbox.Options>
|
|
</div>
|
|
</Listbox>
|
|
</div>
|
|
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
|
|
{data.length === 0 ? (
|
|
<EmptyState message="No objects found" />
|
|
) : (
|
|
<table className="w-full">
|
|
<thead className="bg-gray-800/80 border-b border-gray-700">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">ID</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
Created
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-800">
|
|
{data.map((obj, i) => (
|
|
<tr
|
|
key={i}
|
|
className={`transition-colors ${
|
|
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
|
|
} hover:bg-gray-800/60`}
|
|
>
|
|
<td className="px-4 py-3 font-mono text-sm text-gray-300">{obj.id}</td>
|
|
<td className="px-4 py-3 text-gray-100">
|
|
<span className="inline-block px-2 py-1 bg-blue-900/30 text-blue-300 rounded text-sm">
|
|
{obj.type}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400">{formatRelativeTime(obj.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|