@stacksjs/defaults 0.70.227 → 0.70.228

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,92 +2,192 @@
2
2
 
3
3
  @section('content')
4
4
  <script client>
5
- // Dynamic model page (stacksjs/stacks#1838). Previously a 136-line
6
- // `<script server>` block doing ORM lookup + raw-SQLite fallback at
7
- // render time; now backed by
8
- // `GET /api/dashboard/models/{slug}` (Action:
9
- // `Dashboard/Models/ModelShowAction`) which returns the same shape.
5
+ // The page every model gets for free (stacksjs/stacks#1838): a browsable,
6
+ // sortable, filterable table over any model the app defines, with no
7
+ // per-model code.
10
8
  //
11
- // The URL slug is pulled with `useNavigation().urlSegment(-1)` instead
12
- // of `route.params.model` (which only existed inside the old server
13
- // block).
9
+ // All querying is server-side (`GET /api/dashboard/models/{slug}`), so this
10
+ // works the same on a table with 10 rows and one with a million. Rows and
11
+ // writes go through the model, so scopes, casts and observers apply.
14
12
  import { useNavigation } from '../composables/useNavigation'
13
+ import { dashboardApi } from '~/storage/framework/defaults/functions/dashboard-api'
14
+ import { pushToast } from '~/storage/framework/defaults/functions/toasts'
15
+
16
+ interface ColumnMeta {
17
+ name: string
18
+ label: string
19
+ type: 'number' | 'boolean' | 'date' | 'json' | 'text'
20
+ }
15
21
 
16
22
  interface ResponseShape {
17
23
  modelName: string
18
24
  tableName: string
19
25
  rows: Array<Record<string, unknown>>
20
- columns: string[]
21
26
  displayColumns: string[]
27
+ columnMeta: ColumnMeta[]
22
28
  totalCount: number
29
+ page: number
30
+ perPage: number
31
+ lastPage: number
32
+ sort: string
33
+ dir: 'asc' | 'desc'
34
+ writable: boolean
23
35
  source: 'orm' | 'sqlite-fallback'
24
36
  error: string | null
25
37
  }
26
38
 
39
+ const PER_PAGE_OPTIONS = [25, 50, 100, 200]
40
+
27
41
  const modelName = state('')
28
42
  const tableName = state('')
29
43
  const rows = state<Array<Record<string, unknown>>>([])
30
- const displayColumns = state<string[]>([])
44
+ const columnMeta = state<ColumnMeta[]>([])
31
45
  const totalCount = state(0)
46
+ const page = state(1)
47
+ const perPage = state(25)
48
+ const lastPage = state(1)
49
+ const sort = state('')
50
+ const dir = state<'asc' | 'desc'>('desc')
51
+ const search = state('')
52
+ const filters = state<Record<string, string>>({})
53
+ const writable = state(false)
32
54
  const source = state<'orm' | 'sqlite-fallback'>('orm')
33
55
  const error = state<string | null>(null)
34
56
  const loading = state(true)
57
+ const showFilters = state(false)
58
+
59
+ // Row being edited, plus the working copy of its values. `null` closes the
60
+ // editor; the copy is kept separate so cancelling leaves the table row as
61
+ // it was and a failed save does not desync the two.
62
+ const editing = state<Record<string, unknown> | null>(null)
63
+ const editValues = state<Record<string, string>>({})
64
+ const saving = state(false)
65
+ const confirmingDelete = state<Record<string, unknown> | null>(null)
35
66
 
