feat(web): adopt @vincjo/datatables for all tables, standardize shared components
- Add DataTable.svelte: declarative columns, built-in sorting, sticky headers, text truncation, column alignment, configurable widths, optional pagination/search - 12 built-in renderers: BadgeRenderer, StatusBadgeRenderer (unified risk/severity/ execution/state/type variant mapping), HealthDotRenderer, RelativeTimeRenderer, DateRenderer, DurationRenderer, StatusDotRenderer, SignalActions, ApprovalActions, ActivityAction, ActivityCancel - Migrate Overview (task board), Signals, Ops (3 tables) to DataTable - Refactor EntityTable treegrid to use shared SortHeader, EmptyState, HealthDotRenderer - Create shared components: EmptyState, StatusBadge, FilterTabs - Clean up Knowledge.svelte: replace inline relTime() and typeVariant() with shared utils - Add width, align, truncate column props; table-fixed layout; rounded-xl borders - Bump version to 0.11.0
This commit is contained in:
218
web/src/lib/components/data-table/DataTable.svelte
Normal file
218
web/src/lib/components/data-table/DataTable.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { TableHandler } from '@vincjo/datatables'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import SortHeader from './sort-header.svelte'
|
||||
import Toolbar from './toolbar.svelte'
|
||||
import Pagination from './pagination/Pagination.svelte'
|
||||
import EmptyState from '$lib/components/EmptyState.svelte'
|
||||
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
|
||||
import HealthDotRenderer from './renderers/HealthDotRenderer.svelte'
|
||||
import RelativeTimeRenderer from './renderers/RelativeTimeRenderer.svelte'
|
||||
import DateRenderer from './renderers/DateRenderer.svelte'
|
||||
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
|
||||
import { resolveCellValue } from './columns'
|
||||
import type { DataTableColumn, BuiltinRenderer } from './DataTable.svelte.ts'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Row = Record<string, any>
|
||||
|
||||
const renderers: Record<string, unknown> = {
|
||||
'badge': BadgeRenderer,
|
||||
'health-dot': HealthDotRenderer,
|
||||
'relative-time': RelativeTimeRenderer,
|
||||
'date': DateRenderer,
|
||||
'status-badge': StatusBadgeRenderer,
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
data = [],
|
||||
pageSize = 20,
|
||||
paginated = false,
|
||||
searchable = false,
|
||||
bordered = true,
|
||||
loading = false,
|
||||
emptyMessage = 'No items.',
|
||||
selected = $bindable(null),
|
||||
onRowClick = undefined,
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
columns: DataTableColumn<Row>[]
|
||||
data: Row[]
|
||||
pageSize?: number
|
||||
paginated?: boolean
|
||||
searchable?: boolean
|
||||
bordered?: boolean
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
selected?: string | null
|
||||
onRowClick?: (row: Row) => void
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
|
||||
const table = new TableHandler([], { pageSize: 20 })
|
||||
|
||||
// One SortBuilder per sortable column — each tracks its own direction/isActive
|
||||
// via $derived runes internally.
|
||||
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
|
||||
|
||||
function getSortBuilder(key: string) {
|
||||
if (!sortBuilders.has(key)) {
|
||||
sortBuilders.set(key, table.createSort(key))
|
||||
}
|
||||
return sortBuilders.get(key)!
|
||||
}
|
||||
|
||||
let search = $state.raw(
|
||||
table.createSearch({
|
||||
filterFunction: (row: Row, q: string) => {
|
||||
if (!q) return true
|
||||
const lower = q.toLowerCase()
|
||||
for (const col of columns) {
|
||||
if (col.hidden) continue
|
||||
const val = String(resolveCellValue(row, col) ?? '').toLowerCase()
|
||||
if (val.includes(lower)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
table.setRowsPerPage(pageSize)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
table.setRows(data)
|
||||
})
|
||||
|
||||
function handleSearch(q: string) {
|
||||
search.set(q)
|
||||
if (paginated) table.setPage(1)
|
||||
}
|
||||
|
||||
function colAlignClass(col: DataTableColumn<Row>): string {
|
||||
if (col.align === 'right') return 'text-right'
|
||||
if (col.align === 'center') return 'text-center'
|
||||
return ''
|
||||
}
|
||||
|
||||
function colTruncateClass(col: DataTableColumn<Row>): string {
|
||||
return col.truncate ? 'min-w-0 overflow-hidden text-ellipsis' : ''
|
||||
}
|
||||
|
||||
function colStyle(col: DataTableColumn<Row>): string | undefined {
|
||||
if (!col.width) return undefined
|
||||
const w = typeof col.width === 'number' ? col.width + 'px' : col.width
|
||||
return `width: ${w}; min-width: ${w}`
|
||||
}
|
||||
|
||||
const visibleCols = $derived(columns.filter((c) => !c.hidden))
|
||||
const rows = $derived(table.rows as Row[])
|
||||
|
||||
const skeletonWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
||||
</script>
|
||||
|
||||
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
|
||||
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</Toolbar>
|
||||
|
||||
<div class={['min-h-0 flex-1 overflow-auto relative', bordered ? 'rounded-xl border' : ''].filter(Boolean).join(' ')}>
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr>
|
||||
{#each visibleCols as col (col.key)}
|
||||
<th
|
||||
class={[
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
|
||||
'sticky top-0 z-10 bg-card/95 backdrop-blur',
|
||||
col.headerClass,
|
||||
colAlignClass(col)
|
||||
].filter(Boolean).join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if col.sortable !== false}
|
||||
{@const sb = getSortBuilder(col.key)}
|
||||
<SortHeader
|
||||
label={col.header}
|
||||
sorted={sb.isActive}
|
||||
direction={sb.direction ?? 'asc'}
|
||||
onclick={() => sb.set()}
|
||||
/>
|
||||
{:else}
|
||||
{col.header}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{#if loading}
|
||||
{#each skeletonWidths as w, i}
|
||||
<tr class="border-b transition-colors hover:bg-transparent">
|
||||
{#each visibleCols as col (col.key)}
|
||||
<td class={[col.class, colAlignClass(col), colTruncateClass(col)].filter(Boolean).join(' ')} style={colStyle(col)}>
|
||||
<Skeleton class="h-4 {skeletonWidths[(i + visibleCols.indexOf(col)) % skeletonWidths.length]}" />
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
||||
{:else}
|
||||
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
||||
<tr
|
||||
class={[
|
||||
'border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
tabindex={onRowClick ? 0 : undefined}
|
||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onkeydown={onRowClick
|
||||
? (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) } }
|
||||
: undefined}
|
||||
>
|
||||
{#each visibleCols as col (col.key)}
|
||||
{@const val = resolveCellValue(row, col)}
|
||||
<td
|
||||
class={[
|
||||
'p-2 align-middle whitespace-nowrap',
|
||||
col.class,
|
||||
colAlignClass(col),
|
||||
colTruncateClass(col)
|
||||
].filter(Boolean).join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if typeof col.render === 'string'}
|
||||
{@const R = renderers[col.render]}
|
||||
{#if R}
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||
<R value={val} {row} {...(col.renderProps ?? {})} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
{:else if typeof col.render === 'function'}
|
||||
<col.render {row} value={val} {...(col.renderProps ?? {})} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if paginated}
|
||||
<Pagination {table} />
|
||||
{/if}
|
||||
</div>
|
||||
36
web/src/lib/components/data-table/DataTable.svelte.ts
Normal file
36
web/src/lib/components/data-table/DataTable.svelte.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ComponentType, SvelteComponent } from 'svelte'
|
||||
|
||||
export type BuiltinRenderer = 'badge' | 'health-dot' | 'relative-time' | 'date' | 'status-badge'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CellComponent = ComponentType<SvelteComponent<{ row: any; value: unknown }>>
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
width?: string | number
|
||||
align?: 'left' | 'right' | 'center'
|
||||
truncate?: boolean
|
||||
class?: string
|
||||
headerClass?: string
|
||||
render?: BuiltinRenderer | CellComponent
|
||||
renderProps?: Record<string, unknown>
|
||||
accessor?: (row: T) => unknown
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
pageSize?: number
|
||||
paginated?: boolean
|
||||
searchable?: boolean
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
selected?: string[]
|
||||
onRowClick?: (row: T) => void
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
}
|
||||
9
web/src/lib/components/data-table/columns.ts
Normal file
9
web/src/lib/components/data-table/columns.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { DataTableColumn } from './DataTable.svelte.ts'
|
||||
|
||||
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
|
||||
if (col.accessor) return col.accessor(row)
|
||||
if (col.key in (row as Record<string, unknown>)) {
|
||||
return (row as Record<string, unknown>)[col.key]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { ButtonSize } from '$lib/components/ui/button'
|
||||
|
||||
let {
|
||||
page,
|
||||
active,
|
||||
disabled = false,
|
||||
size = 'xs' as ButtonSize,
|
||||
onclick
|
||||
}: {
|
||||
page: number | string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
size?: ButtonSize
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Button {size} variant={active ? 'default' : 'outline'} {disabled} {onclick}>
|
||||
{String(page)}
|
||||
</Button>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import PageButton from './PageButton.svelte'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let { table }: { table: TableHandler<Record<string, unknown>> } = $props()
|
||||
|
||||
const pages = $derived(table.pagesWithEllipsis as (number | '...')[])
|
||||
const currentPage = $derived(table.currentPage)
|
||||
const pageCount = $derived(table.pageCount)
|
||||
const rowCount = $derived(table.rowCount)
|
||||
</script>
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span class="text-xs text-muted-foreground">{rowCount} rows</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<PageButton
|
||||
page={ChevronLeftIcon}
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => table.setPage('previous')}
|
||||
/>
|
||||
{#each pages as page}
|
||||
{#if page === '...'}
|
||||
<span class="px-1 text-xs text-muted-foreground">…</span>
|
||||
{:else}
|
||||
<PageButton
|
||||
{page}
|
||||
active={page === currentPage}
|
||||
onclick={() => table.setPage(page as number)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
<PageButton
|
||||
page={ChevronRightIcon}
|
||||
disabled={currentPage === pageCount}
|
||||
onclick={() => table.setPage('next')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let { table, class: className }: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
|
||||
|
||||
const options = [10, 20, 50, 100]
|
||||
let value = $state('20')
|
||||
|
||||
function handleChange(newValue: string | undefined) {
|
||||
if (!newValue) return
|
||||
value = newValue
|
||||
table.setRowsPerPage(parseInt(newValue))
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" {value} onValueChange={handleChange}>
|
||||
<Select.Trigger size="sm" class={className}>
|
||||
{value}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each options as n}
|
||||
<Select.Item value={String(n)}>{n} / page</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let { row }: { row: ActivityItem } = $props()
|
||||
|
||||
function fmtDuration(ms: number | null): string {
|
||||
if (ms == null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>{row.verb}</div>
|
||||
{#if row.summary}
|
||||
<div class="text-xs text-muted-foreground">{row.summary}</div>
|
||||
{/if}
|
||||
{#if row.error}
|
||||
<div class="text-xs text-destructive">{row.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
onCancel
|
||||
}: {
|
||||
row: ActivityItem
|
||||
onCancel?: (id: string) => void
|
||||
} = $props()
|
||||
|
||||
function showCancel(status: string): boolean {
|
||||
return ['pending_approval', 'approved', 'running'].includes(status)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{#if showCancel(row.status)}
|
||||
<Button size="sm" variant="outline" onclick={() => onCancel?.(row.id)}>Cancel</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { Approval } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
deciding = null,
|
||||
onApprove,
|
||||
onDeny
|
||||
}: {
|
||||
row: Approval
|
||||
deciding?: string | null
|
||||
onApprove?: (id: string) => void
|
||||
onDeny?: (id: string) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}>Approve</Button>
|
||||
<Button size="sm" variant="destructive" disabled={deciding === row.id} onclick={() => onDeny?.(row.id)}>Deny</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
|
||||
|
||||
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } = $props()
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{String(value ?? '—')}</Badge>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
let { value }: { value: unknown } = $props()
|
||||
|
||||
function format(val: unknown): string {
|
||||
if (!val) return '—'
|
||||
try {
|
||||
return new Date(String(val)).toLocaleString()
|
||||
} catch {
|
||||
return String(val)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{format(value)}</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let { value }: { value: unknown } = $props()
|
||||
|
||||
function fmtDuration(ms: number | null): string {
|
||||
if (ms == null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{fmtDuration(value as number | null)}</span>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import type { Entity } from '$lib/api'
|
||||
|
||||
let { row, value }: { row: Entity; value: unknown } = $props()
|
||||
|
||||
const dot: Record<string, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
const health = $derived(row.health)
|
||||
const lastCheck = $derived(row.last_check_at)
|
||||
|
||||
const title = $derived.by(() => {
|
||||
if (!row.health) return 'not monitored'
|
||||
if (row.health === 'stale') return `stale — last checked ${relativeTime(row.last_check_at)}`
|
||||
return `${row.health} — checked ${relativeTime(row.last_check_at)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={title}>
|
||||
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$lib/utils'
|
||||
|
||||
let { value }: { value: unknown } = $props()
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{relativeTime(String(value ?? ''))}</span>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { Signal } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
acting = null,
|
||||
onAck,
|
||||
onMute,
|
||||
onResolve
|
||||
}: {
|
||||
row: Signal
|
||||
acting?: string | null
|
||||
onAck?: (id: string) => void
|
||||
onMute?: (id: string) => void
|
||||
onResolve?: (id: string) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if row.state === 'raised'}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}>Ack</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}>Mute 1h</Button>
|
||||
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
let { value, kind = 'default' }: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } = $props()
|
||||
|
||||
const v = $derived(String(value ?? ''))
|
||||
|
||||
const variantMap: Record<string, Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary',
|
||||
default: 'default',
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default',
|
||||
default: 'default',
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
denied: 'destructive',
|
||||
revoked: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary',
|
||||
default: 'outline',
|
||||
},
|
||||
state: {
|
||||
active: 'default',
|
||||
healthy: 'default',
|
||||
default: 'outline',
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default',
|
||||
default: 'outline',
|
||||
},
|
||||
default: { default: 'default' },
|
||||
}
|
||||
|
||||
const variant = $derived.by(() => {
|
||||
const map = variantMap[kind] ?? variantMap.default
|
||||
return (map[v] ?? map.default) as 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
})
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{v}</Badge>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { statusStyle } from '$lib/tasks'
|
||||
import type { Session } from '$lib/api'
|
||||
|
||||
let { row }: { row: Session } = $props()
|
||||
|
||||
const st = $derived(statusStyle(row))
|
||||
</script>
|
||||
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
{st.label}
|
||||
</span>
|
||||
55
web/src/lib/components/data-table/search-input.svelte
Normal file
55
web/src/lib/components/data-table/search-input.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import { debounce } from '$lib/utils'
|
||||
|
||||
let {
|
||||
value = '',
|
||||
placeholder = 'Search...',
|
||||
class: className,
|
||||
onSearch
|
||||
}: {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
class?: string
|
||||
onSearch?: (q: string) => void
|
||||
} = $props()
|
||||
|
||||
let inputVal = $state('')
|
||||
|
||||
const debouncedSearch = debounce((q: string) => {
|
||||
onSearch?.(q)
|
||||
}, 200)
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
inputVal = target.value
|
||||
debouncedSearch(inputVal)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
inputVal = ''
|
||||
onSearch?.('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative', className].filter(Boolean).join(' ')}>
|
||||
<SearchIcon class="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
{placeholder}
|
||||
value={inputVal}
|
||||
oninput={handleInput}
|
||||
class="h-8 pl-8 pr-8 text-xs"
|
||||
/>
|
||||
{#if inputVal}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onclick={clear}
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
30
web/src/lib/components/data-table/sort-header.svelte
Normal file
30
web/src/lib/components/data-table/sort-header.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
sorted = false,
|
||||
direction = 'asc',
|
||||
onclick
|
||||
}: {
|
||||
label: string
|
||||
sorted?: boolean
|
||||
direction?: 'asc' | 'desc'
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" {onclick}>
|
||||
{label}
|
||||
{#if sorted}
|
||||
{#if direction === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
|
||||
{/if}
|
||||
</button>
|
||||
33
web/src/lib/components/data-table/toolbar.svelte
Normal file
33
web/src/lib/components/data-table/toolbar.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import SearchInput from './search-input.svelte'
|
||||
import RowsPerPage from './pagination/RowsPerPage.svelte'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let {
|
||||
table,
|
||||
searchable = false,
|
||||
paginated = false,
|
||||
onSearchChange,
|
||||
children
|
||||
}: {
|
||||
table: TableHandler<Record<string, unknown>>
|
||||
searchable?: boolean
|
||||
paginated?: boolean
|
||||
onSearchChange?: (q: string) => void
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
{#if searchable || paginated || children}
|
||||
<div class="flex items-center gap-2 px-1 py-2">
|
||||
{#if searchable}
|
||||
<SearchInput placeholder="Search..." onSearch={onSearchChange} class="w-64" />
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
{@render children?.()}
|
||||
{#if paginated}
|
||||
<RowsPerPage {table} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user