@stacksjs/defaults 0.70.227 → 0.70.229
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.
- package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +36 -0
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +256 -49
- package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +59 -0
- package/app/Actions/Dashboard/Models/model-write.ts +52 -0
- package/functions/dashboard-api.ts +69 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/routes/dashboard-api.ts +10 -3
- package/views/dashboard/models/[model].stx +472 -92
|
@@ -2,92 +2,192 @@
|
|
|
2
2
|
|
|
3
3
|
@section('content')
|
|
4
4
|
<script client>
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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
|
|
37
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,
|
|
57
|
-
if (value === null || value === undefined) return '-'
|
|
58
|
-
if (
|
|
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,
|
|
126
|
+
try { return JSON.stringify(value).slice(0, 90) }
|
|
61
127
|
catch { return '[object]' }
|
|
62
128
|
}
|
|
63
|
-
|
|
64
|
-
return
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
82
|
-
|
|
164
|
+
const params = new URLSearchParams({
|
|
165
|
+
page: String(page()),
|
|
166
|
+
per_page: String(perPage()),
|
|
167
|
+
dir: dir(),
|
|
83
168
|
})
|
|
84
|
-
if (
|
|
85
|
-
|
|
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
|
-
|
|
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-
|
|
329
|
+
<div class="p-6 space-y-4">
|
|
106
330
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
<p class="mt-2 text-red-500
|
|
123
|
-
Looked for an ORM model named <code :text="modelName()"></code> and a
|
|
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="
|
|
130
|
-
<span class="mx-auto h-
|
|
131
|
-
<h3 class="mt-
|
|
132
|
-
|
|
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-
|
|
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
|
|
138
|
-
<thead class="bg-neutral-50 dark:bg-
|
|
415
|
+
<table class="min-w-full">
|
|
416
|
+
<thead class="bg-neutral-50 dark:bg-white/5">
|
|
139
417
|
<tr>
|
|
140
|
-
<template :for="column in
|
|
141
|
-
<th class="px-
|
|
142
|
-
|
|
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-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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-
|
|
157
|
-
<div class="
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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>
|