@stacksjs/defaults 0.75.244 → 0.75.246
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/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-native/CATALOG.md +7 -7
- package/ai/skills/stacks-path/SKILL.md +19 -1
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +2 -1
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +2 -1
- package/app/Actions/Dashboard/Source/source-inventory.ts +2 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/tsconfig.app.json +8 -0
- package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +5 -5
- package/resources/components/Dashboard/Ci/CiDashboard.stx +2 -2
- package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +1 -1
- package/resources/components/Dashboard/Content/AuthorsDashboard.stx +59 -20
- package/resources/components/Dashboard/Content/BlogDashboard.stx +2 -2
- package/resources/components/Dashboard/Content/ContentDashboard.stx +2 -2
- package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +50 -18
- package/resources/components/Dashboard/Content/PagesDashboard.stx +54 -10
- package/resources/components/Dashboard/Content/PostsDashboard.stx +91 -20
- package/resources/components/Dashboard/Content/SeoDashboard.stx +61 -59
- package/resources/components/Dashboard/Email/InboxSettingsDashboard.stx +3 -3
- package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +1 -1
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +3 -3
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +2 -2
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +1 -1
- package/resources/components/Dashboard/Library/ComponentsDashboard.stx +47 -81
- package/resources/components/Dashboard/Library/FunctionsDashboard.stx +24 -15
- package/resources/components/Dashboard/Library/PackagesDashboard.stx +68 -13
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +12 -15
- package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +3 -3
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +3 -3
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +1 -1
- package/resources/components/Dashboard/Teams/TeamInvitationDashboard.stx +2 -2
- package/resources/components/Dashboard/UI/Button.stx +29 -3
- package/resources/components/Dashboard/UI/SearchFilter.stx +46 -0
- package/resources/components/Dashboard/UI/Table.stx +43 -20
- package/resources/functions/dashboard/data.ts +27 -4
- package/resources/functions/dashboard/sidebar.ts +3 -2
- package/resources/plugins/preloader.ts +9 -1
- package/views/dashboard/layouts/default.stx +17 -13
- package/views/system-tray/index.stx +1 -1
- package/resources/components/Dashboard/Commerce/Delivery/SearchFilter.stx +0 -24
- package/resources/components/Dashboard/UI/FilterBar.stx +0 -62
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script server>
|
|
2
4
|
import { publicApplicationUrl } from '../../../../functions/public-application-url'
|
|
3
5
|
|
|
@@ -43,7 +45,6 @@ const tags = state<OptionRecord[]>([])
|
|
|
43
45
|
const authors = state<AuthorRecord[]>([])
|
|
44
46
|
const loading = state(true)
|
|
45
47
|
const loadError = state('')
|
|
46
|
-
const search = state('')
|
|
47
48
|
const statusFilter = state('all')
|
|
48
49
|
|
|
49
50
|
const formOpen = state(false)
|
|
@@ -88,12 +89,55 @@ function normalizeAuthor(record: any): AuthorRecord {
|
|
|
88
89
|
return { id: String(record?.id || ''), name: String(record?.name || ''), email: String(record?.email || '') }
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
const COLUMNS = [
|
|
93
|
+
// Two slotted columns, so the single `cell` slot dispatches on `column.key`:
|
|
94
|
+
// the title carries a featured star and two quieter lines, and taxonomy
|
|
95
|
+
// labels its two lines "Categories:" and "Tags:".
|
|
96
|
+
{ key: 'title', label: 'Post', sortable: true, slotted: true, class: 'max-w-md' },
|
|
97
|
+
{ key: 'categoriesLabel', label: 'Taxonomy', slotted: true, class: 'max-w-xs' },
|
|
98
|
+
{
|
|
99
|
+
key: 'status',
|
|
100
|
+
label: 'Publishing',
|
|
101
|
+
type: 'badge' as const,
|
|
102
|
+
sortable: true,
|
|
103
|
+
subtitleKey: 'publishedLabel',
|
|
104
|
+
badgeClasses: {
|
|
105
|
+
published: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
106
|
+
draft: 'text-yellow-700 dark:text-yellow-300 bg-yellow-50 dark:bg-yellow-950/40',
|
|
107
|
+
archived: 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{ key: 'views', label: 'Views', type: 'number' as const, sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
const ROW_ACTIONS = [
|
|
114
|
+
{ action: 'edit', label: 'Edit post', icon: 'i-hugeicons-edit-02' },
|
|
115
|
+
{ action: 'delete', label: 'Delete post', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `UI/Table` owns the search, so this applies only the status filter. The
|
|
120
|
+
* labels are derived onto the row because a slot body reads fields rather than
|
|
121
|
+
* calling back into the component.
|
|
122
|
+
*/
|
|
91
123
|
function visiblePosts(): PostRecord[] {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
(
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
return posts()
|
|
125
|
+
.filter(post => statusFilter() === 'all' || post.status === statusFilter())
|
|
126
|
+
.map(post => ({
|
|
127
|
+
...post,
|
|
128
|
+
summaryLabel: post.excerpt || post.content,
|
|
129
|
+
authorLabel: authorName(post),
|
|
130
|
+
categoriesLabel: optionNames(post.categoryIds, categories()),
|
|
131
|
+
tagsLabel: optionNames(post.tagIds, tags()),
|
|
132
|
+
publishedLabel: formatDate(post.publishedAt),
|
|
133
|
+
})) as PostRecord[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleRowAction(payload: { action: string, row: PostRecord }): void {
|
|
137
|
+
if (payload.action === 'edit')
|
|
138
|
+
openEdit(payload.row)
|
|
139
|
+
else
|
|
140
|
+
openDelete(payload.row)
|
|
97
141
|
}
|
|
98
142
|
|
|
99
143
|
function postCount(value: PostStatus): number {
|
|
@@ -115,14 +159,6 @@ function formatDate(value: string): string {
|
|
|
115
159
|
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
|
|
116
160
|
}
|
|
117
161
|
|
|
118
|
-
function statusClass(value: PostStatus): string {
|
|
119
|
-
return `inline-flex px-2 py-1 font-medium text-xs rounded-full ${({
|
|
120
|
-
published: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
121
|
-
draft: 'text-yellow-700 dark:text-yellow-300 bg-yellow-50 dark:bg-yellow-950/40',
|
|
122
|
-
archived: 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
|
|
123
|
-
}[value])}`
|
|
124
|
-
}
|
|
125
|
-
|
|
126
162
|
function authorName(post: PostRecord): string {
|
|
127
163
|
return post.authorId ? authors().find(author => author.id === post.authorId)?.name || 'Unknown author' : 'No author'
|
|
128
164
|
}
|
|
@@ -224,12 +260,47 @@ onMount(() => { void loadPosts() })
|
|
|
224
260
|
</section>
|
|
225
261
|
|
|
226
262
|
<section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
263
|
+
<Table
|
|
264
|
+
:columns="COLUMNS"
|
|
265
|
+
:data="visiblePosts()"
|
|
266
|
+
:loading="loading()"
|
|
267
|
+
:row-actions="ROW_ACTIONS"
|
|
268
|
+
action-label-key="title"
|
|
269
|
+
searchable
|
|
270
|
+
search-placeholder="Search title, excerpt, or content"
|
|
271
|
+
paginated
|
|
272
|
+
:page-size="25"
|
|
273
|
+
empty-title="No posts found"
|
|
274
|
+
empty-description="Adjust the search or status filter."
|
|
275
|
+
@row-action="handleRowAction"
|
|
276
|
+
>
|
|
277
|
+
<template #toolbar>
|
|
278
|
+
<label>
|
|
279
|
+
<span class="sr-only">Filter by status</span>
|
|
280
|
+
<select x-model="statusFilter" class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600">
|
|
281
|
+
<option value="all">All statuses</option>
|
|
282
|
+
<option value="published">Published</option>
|
|
283
|
+
<option value="draft">Draft</option>
|
|
284
|
+
<option value="archived">Archived</option>
|
|
285
|
+
</select>
|
|
286
|
+
</label>
|
|
287
|
+
</template>
|
|
288
|
+
|
|
289
|
+
<template #cell="{ row, column }">
|
|
290
|
+
<template :if="column.key === 'title'">
|
|
291
|
+
<div class="flex gap-2 items-center">
|
|
292
|
+
<p class="font-medium text-gray-900 text-sm truncate dark:text-white" x-text="row.title"></p>
|
|
293
|
+
<span :if="row.featured" x-tooltip="Featured" title="Featured" class="flex-shrink-0 h-4 w-4 text-amber-500 i-hugeicons-star"></span>
|
|
294
|
+
</div>
|
|
295
|
+
<p class="mt-1 text-gray-500 text-xs truncate dark:text-neutral-400" x-text="row.summaryLabel"></p>
|
|
296
|
+
<p class="mt-1 text-gray-400 text-xs dark:text-neutral-500" x-text="row.authorLabel"></p>
|
|
297
|
+
</template>
|
|
298
|
+
<template :else>
|
|
299
|
+
<p class="text-gray-500 text-xs truncate dark:text-neutral-400"><span class="font-medium text-gray-700 dark:text-neutral-200">Categories:</span> <span x-text="row.categoriesLabel"></span></p>
|
|
300
|
+
<p class="mt-1 text-gray-500 text-xs truncate dark:text-neutral-400"><span class="font-medium text-gray-700 dark:text-neutral-200">Tags:</span> <span x-text="row.tagsLabel"></span></p>
|
|
301
|
+
</template>
|
|
302
|
+
</template>
|
|
303
|
+
</Table>
|
|
233
304
|
</section>
|
|
234
305
|
</div>
|
|
235
306
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
|
-
import { dashboardApi } from '
|
|
3
|
-
import { pushToast } from '
|
|
4
|
+
import { dashboardApi } from '@stacksjs/defaults/functions/dashboard-api'
|
|
5
|
+
import { pushToast } from '@stacksjs/defaults/functions/toasts'
|
|
4
6
|
|
|
5
7
|
interface PostRow {
|
|
6
8
|
id: number
|
|
@@ -117,6 +119,42 @@ const filteredEntries = derived(() => {
|
|
|
117
119
|
})
|
|
118
120
|
})
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The score pill is banded by range, not by value, so `badgeClasses` is keyed
|
|
124
|
+
* on a derived band and `badgeLabelKey` puts the number itself in the pill.
|
|
125
|
+
*/
|
|
126
|
+
const SEO_ROW_ACTIONS = [
|
|
127
|
+
{ action: 'edit', label: 'Edit SEO for', icon: 'i-hugeicons-edit-02' },
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
const SEO_COLUMNS = [
|
|
131
|
+
{ key: 'title', label: 'Post', sortable: true, subtitleKey: 'postMetaLabel', class: 'font-medium text-neutral-900 dark:text-white' },
|
|
132
|
+
{ key: 'focus_keyword', label: 'Focus keyword', sortable: true, emptyText: 'Not set', class: 'text-neutral-600 dark:text-neutral-300' },
|
|
133
|
+
{ key: 'meta_description', label: 'Meta description', emptyText: 'Not set', class: 'max-w-md truncate text-neutral-600 dark:text-neutral-300' },
|
|
134
|
+
{
|
|
135
|
+
key: 'scoreBand',
|
|
136
|
+
label: 'Score',
|
|
137
|
+
type: 'badge' as const,
|
|
138
|
+
align: 'right' as const,
|
|
139
|
+
sortable: true,
|
|
140
|
+
badgeLabelKey: 'score',
|
|
141
|
+
badgeClasses: {
|
|
142
|
+
good: 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200',
|
|
143
|
+
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-200',
|
|
144
|
+
poor: 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{ key: 'updatedLabel', label: 'Updated', sortable: true, class: 'whitespace-nowrap text-neutral-500 dark:text-neutral-400' },
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
/** The page's own filter bar still owns search and the score filter. */
|
|
151
|
+
const seoRows = derived(() => filteredEntries().map(entry => ({
|
|
152
|
+
...entry,
|
|
153
|
+
postMetaLabel: `Post #${entry.id} \u00B7 ${entry.status || 'unknown'}`,
|
|
154
|
+
scoreBand: entry.score >= 80 ? 'good' : entry.score >= 50 ? 'medium' : 'poor',
|
|
155
|
+
updatedLabel: formatDate(entry.updated_at || entry.created_at),
|
|
156
|
+
})))
|
|
157
|
+
|
|
120
158
|
const totalPosts = derived(() => entries().length)
|
|
121
159
|
const optimizedCount = derived(() => entries().filter(entry => entry.score >= 80).length)
|
|
122
160
|
const needsWorkCount = derived(() => entries().filter(entry => entry.score < 50).length)
|
|
@@ -126,15 +164,6 @@ const avgScore = derived(() => {
|
|
|
126
164
|
return Math.round(entries().reduce((total, entry) => total + entry.score, 0) / entries().length)
|
|
127
165
|
})
|
|
128
166
|
|
|
129
|
-
function scoreBadgeClass(score: number): string {
|
|
130
|
-
const base = 'inline-flex items-center px-2 py-0.5 font-semibold text-xs rounded-full'
|
|
131
|
-
if (score >= 80)
|
|
132
|
-
return `${base} bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200`
|
|
133
|
-
if (score >= 50)
|
|
134
|
-
return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-200`
|
|
135
|
-
return `${base} bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200`
|
|
136
|
-
}
|
|
137
|
-
|
|
138
167
|
function formatDate(value?: string | null): string {
|
|
139
168
|
if (!value)
|
|
140
169
|
return 'Not updated'
|
|
@@ -261,54 +290,27 @@ void loadEntries()
|
|
|
261
290
|
@endif
|
|
262
291
|
|
|
263
292
|
<Card variant="default" padding="none">
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<th class="px-4 py-3"><span class="sr-only">Actions</span></th>
|
|
286
|
-
</tr>
|
|
287
|
-
</thead>
|
|
288
|
-
<tbody class="divide-neutral-200/60 divide-y dark:divide-white/5">
|
|
289
|
-
<template :for="entry in filteredEntries">
|
|
290
|
-
<tr class="hover:bg-neutral-50 dark:hover:bg-white/[0.03]">
|
|
291
|
-
<td class="px-4 py-4">
|
|
292
|
-
<div class="font-medium text-neutral-900 text-sm dark:text-white">{{ entry.title }}</div>
|
|
293
|
-
<div class="mt-0.5 text-neutral-500 text-xs dark:text-neutral-400">Post #{{ entry.id }} · {{ entry.status || 'unknown' }}</div>
|
|
294
|
-
</td>
|
|
295
|
-
<td class="px-4 py-4 text-neutral-600 text-sm dark:text-neutral-300">{{ entry.focus_keyword || 'Not set' }}</td>
|
|
296
|
-
<td class="px-4 py-4">
|
|
297
|
-
<div class="max-w-md text-neutral-600 text-sm truncate dark:text-neutral-300">{{ entry.meta_description || 'Not set' }}</div>
|
|
298
|
-
</td>
|
|
299
|
-
<td class="px-4 py-4 text-right">
|
|
300
|
-
<span :class="scoreBadgeClass(entry.score)">{{ entry.score }}</span>
|
|
301
|
-
</td>
|
|
302
|
-
<td class="px-4 py-4 text-neutral-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ formatDate(entry.updated_at || entry.created_at) }}</td>
|
|
303
|
-
<td class="px-4 py-4 text-right">
|
|
304
|
-
<Button variant="ghost" size="sm" @click="openEdit(entry)">Edit</Button>
|
|
305
|
-
</td>
|
|
306
|
-
</tr>
|
|
307
|
-
</template>
|
|
308
|
-
</tbody>
|
|
309
|
-
</table>
|
|
310
|
-
</div>
|
|
311
|
-
@endif
|
|
293
|
+
{{-- `UI/Table` renders its own loading and empty states, so the three-way
|
|
294
|
+
block this replaced is gone.
|
|
295
|
+
|
|
296
|
+
The edit control is a declarative `rowActions` icon rather than the
|
|
297
|
+
`actions` slot, which would have kept the "Edit" wording. The slot
|
|
298
|
+
hangs the page render outright on the installed stx - verified by
|
|
299
|
+
adding one to a working table, watching `/dependencies` stop
|
|
300
|
+
responding, and removing it again. See stacksjs/stx#2101. --}}
|
|
301
|
+
<Table
|
|
302
|
+
:columns="SEO_COLUMNS"
|
|
303
|
+
:data="seoRows()"
|
|
304
|
+
:loading="loading()"
|
|
305
|
+
row-key="id"
|
|
306
|
+
paginated
|
|
307
|
+
:page-size="25"
|
|
308
|
+
empty-title="No matching posts"
|
|
309
|
+
empty-description="Adjust the search or score filter, or create a CMS post first."
|
|
310
|
+
:row-actions="SEO_ROW_ACTIONS"
|
|
311
|
+
action-label-key="title"
|
|
312
|
+
@row-action="openEdit($event.row)"
|
|
313
|
+
/>
|
|
312
314
|
</Card>
|
|
313
315
|
|
|
314
316
|
<SeoEditorDialog
|
|
@@ -262,9 +262,9 @@ import type {
|
|
|
262
262
|
MailTheme,
|
|
263
263
|
NotificationSound,
|
|
264
264
|
ReplyBehavior,
|
|
265
|
-
} from '
|
|
266
|
-
import { fetchInboxActivity } from '
|
|
267
|
-
import { fetchMailPreference, saveMailPreference } from '
|
|
265
|
+
} from '@stacksjs/defaults/functions/mail-preferences'
|
|
266
|
+
import { fetchInboxActivity } from '@stacksjs/defaults/functions/inbox'
|
|
267
|
+
import { fetchMailPreference, saveMailPreference } from '@stacksjs/defaults/functions/mail-preferences'
|
|
268
268
|
|
|
269
269
|
interface SettingsSection {
|
|
270
270
|
id: string
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
// Both column reorder (drag the column header) and card reorder
|
|
13
13
|
// (drag the card body) share the same /cards/reorder + /columns/reorder
|
|
14
14
|
// mutations on the store.
|
|
15
|
-
import { useNavigation } from '
|
|
16
|
-
import { useRole } from '
|
|
17
|
-
import { kanbanStore } from '
|
|
15
|
+
import { useNavigation } from '@stacksjs/defaults/views/dashboard/composables/useNavigation'
|
|
16
|
+
import { useRole } from '@stacksjs/defaults/views/dashboard/composables/useRole'
|
|
17
|
+
import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban'
|
|
18
18
|
|
|
19
19
|
// The component owns its store dependency. Importing the definition creates
|
|
20
20
|
// and registers the singleton before this component controller executes,
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// Follows #1838 conventions: no <script server>, no vanilla DOM
|
|
6
6
|
// reads, all state via the store. Role gate via useRole() - the
|
|
7
7
|
// surface is dev-mode-only (admins + devs see it, clients don't).
|
|
8
|
-
import { useRole } from '
|
|
9
|
-
import { kanbanStore } from '
|
|
8
|
+
import { useRole } from '@stacksjs/defaults/views/dashboard/composables/useRole'
|
|
9
|
+
import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban'
|
|
10
10
|
|
|
11
11
|
// Keep the page component self-contained: the imported store definition is
|
|
12
12
|
// registered before this controller runs on both direct and SPA navigation.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script client>
|
|
2
|
-
import { kanbanStore } from '
|
|
2
|
+
import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban'
|
|
3
3
|
import { useTimeoutFn } from '@stacksjs/composables'
|
|
4
4
|
|
|
5
5
|
const kanban = kanbanStore
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@import('../UI/Button')
|
|
2
|
+
@import('../UI/Table')
|
|
2
3
|
|
|
3
4
|
<script client>
|
|
4
5
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
@@ -25,18 +26,28 @@ const totalBytes = state(0)
|
|
|
25
26
|
const sourceRoot = state('resources/components')
|
|
26
27
|
const loading = state(true)
|
|
27
28
|
const loadError = state('')
|
|
28
|
-
const search = state('')
|
|
29
29
|
const selectedCategory = state('All')
|
|
30
30
|
const dialogOpen = state(false)
|
|
31
31
|
const saving = state(false)
|
|
32
32
|
const saveError = state('')
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const COLUMNS = [
|
|
35
|
+
{ key: 'name', label: 'Component', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
|
|
36
|
+
// A neutral pill with no per-value styling, which is what a badge column
|
|
37
|
+
// renders when it is given no `badgeClasses`.
|
|
38
|
+
{ key: 'category', label: 'Category', type: 'badge' as const, sortable: true },
|
|
39
|
+
{ key: 'path', label: 'Path', sortable: true, class: 'font-mono text-xs text-gray-500 dark:text-neutral-400' },
|
|
40
|
+
// `sizeLabel` is what the cell shows; `bytes` stays on the row so a future
|
|
41
|
+
// numeric sort has something to read.
|
|
42
|
+
{ key: 'sizeLabel', label: 'Size', sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
|
|
43
|
+
{ key: 'updatedAt', label: 'Updated', type: 'datetime' as const, sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/** `UI/Table` owns the search, so this applies only the category filter. */
|
|
47
|
+
function visibleComponents(): Array<ComponentRow & { sizeLabel: string }> {
|
|
48
|
+
return components()
|
|
49
|
+
.filter(component => selectedCategory() === 'All' || component.category === selectedCategory())
|
|
50
|
+
.map(component => ({ ...component, sizeLabel: formatBytes(component.bytes) }))
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
function formatBytes(bytes: number): string {
|
|
@@ -45,13 +56,6 @@ function formatBytes(bytes: number): string {
|
|
|
45
56
|
return `${(bytes / 1024).toFixed(1)} kB`
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
function formatDate(value: string): string {
|
|
49
|
-
const date = new Date(value)
|
|
50
|
-
return Number.isNaN(date.getTime())
|
|
51
|
-
? value
|
|
52
|
-
: new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
59
|
async function loadComponents(): Promise<void> {
|
|
56
60
|
loading.set(true)
|
|
57
61
|
loadError.set('')
|
|
@@ -167,73 +171,35 @@ onMount(() => {
|
|
|
167
171
|
</section>
|
|
168
172
|
|
|
169
173
|
<section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Size</th>
|
|
200
|
-
<th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Updated</th>
|
|
201
|
-
</tr>
|
|
202
|
-
</thead>
|
|
203
|
-
<tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
|
|
204
|
-
<template :if="loading()">
|
|
205
|
-
<tr>
|
|
206
|
-
<td colspan="5" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">
|
|
207
|
-
<span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
|
|
208
|
-
Scanning component sources
|
|
209
|
-
</td>
|
|
210
|
-
</tr>
|
|
211
|
-
</template>
|
|
212
|
-
<template :else-if="visibleComponents().length === 0">
|
|
213
|
-
<tr>
|
|
214
|
-
<td colspan="5" class="px-4 py-14 text-center">
|
|
215
|
-
<span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-layout-table-01"></span>
|
|
216
|
-
<p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No components found</p>
|
|
217
|
-
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create a component or adjust the filters.</p>
|
|
218
|
-
</td>
|
|
219
|
-
</tr>
|
|
220
|
-
</template>
|
|
221
|
-
<template :else>
|
|
222
|
-
<template :for="component in visibleComponents()">
|
|
223
|
-
<tr>
|
|
224
|
-
<td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ component.name }}</td>
|
|
225
|
-
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">
|
|
226
|
-
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-neutral-700 rounded-full" :text="component.category"></span>
|
|
227
|
-
</td>
|
|
228
|
-
<td class="px-4 py-3 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ component.path }}</td>
|
|
229
|
-
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatBytes(component.bytes) }}</td>
|
|
230
|
-
<td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatDate(component.updatedAt) }}</td>
|
|
231
|
-
</tr>
|
|
232
|
-
</template>
|
|
233
|
-
</template>
|
|
234
|
-
</tbody>
|
|
235
|
-
</table>
|
|
236
|
-
</div>
|
|
174
|
+
<Table
|
|
175
|
+
:columns="COLUMNS"
|
|
176
|
+
:data="visibleComponents()"
|
|
177
|
+
:loading="loading()"
|
|
178
|
+
row-key="path"
|
|
179
|
+
searchable
|
|
180
|
+
search-placeholder="Search name, category, or path"
|
|
181
|
+
paginated
|
|
182
|
+
:page-size="25"
|
|
183
|
+
empty-title="No components found"
|
|
184
|
+
empty-description="Adjust the search or category filter."
|
|
185
|
+
>
|
|
186
|
+
<template #toolbar>
|
|
187
|
+
<label>
|
|
188
|
+
<span class="sr-only">Filter by category</span>
|
|
189
|
+
{{-- `x-text` rather than `{{ category }}`: an interpolation inside
|
|
190
|
+
slot content is preserved but never evaluated on the installed
|
|
191
|
+
stx, while the attribute forms read the loop variable fine.
|
|
192
|
+
See stacksjs/stx#2095. --}}
|
|
193
|
+
<select
|
|
194
|
+
x-model="selectedCategory"
|
|
195
|
+
aria-label="Filter by category"
|
|
196
|
+
class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600"
|
|
197
|
+
>
|
|
198
|
+
<option :for="category in categories()" :value="category" x-text="category"></option>
|
|
199
|
+
</select>
|
|
200
|
+
</label>
|
|
201
|
+
</template>
|
|
202
|
+
</Table>
|
|
237
203
|
</section>
|
|
238
204
|
</div>
|
|
239
205
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@import('../UI/Button')
|
|
2
|
+
@import('../UI/Table')
|
|
2
3
|
|
|
3
4
|
<script client>
|
|
4
5
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
@@ -18,14 +19,21 @@ const totalBytes = state(0)
|
|
|
18
19
|
const sourceRoot = state('resources/functions')
|
|
19
20
|
const loading = state(true)
|
|
20
21
|
const loadError = state('')
|
|
21
|
-
const search = state('')
|
|
22
22
|
const dialogOpen = state(false)
|
|
23
23
|
const saving = state(false)
|
|
24
24
|
const saveError = state('')
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const COLUMNS = [
|
|
27
|
+
{ key: 'name', label: 'Function', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
|
|
28
|
+
{ key: 'path', label: 'Path', sortable: true, class: 'font-mono text-xs text-gray-500 dark:text-neutral-400' },
|
|
29
|
+
// `bytes` so it sorts numerically; `sizeLabel` is what the cell shows.
|
|
30
|
+
{ key: 'sizeLabel', label: 'Size', sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
|
|
31
|
+
{ key: 'updatedAt', label: 'Updated', type: 'datetime' as const, sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/** `UI/Table` owns the search, so this only shapes the rows it reads. */
|
|
35
|
+
function visibleFunctions(): Array<FunctionRow & { sizeLabel: string }> {
|
|
36
|
+
return functions().map(fn => ({ ...fn, sizeLabel: formatBytes(fn.bytes) }))
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
function formatBytes(bytes: number): string {
|
|
@@ -34,11 +42,6 @@ function formatBytes(bytes: number): string {
|
|
|
34
42
|
return `${(bytes / 1024).toFixed(1)} kB`
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
function formatDate(value: string): string {
|
|
38
|
-
const date = new Date(value)
|
|
39
|
-
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
45
|
async function loadFunctions(): Promise<void> {
|
|
43
46
|
loading.set(true)
|
|
44
47
|
loadError.set('')
|
|
@@ -107,12 +110,18 @@ onMount(() => { void loadFunctions() })
|
|
|
107
110
|
</section>
|
|
108
111
|
|
|
109
112
|
<section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
<Table
|
|
114
|
+
:columns="COLUMNS"
|
|
115
|
+
:data="visibleFunctions()"
|
|
116
|
+
:loading="loading()"
|
|
117
|
+
row-key="path"
|
|
118
|
+
searchable
|
|
119
|
+
search-placeholder="Search name or path"
|
|
120
|
+
paginated
|
|
121
|
+
:page-size="25"
|
|
122
|
+
empty-title="No functions found"
|
|
123
|
+
empty-description="Create a function or adjust the search."
|
|
124
|
+
/>
|
|
116
125
|
</section>
|
|
117
126
|
</div>
|
|
118
127
|
|