36
- const sourceLabel = derived(() => {
37
- return source() === 'orm' ? 'via Stacks ORM' : 'raw SQLite (no model file)'
67
+ const slug = derived(() => useNavigation().urlSegment(-1) || '')
68
+
69
+ const sourceLabel = derived(() => source() === 'orm' ? 'via Stacks ORM' : 'raw SQLite, read-only')
70
+
71
+ const recordLabel = derived(() => {
72
+ const n = totalCount()
73
+ return `${n.toLocaleString()} ${n === 1 ? 'record' : 'records'}`
38
74
  })
39
75
 
40
- const recordCountLabel = derived(() => {
41
- return totalCount() + ' record' + (totalCount() === 1 ? '' : 's')
76
+ const rangeLabel = derived(() => {
77
+ if (totalCount() === 0) return 'No records'
78
+ const from = (page() - 1) * perPage() + 1
79
+ const to = Math.min(page() * perPage(), totalCount())
80
+ return `${from.toLocaleString()} to ${to.toLocaleString()} of ${totalCount().toLocaleString()}`
42
81
  })
43
82
 
83
+ const activeFilterCount = derived(() => Object.values(filters()).filter(v => v !== '').length)
84
+
85
+ const filterLabel = derived(() => {
86
+ const n = activeFilterCount()
87
+ return n === 0 ? 'Filter' : `Filter (${n})`
88
+ })
89
+
90
+ const isEmpty = derived(() => !loading() && !error() && rows().length === 0)
91
+
92
+ // Fields for the edit sheet, carrying their starting value. Derived from the
93
+ // row being edited rather than from `editValues`, so the list changes exactly
94
+ // once per open: deriving it from the working copy instead would rebuild
95
+ // every input on each keystroke and steal focus mid-word.
96
+ const editFields = derived(() => {
97
+ const row = editing()
98
+ if (!row) return []
99
+ return columnMeta().map(column => ({
100
+ name: column.name,
101
+ label: column.label,
102
+ value: row[column.name] === null || row[column.name] === undefined ? '' : String(row[column.name]),
103
+ // The primary key, the uuid and the timestamps are shown for context but
104
+ // the API refuses to write them, so the input says so rather than
105
+ // accepting an edit that will be silently dropped.
106
+ readonly: column.name === 'id' || column.name === 'uuid' || column.name.endsWith('_at'),
107
+ }))
108
+ })
109
+
110
+ // Skeleton rows while a query is in flight. Matching the real row count
111
+ // keeps the table from collapsing and jumping back on every keystroke.
112
+ const skeletonRows = derived(() => Array.from({ length: Math.min(8, Math.max(3, rows().length || 6)) }, (_, i) => i))
113
+
44
114
  function formatDate(value: unknown): string {
45
- if (!value) return '-'
46
- try {
47
- const date = new Date(value as string | number)
48
- if (Number.isNaN(date.getTime())) return String(value)
49
- return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
50
- }
51
- catch {
52
- return String(value)
53
- }
115
+ const date = new Date(value as string | number)
116
+ if (Number.isNaN(date.getTime())) return String(value)
117
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
54
118
  }
55
119
 
56
- function formatCell(value: unknown, column: string): string {
57
- if (value === null || value === undefined) return '-'
58
- if (column === 'created_at' || column === 'updated_at' || column.endsWith('_at')) return formatDate(value)
120
+ function formatCell(value: unknown, type: ColumnMeta['type']): string {
121
+ if (value === null || value === undefined || value === '') return '-'
122
+ if (type === 'date') return formatDate(value)
123
+ if (type === 'boolean') return (value === true || value === 1 || value === '1') ? 'Yes' : 'No'
124
+ if (type === 'number' && typeof value === 'number') return value.toLocaleString()
59
125
  if (typeof value === 'object') {
60
- try { return JSON.stringify(value).slice(0, 80) }
126
+ try { return JSON.stringify(value).slice(0, 90) }
61
127
  catch { return '[object]' }
62
128
  }
63
- if (typeof value === 'string' && value.length > 100) return value.slice(0, 100) + '…'
64
- return String(value)
129
+ const text = String(value)
130
+ return text.length > 120 ? `${text.slice(0, 120)}…` : text
131
+ }
132
+
133
+ function cellValue(row: Record<string, unknown>, column: ColumnMeta): string {
134
+ return formatCell(row[column.name], column.type)
135
+ }
136
+
137
+ // Returned as COMPLETE class strings and applied with `:class`, because a
138
+ // `{{ }}` placeholder inside a class attribute is not reactively bound.
139
+ function cellClass(column: ColumnMeta): string {
140
+ const align = column.type === 'number' ? 'text-right tabular-nums' : 'text-left'
141
+ const mono = column.type === 'json' ? ' font-mono text-[12px]' : ''
142
+ return `px-3 py-2 max-w-xs truncate whitespace-nowrap text-[13px] text-neutral-800 dark:text-neutral-200 ${align}${mono}`
65
143
  }
66
144
 
67
- function cellValue(row: Record<string, unknown>, col: string): string {
68
- return formatCell(row[col], col)
145
+ function headerClass(column: ColumnMeta): string {
146
+ const align = column.type === 'number' ? 'justify-end' : 'justify-start'
147
+ return `flex items-center gap-1 w-full ${align} font-medium text-[11px] uppercase tracking-wide text-neutral-500 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors`
148
+ }
149
+
150
+ function sortIconClass(column: ColumnMeta): string {
151
+ if (sort() !== column.name) return 'h-3 w-3 opacity-0 group-hover:opacity-40 i-hugeicons-arrow-up-down'
152
+ return dir() === 'asc' ? 'h-3 w-3 i-hugeicons-arrow-up-01' : 'h-3 w-3 i-hugeicons-arrow-down-01'
69
153
  }
70
154
 
71
155
  async function load() {
72
- loading.set(true)
73
- error.set(null)
74
- const slug = useNavigation().urlSegment(-1)
75
- if (!slug) {
76
- error.set('Missing model slug in URL.')
156
+ if (!slug()) {
157
+ error.set('Missing model slug in the URL.')
77
158
  loading.set(false)
78
159
  return
79
160
  }
161
+ loading.set(true)
162
+ error.set(null)
80
163
  try {
81
- const res = await fetch(`/api/dashboard/models/${encodeURIComponent(slug)}`, {
82
- headers: { accept: 'application/json' },
164
+ const params = new URLSearchParams({
165
+ page: String(page()),
166
+ per_page: String(perPage()),
167
+ dir: dir(),
83
168
  })
84
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
85
- const data = await res.json() as Partial<ResponseShape>
169
+ if (sort()) params.set('sort', sort())
170
+ if (search()) params.set('q', search())
171
+ const active = Object.fromEntries(Object.entries(filters()).filter(([, v]) => v !== ''))
172
+ if (Object.keys(active).length > 0) params.set('filters', JSON.stringify(active))
173
+
174
+ const data = await dashboardApi<ResponseShape>(`/api/dashboard/models/${encodeURIComponent(slug())}?${params}`)
175
+
86
176
  modelName.set(data.modelName ?? '')
87
177
  tableName.set(data.tableName ?? '')
88
178
  rows.set(data.rows ?? [])
89
- displayColumns.set(data.displayColumns ?? [])
179
+ // Only reassign when the column set actually changes. It is the same for
180
+ // every page and every sort of a given model, and re-rendering the
181
+ // header `:for` on each query both wastes work and trips a reconciler
182
+ // bug that leaves the columns rotated out of order.
183
+ const nextColumns = data.columnMeta ?? []
184
+ const sameColumns = nextColumns.length === columnMeta().length
185
+ && nextColumns.every((c, i) => c.name === columnMeta()[i]?.name)
186
+ if (!sameColumns) columnMeta.set(nextColumns)
90
187
  totalCount.set(data.totalCount ?? 0)
188
+ lastPage.set(data.lastPage ?? 1)
189
+ sort.set(data.sort ?? '')
190
+ writable.set(data.writable === true)
91
191
  source.set(data.source ?? 'orm')
92
192
  error.set(data.error ?? null)
93
193
  }
@@ -99,69 +199,281 @@ async function load() {
99
199
  }
100
200
  }
101
201
 
202
+ function toggleSort(column: ColumnMeta) {
203
+ if (sort() === column.name) dir.set(dir() === 'asc' ? 'desc' : 'asc')
204
+ else { sort.set(column.name); dir.set('asc') }
205
+ page.set(1)
206
+ void load()
207
+ }
208
+
209
+ // Debounced so a query is not fired per keystroke. The timer id is module
210
+ // scope rather than signal state: it is bookkeeping, not something the
211
+ // template renders.
212
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
213
+
214
+ function onSearchInput(event: Event) {
215
+ search.set((event.target as HTMLInputElement).value)
216
+ clearTimeout(searchTimer)
217
+ searchTimer = setTimeout(() => {
218
+ page.set(1)
219
+ void load()
220
+ }, 250)
221
+ }
222
+
223
+ function onFilterInput(column: string, event: Event) {
224
+ filters.set({ ...filters(), [column]: (event.target as HTMLInputElement).value })
225
+ clearTimeout(searchTimer)
226
+ searchTimer = setTimeout(() => {
227
+ page.set(1)
228
+ void load()
229
+ }, 250)
230
+ }
231
+
232
+ function clearFilters() {
233
+ filters.set({})
234
+ search.set('')
235
+ page.set(1)
236
+ void load()
237
+ }
238
+
239
+ function goToPage(next: number) {
240
+ const target = Math.min(Math.max(1, next), lastPage())
241
+ if (target === page()) return
242
+ page.set(target)
243
+ void load()
244
+ }
245
+
246
+ function onPerPageChange(event: Event) {
247
+ perPage.set(Number((event.target as HTMLSelectElement).value) || 25)
248
+ page.set(1)
249
+ void load()
250
+ }
251
+
252
+ function startEdit(row: Record<string, unknown>) {
253
+ const values: Record<string, string> = {}
254
+ for (const column of columnMeta()) {
255
+ const raw = row[column.name]
256
+ values[column.name] = raw === null || raw === undefined ? '' : String(raw)
257
+ }
258
+ editValues.set(values)
259
+ editing.set(row)
260
+ }
261
+
262
+ function onEditInput(column: string, event: Event) {
263
+ editValues.set({ ...editValues(), [column]: (event.target as HTMLInputElement).value })
264
+ }
265
+
266
+ async function saveEdit() {
267
+ const row = editing()
268
+ if (!row) return
269
+ // Send only what actually changed, so a save never rewrites columns the
270
+ // editor merely displayed.
271
+ const fields: Record<string, unknown> = {}
272
+ for (const [column, value] of Object.entries(editValues())) {
273
+ const original = row[column]
274
+ const originalText = original === null || original === undefined ? '' : String(original)
275
+ if (value !== originalText) fields[column] = value === '' ? null : value
276
+ }
277
+ if (Object.keys(fields).length === 0) {
278
+ editing.set(null)
279
+ return
280
+ }
281
+
282
+ saving.set(true)
283
+ try {
284
+ const result = await dashboardApi<{ ok: boolean, error?: string }>(
285
+ `/api/dashboard/models/${encodeURIComponent(slug())}/${row.id}`,
286
+ { method: 'PATCH', body: { fields } },
287
+ )
288
+ if (!result?.ok) throw new Error(result?.error || 'The update was rejected.')
289
+ pushToast('success', `${modelName()} ${row.id} updated`)
290
+ editing.set(null)
291
+ await load()
292
+ }
293
+ catch (e) {
294
+ pushToast('error', 'Could not save', { detail: e instanceof Error ? e.message : String(e) })
295
+ }
296
+ finally {
297
+ saving.set(false)
298
+ }
299
+ }
300
+
301
+ async function confirmDelete() {
302
+ const row = confirmingDelete()
303
+ if (!row) return
304
+ saving.set(true)
305
+ try {
306
+ const result = await dashboardApi<{ ok: boolean, error?: string }>(
307
+ `/api/dashboard/models/${encodeURIComponent(slug())}/${row.id}`,
308
+ { method: 'DELETE' },
309
+ )
310
+ if (!result?.ok) throw new Error(result?.error || 'The delete was rejected.')
311
+ pushToast('success', `${modelName()} ${row.id} deleted`)
312
+ confirmingDelete.set(null)
313
+ // Deleting the last row of the final page would strand the viewer on an
314
+ // empty page, so step back one.
315
+ if (rows().length === 1 && page() > 1) page.set(page() - 1)
316
+ await load()
317
+ }
318
+ catch (e) {
319
+ pushToast('error', 'Could not delete', { detail: e instanceof Error ? e.message : String(e) })
320
+ }
321
+ finally {
322
+ saving.set(false)
323
+ }
324
+ }
325
+
102
326
  void load()
103
327
  </script>
104
328
 
105
- <div class="p-6 space-y-6">
329
+ <div class="p-6 space-y-4">
106
330
 
107
- <PageHeader :title="modelName()" :description="recordCountLabel() + ' · ' + sourceLabel()">
108
- <template #actions>
109
- <Button variant="primary">
110
- <div class="h-4 w-4 i-hugeicons-add-01"></div>
111
- New <span :text="modelName()"></span>
112
- </Button>
113
- </template>
114
- </PageHeader>
331
+ <!-- Written out rather than using <PageHeader>: that component renders on
332
+ the server, so a `{{ }}` placeholder in it is baked at render time and
333
+ would stay empty for a title that only exists once the fetch resolves.
334
+ `:text` bindings here stay reactive. -->
335
+ <div class="flex flex-wrap gap-4 items-start justify-between">
336
+ <div class="min-w-0">
337
+ <h1 class="font-semibold text-2xl text-neutral-900 truncate dark:text-white" :text="modelName()"></h1>
338
+ <p class="mt-1 text-[13px] text-neutral-500 dark:text-neutral-400" :text="recordLabel() + ' · ' + tableName() + ' · ' + sourceLabel()"></p>
339
+ </div>
340
+ <button
341
+ type="button"
342
+ @click="showFilters.set(!showFilters())"
343
+ class="inline-flex gap-1.5 items-center px-3 h-8 font-medium text-[13px] text-neutral-700 dark:text-neutral-200 bg-white dark:bg-white/10 border border-black/10 rounded-lg dark:border-white/15 hover:bg-neutral-50 dark:hover:bg-white/15 transition-colors"
344
+ >
345
+ <span class="h-4 w-4 i-hugeicons-filter"></span>
346
+ <span :text="filterLabel()"></span>
347
+ </button>
348
+ </div>
349
+
350
+ <!-- Search + page size -->
351
+ <div class="flex flex-wrap gap-3 items-center">
352
+ <div class="relative flex-1 max-w-sm">
353
+ <div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
354
+ <div class="h-4 w-4 text-neutral-400 dark:text-neutral-500 i-hugeicons-search-01"></div>
355
+ </div>
356
+ <input
357
+ type="search"
358
+ :value="search()"
359
+ @input="onSearchInput($event)"
360
+ :placeholder="'Search ' + (modelName() || 'records')"
361
+ class="block pl-9 pr-3 py-1.5 w-full text-[13px] text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 bg-white dark:bg-white/10 border border-black/10 rounded-lg dark:border-white/15 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all"
362
+ >
363
+ </div>
115
364
 
116
- <div :if="error()" class="p-6 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800">
365
+ <div :if="activeFilterCount() > 0 || search()" class="flex items-center">
366
+ <button
367
+ type="button"
368
+ @click="clearFilters()"
369
+ class="px-2.5 h-8 text-[13px] text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition-colors"
370
+ >
371
+ Clear
372
+ </button>
373
+ </div>
374
+
375
+ <div class="flex gap-2 items-center ml-auto text-[12px] text-neutral-500 dark:text-neutral-400">
376
+ <span>Show</span>
377
+ <select
378
+ @change="onPerPageChange($event)"
379
+ class="px-2 h-8 text-[12px] text-neutral-700 dark:text-neutral-200 bg-white dark:bg-white/10 border border-black/10 rounded-md dark:border-white/15 focus:border-blue-500/50"
380
+ >
381
+ <template :for="option in PER_PAGE_OPTIONS">
382
+ <option :value="option" :selected="option === perPage()">{{ option }}</option>
383
+ </template>
384
+ </select>
385
+ </div>
386
+ </div>
387
+
388
+ <div :if="error()" class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-xl dark:border-red-800">
117
389
  <div class="flex gap-3 items-start">
118
390
  <span class="flex-shrink-0 mt-0.5 h-5 w-5 text-red-500 i-hugeicons-alert-02"></span>
119
391
  <div>
120
- <h3 class="font-medium text-red-800 dark:text-red-200">Couldn't load <span :text="modelName()"></span></h3>
121
- <p class="mt-1 text-red-600 text-sm dark:text-red-300" :text="error()"></p>
122
- <p class="mt-2 text-red-500 text-xs dark:text-red-400">
123
- Looked for an ORM model named <code :text="modelName()"></code> and a SQLite table named <code :text="tableName()"></code>.
392
+ <h3 class="font-medium text-[13px] text-red-800 dark:text-red-200">Couldn't load <span :text="modelName()"></span></h3>
393
+ <p class="mt-1 text-[13px] text-red-600 dark:text-red-300" :text="error()"></p>
394
+ <p class="mt-2 text-[12px] text-red-500 dark:text-red-400">
395
+ Looked for an ORM model named <code :text="modelName()"></code> and a table named <code :text="tableName()"></code>.
124
396
  </p>
125
397
  </div>
126
398
  </div>
127
399
  </div>
128
400
 
129
- <div :if="!error() && !loading() && rows().length === 0" class="p-12 text-center bg-white dark:bg-neutral-900 border border-neutral-200 rounded-xl dark:border-neutral-800">
130
- <span class="mx-auto h-12 w-12 text-neutral-300 dark:text-neutral-600 i-hugeicons-database-01"></span>
131
- <h3 class="mt-4 font-medium text-lg text-neutral-900 dark:text-white">No <span :text="modelName()"></span> records</h3>
132
- <p class="mt-2 text-neutral-500 text-sm dark:text-neutral-400">Get started by creating a new <span :text="modelName()"></span>.</p>
401
+ <div :if="isEmpty()" class="p-12 text-center bg-white dark:bg-neutral-900 border border-black/8 rounded-xl dark:border-white/10">
402
+ <span class="mx-auto h-10 w-10 text-neutral-300 dark:text-neutral-600 i-hugeicons-database-01"></span>
403
+ <h3 class="mt-3 font-medium text-[15px] text-neutral-900 dark:text-white">
404
+ <span :if="activeFilterCount() > 0 || search()">Nothing matches those filters</span>
405
+ <span :if="activeFilterCount() === 0 && !search()">No <span :text="modelName()"></span> records yet</span>
406
+ </h3>
407
+ <p class="mt-1.5 text-[13px] text-neutral-500 dark:text-neutral-400">
408
+ <span :if="activeFilterCount() > 0 || search()">Try a broader search, or clear the filters.</span>
409
+ <span :if="activeFilterCount() === 0 && !search()">Seed some with <code>./buddy seed</code>.</span>
410
+ </p>
133
411
  </div>
134
412
 
135
- <div :if="!error() && rows().length > 0" class="overflow-hidden bg-white dark:bg-neutral-900 border border-neutral-200 rounded-xl dark:border-neutral-800">
413
+ <div :if="!error() && (rows().length > 0 || loading())" class="overflow-hidden bg-white dark:bg-neutral-900 border border-black/8 rounded-xl dark:border-white/10">
136
414
  <div class="overflow-x-auto">
137
- <table class="min-w-full divide-neutral-200 divide-y dark:divide-neutral-800">
138
- <thead class="bg-neutral-50 dark:bg-neutral-800/50">
415
+ <table class="min-w-full">
416
+ <thead class="bg-neutral-50 dark:bg-white/5">
139
417
  <tr>
140
- <template :for="column in displayColumns">
141
- <th class="px-4 py-3 font-medium text-left text-neutral-500 text-xs tracking-wider uppercase whitespace-nowrap dark:text-neutral-400">
142
- {{ column.replace(/_/g, ' ') }}
418
+ <template :for="column in columnMeta()">
419
+ <th scope="col" class="group px-3 py-2 whitespace-nowrap">
420
+ <button type="button" @click="toggleSort(column)" :class="headerClass(column)">
421
+ <span>{{ column.label }}</span>
422
+ <span :class="sortIconClass(column)"></span>
423
+ </button>
143
424
  </th>
144
425
  </template>
145
- <th class="px-4 py-3 font-medium text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Actions</th>
426
+ <th :if="writable()" scope="col" class="px-3 py-2 w-24 font-medium text-[11px] text-neutral-500 text-right uppercase tracking-wide dark:text-neutral-400">Actions</th>
427
+ </tr>
428
+
429
+ <!-- Per-column filter row -->
430
+ <tr :if="showFilters()" class="bg-white dark:bg-neutral-900">
431
+ <template :for="column in columnMeta()">
432
+ <th scope="col" class="px-2 pb-2">
433
+ <input
434
+ type="text"
435
+ :value="filters()[column.name] || ''"
436
+ @input="onFilterInput(column.name, $event)"
437
+ :placeholder="column.label"
438
+ class="block px-2 py-1 w-full font-normal text-[12px] text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 bg-white dark:bg-white/10 border border-black/10 rounded-md dark:border-white/15 focus:border-blue-400 focus:outline-none"
439
+ >
440
+ </th>
441
+ </template>
442
+ <th :if="writable()"></th>
146
443
  </tr>
147
444
  </thead>
148
- <tbody class="divide-neutral-200 divide-y dark:divide-neutral-800">
149
- <template :for="row in rows">
150
- <tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
151
- <template :for="column in displayColumns">
152
- <td class="px-4 py-3 max-w-xs text-neutral-900 text-sm truncate whitespace-nowrap dark:text-neutral-100">
153
- {{ cellValue(row, column) }}
445
+
446
+ <!-- Loading skeleton, shaped like the real rows so the table does not
447
+ collapse and jump back on every keystroke. Two <tbody> elements
448
+ rather than one: `:if` and `:for` on the same <template> do not
449
+ compose, and a table may legally carry several bodies. -->
450
+ <tbody :if="loading()" class="divide-black/5 divide-y dark:divide-white/8">
451
+ <template :for="i in skeletonRows()">
452
+ <tr>
453
+ <template :for="column in columnMeta()">
454
+ <td class="px-3 py-2">
455
+ <div class="h-3 rounded bg-neutral-200 dark:bg-white/10 animate-pulse"></div>
154
456
  </td>
155
457
  </template>
156
- <td class="px-4 py-3 text-right">
157
- <div class="flex gap-2 items-center justify-end">
158
- <button class="p-1.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors" title="View">
159
- <span class="h-4 w-4 i-hugeicons-view"></span>
160
- </button>
161
- <button class="p-1.5 text-neutral-400 hover:text-blue-600 transition-colors" title="Edit">
458
+ <td :if="writable()" class="px-3 py-2">
459
+ <div class="ml-auto h-3 w-16 rounded bg-neutral-200 dark:bg-white/10 animate-pulse"></div>
460
+ </td>
461
+ </tr>
462
+ </template>
463
+ </tbody>
464
+
465
+ <tbody :if="!loading()" class="divide-black/5 divide-y dark:divide-white/8">
466
+ <template :for="row in rows()">
467
+ <tr class="hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors">
468
+ <template :for="column in columnMeta()">
469
+ <td :class="cellClass(column)" :title="cellValue(row, column)">{{ cellValue(row, column) }}</td>
470
+ </template>
471
+ <td :if="writable()" class="px-3 py-2 text-right">
472
+ <div class="flex gap-1 items-center justify-end">
473
+ <button type="button" @click="startEdit(row)" class="p-1.5 text-neutral-400 hover:text-blue-600 rounded-md hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors" title="Edit">
162
474
  <span class="h-4 w-4 i-hugeicons-edit-02"></span>
163
475
  </button>
164
- <button class="p-1.5 text-neutral-400 hover:text-red-600 transition-colors" title="Delete">
476
+ <button type="button" @click="confirmingDelete.set(row)" class="p-1.5 text-neutral-400 hover:text-red-600 rounded-md hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors" title="Delete">
165
477
  <span class="h-4 w-4 i-hugeicons-delete-02"></span>
166
478
  </button>
167
479
  </div>
@@ -172,23 +484,91 @@ void load()
172
484
  </table>
173
485
  </div>
174
486
 
175
- <div class="px-4 py-3 bg-neutral-50 dark:bg-neutral-800/30 border-neutral-200 border-t dark:border-neutral-800">
176
- <div class="flex items-center justify-between">
177
- <p class="text-neutral-500 text-sm dark:text-neutral-400">
178
- Showing <span class="font-medium">{{ rows().length }}</span> of <span class="font-medium">{{ totalCount() }}</span>
179
- </p>
180
- <div class="flex gap-2 items-center">
181
- <button class="px-3 py-1.5 font-medium text-neutral-600 text-sm dark:text-neutral-300 bg-white dark:bg-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-700 border border-neutral-300 rounded-lg dark:border-neutral-600 disabled:opacity-50 transition-colors" disabled>
182
- Previous
183
- </button>
184
- <button class="px-3 py-1.5 font-medium text-neutral-600 text-sm dark:text-neutral-300 bg-white dark:bg-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-700 border border-neutral-300 rounded-lg dark:border-neutral-600 transition-colors">
185
- Next
186
- </button>
187
- </div>
487
+ <div class="flex flex-wrap gap-3 items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-white/5 border-black/5 border-t dark:border-white/10">
488
+ <p class="text-[12px] text-neutral-500 dark:text-neutral-400" :text="rangeLabel()"></p>
489
+ <div class="flex gap-1 items-center">
490
+ <button
491
+ type="button"
492
+ @click="goToPage(1)"
493
+ :disabled="page() === 1"
494
+ class="px-2 h-7 text-[12px] text-neutral-600 dark:text-neutral-300 bg-white dark:bg-white/10 border border-black/10 rounded-md dark:border-white/15 hover:bg-neutral-100 dark:hover:bg-white/15 disabled:opacity-40 disabled:pointer-events-none transition-colors"
495
+ >First</button>
496
+ <button
497
+ type="button"
498
+ @click="goToPage(page() - 1)"
499
+ :disabled="page() === 1"
500
+ class="px-2 h-7 text-[12px] text-neutral-600 dark:text-neutral-300 bg-white dark:bg-white/10 border border-black/10 rounded-md dark:border-white/15 hover:bg-neutral-100 dark:hover:bg-white/15 disabled:opacity-40 disabled:pointer-events-none transition-colors"
501
+ >Previous</button>
502
+ <span class="px-2 text-[12px] text-neutral-500 tabular-nums dark:text-neutral-400">{{ page() }} / {{ lastPage() }}</span>
503
+ <button
504
+ type="button"
505
+ @click="goToPage(page() + 1)"
506
+ :disabled="page() >= lastPage()"
507
+ class="px-2 h-7 text-[12px] text-neutral-600 dark:text-neutral-300 bg-white dark:bg-white/10 border border-black/10 rounded-md dark:border-white/15 hover:bg-neutral-100 dark:hover:bg-white/15 disabled:opacity-40 disabled:pointer-events-none transition-colors"
508
+ >Next</button>
509
+ <button
510
+ type="button"
511
+ @click="goToPage(lastPage())"
512
+ :disabled="page() >= lastPage()"
513
+ class="px-2 h-7 text-[12px] text-neutral-600 dark:text-neutral-300 bg-white dark:bg-white/10 border border-black/10 rounded-md dark:border-white/15 hover:bg-neutral-100 dark:hover:bg-white/15 disabled:opacity-40 disabled:pointer-events-none transition-colors"
514
+ >Last</button>
515
+ </div>
516
+ </div>
517
+ </div>
518
+
519
+ <!-- Edit sheet -->
520
+ <div :if="editing()" class="flex fixed inset-0 z-50 items-center justify-center p-4 bg-black/30" @click="editing.set(null)">
521
+ <div class="overflow-hidden bg-white dark:bg-neutral-900 rounded-xl shadow-2xl max-h-[85vh] w-full max-w-lg flex flex-col" @click.stop>
522
+ <div class="flex items-center justify-between px-4 py-3 border-black/5 border-b dark:border-white/10">
523
+ <h2 class="font-semibold text-[14px] text-neutral-900 dark:text-white">Edit <span :text="modelName()"></span></h2>
524
+ <button type="button" @click="editing.set(null)" class="p-1 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200" aria-label="Close">
525
+ <span class="h-4 w-4 i-hugeicons-cancel-01"></span>
526
+ </button>
527
+ </div>
528
+
529
+ <div class="overflow-y-auto flex-1 p-4 space-y-3">
530
+ <template :for="field in editFields()">
531
+ <div class="flex flex-col gap-1.5">
532
+ <label class="font-medium text-[12px] text-neutral-600 dark:text-neutral-300">{{ field.label }}</label>
533
+ <input
534
+ type="text"
535
+ :value="field.value"
536
+ @input="onEditInput(field.name, $event)"
537
+ :readonly="field.readonly"
538
+ class="px-2.5 py-1.5 text-[13px] text-neutral-900 dark:text-neutral-100 bg-white dark:bg-white/10 border border-black/10 rounded-lg dark:border-white/15 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 read-only:bg-neutral-50 read-only:text-neutral-500 dark:read-only:bg-white/5 dark:read-only:text-neutral-400"
539
+ >
540
+ </div>
541
+ </template>
542
+ </div>
543
+
544
+ <div class="flex gap-2 items-center justify-end px-4 py-3 bg-neutral-50 dark:bg-white/5 border-black/5 border-t dark:border-white/10">
545
+ <button type="button" @click="editing.set(null)" class="px-3 h-8 font-medium text-[13px] text-neutral-700 dark:text-neutral-200 bg-white dark:bg-white/10 border border-black/10 rounded-lg dark:border-white/15 hover:bg-neutral-100 dark:hover:bg-white/15 transition-colors">Cancel</button>
546
+ <button type="button" @click="saveEdit()" :disabled="saving()" class="px-3 h-8 font-medium text-[13px] text-white bg-blue-600 rounded-lg hover:bg-blue-700 active:scale-[0.98] disabled:opacity-60 disabled:pointer-events-none transition-all">
547
+ <span :if="!saving()">Save changes</span>
548
+ <span :if="saving()">Saving</span>
549
+ </button>
550
+ </div>
551
+ </div>
552
+ </div>
553
+
554
+ <!-- Delete confirmation -->
555
+ <div :if="confirmingDelete()" class="flex fixed inset-0 z-50 items-center justify-center p-4 bg-black/30" @click="confirmingDelete.set(null)">
556
+ <div class="p-5 bg-white dark:bg-neutral-900 rounded-xl shadow-2xl w-full max-w-sm" @click.stop>
557
+ <h2 class="font-semibold text-[14px] text-neutral-900 dark:text-white">Delete this <span :text="modelName()"></span>?</h2>
558
+ <p class="mt-1.5 text-[13px] text-neutral-500 dark:text-neutral-400">
559
+ Row <span class="font-mono">{{ confirmingDelete().id }}</span> will be removed. This runs through the model, so soft deletes and observers apply.
560
+ </p>
561
+ <div class="flex gap-2 items-center justify-end mt-4">
562
+ <button type="button" @click="confirmingDelete.set(null)" class="px-3 h-8 font-medium text-[13px] text-neutral-700 dark:text-neutral-200 bg-white dark:bg-white/10 border border-black/10 rounded-lg dark:border-white/15 hover:bg-neutral-100 dark:hover:bg-white/15 transition-colors">Cancel</button>
563
+ <button type="button" @click="confirmDelete()" :disabled="saving()" class="px-3 h-8 font-medium text-[13px] text-white bg-red-600 rounded-lg hover:bg-red-700 active:scale-[0.98] disabled:opacity-60 disabled:pointer-events-none transition-all">
564
+ <span :if="!saving()">Delete</span>
565
+ <span :if="saving()">Deleting</span>
566
+ </button>
188
567
  </div>
189
568
  </div>
190
569
  </div>
191
570
  </div>
571
+
192
572
  <style>
193
573
  .overflow-x-auto { -webkit-overflow-scrolling: touch; }
194
574
  </style>