@stacksjs/defaults 0.75.326 → 0.75.329
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/ide/vscode/package.json +1 -1
- package/package.json +4 -4
- package/resources/components/Dashboard/Commerce/CommerceCouponBreakdown.stx +1 -1
- package/resources/components/Dashboard/Content/AuthorsDashboard.stx +1 -1
- package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +1 -1
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +53 -61
- package/resources/components/Dashboard/Content/FileStoragePanel.stx +99 -0
- package/resources/components/Dashboard/Content/PagesDashboard.stx +1 -1
- package/resources/components/Dashboard/Email/CapturedMailDetail.stx +1 -1
- package/resources/components/Dashboard/Infrastructure/ServerlessDashboard.stx +1 -1
- package/resources/components/Dashboard/UI/Tabs.stx +7 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-dashboard
|
|
3
|
-
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the
|
|
3
|
+
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 414 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
8
8
|
|
|
9
9
|
# Stacks Dashboard
|
|
10
10
|
|
|
11
|
-
The Stacks admin dashboard provides a full-featured admin panel with 100+ route views,
|
|
11
|
+
The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 414 components, and a multi-section layout.
|
|
12
12
|
|
|
13
13
|
## Key Paths
|
|
14
14
|
- Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.329",
|
|
6
6
|
"description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/chrisbbreuer",
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.329",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@iconify-json/f7": "^1.2.2",
|
|
47
47
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
48
|
-
"@stacksjs/mobile": "0.75.
|
|
49
|
-
"@stacksjs/sanitizer": "0.2.
|
|
50
|
-
"@stacksjs/stx": "0.2.
|
|
48
|
+
"@stacksjs/mobile": "0.75.329",
|
|
49
|
+
"@stacksjs/sanitizer": "0.2.720",
|
|
50
|
+
"@stacksjs/stx": "0.2.720",
|
|
51
51
|
"@xterm/addon-fit": "^0.11.0",
|
|
52
52
|
"@xterm/xterm": "^6.0.0",
|
|
53
53
|
"ts-qr-codes": "^0.1.8"
|
|
@@ -27,7 +27,7 @@ function mostUsed(): CouponRecord[] {
|
|
|
27
27
|
}
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
|
-
<section aria-label="Coupon breakdowns" class="grid gap-3 lg:grid-cols-3">
|
|
30
|
+
<section aria-label="Coupon breakdowns" class="grid gap-3 grid-cols-1 lg:grid-cols-3">
|
|
31
31
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><h2 class="font-semibold text-gray-900 text-sm dark:text-white">Status distribution</h2><div class="mt-4 space-y-3"><template :for="item in statuses()" :key="item.label"><div><div class="flex justify-between text-xs"><span class="text-gray-500 dark:text-neutral-400">{{ item.label }}</span><span class="font-mono text-gray-700 dark:text-neutral-200">{{ item.count }}</span></div><div class="mt-1 h-1.5 bg-gray-100 dark:bg-neutral-700 rounded-full"><div :class="'h-full rounded-full ' + item.color" :style="'width: ' + width(item.count)"></div></div></div></template></div></article>
|
|
32
32
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><h2 class="font-semibold text-gray-900 text-sm dark:text-white">Discount types</h2><dl class="mt-3 divide-gray-100 divide-y dark:divide-neutral-700"><template :for="item in discountTypes()" :key="item.label"><div class="flex items-center justify-between py-3"><dt class="text-gray-500 text-sm dark:text-neutral-400">{{ item.label }}</dt><dd class="font-mono text-gray-900 text-sm dark:text-white">{{ item.count }}</dd></div></template></dl></article>
|
|
33
33
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><h2 class="font-semibold text-gray-900 text-sm dark:text-white">Most redeemed</h2><p :if="mostUsed().length === 0" class="mt-4 text-gray-500 text-sm dark:text-neutral-400">No coupon usage recorded.</p><ol :else class="mt-3 divide-gray-100 divide-y dark:divide-neutral-700"><template :for="record in mostUsed()" :key="record.id"><li class="flex items-center justify-between py-2"><span class="font-mono text-gray-600 text-sm truncate dark:text-neutral-300">{{ record.code }}</span><span class="ml-3 font-mono text-gray-900 text-sm dark:text-white">{{ record.usageCount.toLocaleString() }}</span></li></template></ol></article>
|
|
@@ -193,7 +193,7 @@ onMount(() => {
|
|
|
193
193
|
|
|
194
194
|
<div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadAuthors()">Retry</Button></div>
|
|
195
195
|
|
|
196
|
-
<section aria-label="Author metrics" class="grid gap-3 sm:grid-cols-3">
|
|
196
|
+
<section aria-label="Author metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-3">
|
|
197
197
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ authors().length }}</p></article>
|
|
198
198
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Assigned posts</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ assignedPostCount() }}</p></article>
|
|
199
199
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Newest</p><p class="mt-2 font-semibold text-gray-900 text-lg truncate dark:text-white">{{ authors()[0]?.name || 'None' }}</p></article>
|
|
@@ -206,7 +206,7 @@ onMount(() => {
|
|
|
206
206
|
|
|
207
207
|
<div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadRecords()">Retry</Button></div>
|
|
208
208
|
|
|
209
|
-
<section :aria-label="title() + ' metrics'" class="grid gap-3 sm:grid-cols-3">
|
|
209
|
+
<section :aria-label="title() + ' metrics'" class="grid gap-3 grid-cols-1 sm:grid-cols-3">
|
|
210
210
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ records().length }}</p></article>
|
|
211
211
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">{{ secondaryMetricLabel() }}</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ secondaryMetric() }}</p></article>
|
|
212
212
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Newest</p><p class="mt-2 font-semibold text-gray-900 text-lg truncate dark:text-white">{{ records()[0]?.name || 'None' }}</p></article>
|
|
@@ -77,7 +77,11 @@ const searchQuery = state('')
|
|
|
77
77
|
const sortBy = state<'name' | 'lastModified' | 'size'>('name')
|
|
78
78
|
const sortOrder = state<'asc' | 'desc'>('asc')
|
|
79
79
|
const viewMode = state<'grid' | 'list'>('grid')
|
|
80
|
-
|
|
80
|
+
// A wide screen shows the storage panel beside the files; a narrow one has
|
|
81
|
+
// no room for it, so the same panel opens in a drawer instead.
|
|
82
|
+
const sidePanelOpen = state(true)
|
|
83
|
+
const storageDrawerOpen = state(false)
|
|
84
|
+
const isWideScreen = useMediaQuery('(min-width: 1024px)')
|
|
81
85
|
const loading = state(true)
|
|
82
86
|
const loadError = state('')
|
|
83
87
|
const truncated = state(false)
|
|
@@ -165,7 +169,7 @@ const typeRows = derived(() => [
|
|
|
165
169
|
{ key: 'videos', label: 'Videos', bytes: stats().byType.videos, color: 'bg-amber-500' },
|
|
166
170
|
{ key: 'audio', label: 'Audio', bytes: stats().byType.audio, color: 'bg-emerald-500' },
|
|
167
171
|
{ key: 'other', label: 'Other', bytes: stats().byType.other, color: 'bg-stone-400 dark:bg-neutral-500' },
|
|
168
|
-
])
|
|
172
|
+
].map(row => ({ key: row.key, label: row.label, color: row.color, size: formatFileSize(row.bytes), barStyle: typeBarStyle(row.bytes) })))
|
|
169
173
|
|
|
170
174
|
const volumePercent = derived(() => {
|
|
171
175
|
const total = stats().disk.totalBytes
|
|
@@ -173,9 +177,31 @@ const volumePercent = derived(() => {
|
|
|
173
177
|
return total && used !== null ? Math.min(100, Math.max(0, used / total * 100)) : 0
|
|
174
178
|
})
|
|
175
179
|
const capacityKnown = derived(() => stats().disk.totalBytes !== null && stats().disk.availableBytes !== null)
|
|
180
|
+
const storageSummary = derived(() => ({
|
|
181
|
+
files: stats().files,
|
|
182
|
+
folders: stats().folders,
|
|
183
|
+
contentSize: formatFileSize(stats().contentBytes),
|
|
184
|
+
capacityKnown: capacityKnown(),
|
|
185
|
+
capacityPercent: volumePercent(),
|
|
186
|
+
available: formatFileSize(stats().disk.availableBytes),
|
|
187
|
+
total: formatFileSize(stats().disk.totalBytes),
|
|
188
|
+
}))
|
|
189
|
+
const storageFolders = derived(() => topLevelFolders().map(folder => ({ path: folder.path, name: folder.name, count: (folder.items || []).length })))
|
|
176
190
|
const currentFolderLabel = derived(() => currentFolderPath() || 'Storage root')
|
|
177
191
|
const emptyHint = derived(() => searchQuery() ? 'No items match your search.' : 'This folder is empty.')
|
|
178
|
-
|
|
192
|
+
|
|
193
|
+
function toggleSidePanel(): void {
|
|
194
|
+
if (isWideScreen())
|
|
195
|
+
sidePanelOpen.set(!sidePanelOpen())
|
|
196
|
+
else
|
|
197
|
+
storageDrawerOpen.set(true)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
effect(() => {
|
|
201
|
+
if (isWideScreen() && storageDrawerOpen())
|
|
202
|
+
storageDrawerOpen.set(false)
|
|
203
|
+
})
|
|
204
|
+
|
|
179
205
|
const segmentBase = 'inline-flex items-center px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors'
|
|
180
206
|
const segmentActive = 'bg-white dark:bg-neutral-700 text-stone-800 dark:text-white shadow-sm'
|
|
181
207
|
const segmentInactive = 'text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200'
|
|
@@ -183,13 +209,6 @@ const gridViewClass = derived(() => `${segmentBase} ${viewMode() === 'grid' ? se
|
|
|
183
209
|
const listViewClass = derived(() => `${segmentBase} ${viewMode() === 'list' ? segmentActive : segmentInactive}`)
|
|
184
210
|
const sortOrderIconClass = derived(() => `${sortOrder() === 'asc' ? 'i-hugeicons-arrow-up-02' : 'i-hugeicons-arrow-down-02'} h-5 w-5`)
|
|
185
211
|
|
|
186
|
-
function navRowClass(active: boolean): string {
|
|
187
|
-
const base = 'group flex items-center gap-2.5 px-2.5 py-1.5 w-full text-left font-medium text-[13px] rounded-lg transition-colors'
|
|
188
|
-
return active
|
|
189
|
-
? `${base} bg-black/[0.06] dark:bg-white/[0.09] text-stone-900 dark:text-white`
|
|
190
|
-
: `${base} text-stone-600 dark:text-neutral-300 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]`
|
|
191
|
-
}
|
|
192
|
-
|
|
193
212
|
function gridCardClass(item: FileItem): string {
|
|
194
213
|
const selected = selectedItem()?.id === item.id
|
|
195
214
|
return `group relative overflow-hidden bg-white dark:bg-neutral-800 border rounded-lg shadow-sm hover:shadow-md transition-all cursor-pointer ${selected ? 'border-blue-400 ring-2 ring-blue-500/20' : 'border-gray-200 dark:border-neutral-700 hover:border-blue-300 dark:hover:border-blue-700'}`
|
|
@@ -275,6 +294,22 @@ function navigateToFolder(folder: FileItem): void {
|
|
|
275
294
|
selectedItem.set(null)
|
|
276
295
|
}
|
|
277
296
|
|
|
297
|
+
function openStorageFolder(path: string): void {
|
|
298
|
+
currentFolderPath.set(path)
|
|
299
|
+
selectedItem.set(null)
|
|
300
|
+
storageDrawerOpen.set(false)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function selectStorageDisk(name: string): void {
|
|
304
|
+
selectedDisk.set(name)
|
|
305
|
+
changeDisk()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function uploadFromStoragePanel(): void {
|
|
309
|
+
storageDrawerOpen.set(false)
|
|
310
|
+
openUpload()
|
|
311
|
+
}
|
|
312
|
+
|
|
278
313
|
function onItemClick(item: FileItem): void {
|
|
279
314
|
if (item.type === 'folder')
|
|
280
315
|
navigateToFolder(item)
|
|
@@ -409,56 +444,9 @@ onMount(() => { void loadFiles() })
|
|
|
409
444
|
</script>
|
|
410
445
|
|
|
411
446
|
<div class="flex min-h-[calc(100dvh-3rem)] bg-white dark:bg-neutral-900">
|
|
412
|
-
<aside :class="
|
|
413
|
-
<div class="
|
|
414
|
-
<
|
|
415
|
-
<label class="block">
|
|
416
|
-
<span class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Storage disk</span>
|
|
417
|
-
<select x-model="selectedDisk" :disabled="loading()" class="mt-2 px-2.5 py-2 w-full text-sm text-stone-800 dark:text-white bg-white/80 dark:bg-neutral-800 border border-stone-200 rounded-lg dark:border-neutral-700 disabled:opacity-60" @change="changeDisk()">
|
|
418
|
-
<template :for="disk in disks"><option :value="disk.name">{{ disk.name }}{{ disk.public ? ' (public)' : '' }}</option></template>
|
|
419
|
-
</select>
|
|
420
|
-
</label>
|
|
421
|
-
</div>
|
|
422
|
-
|
|
423
|
-
<div class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
424
|
-
<div class="flex items-center justify-between">
|
|
425
|
-
<p class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Stored content</p>
|
|
426
|
-
<span class="font-mono text-[11px] text-stone-500 dark:text-neutral-400">{{ formatFileSize(stats().contentBytes) }}</span>
|
|
427
|
-
</div>
|
|
428
|
-
<div class="grid gap-2 grid-cols-2 mt-3">
|
|
429
|
-
<div class="p-2.5 bg-white/65 dark:bg-white/[0.05] rounded-lg"><p class="font-semibold text-lg text-stone-900 dark:text-white">{{ stats().files }}</p><p class="text-[11px] text-stone-500 dark:text-neutral-400">Files</p></div>
|
|
430
|
-
<div class="p-2.5 bg-white/65 dark:bg-white/[0.05] rounded-lg"><p class="font-semibold text-lg text-stone-900 dark:text-white">{{ stats().folders }}</p><p class="text-[11px] text-stone-500 dark:text-neutral-400">Folders</p></div>
|
|
431
|
-
</div>
|
|
432
|
-
</div>
|
|
433
|
-
|
|
434
|
-
<div class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
435
|
-
<p class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Content by type</p>
|
|
436
|
-
<div class="mt-3 space-y-3">
|
|
437
|
-
<template :for="row in typeRows">
|
|
438
|
-
<div>
|
|
439
|
-
<div class="flex items-center justify-between text-[11px]"><span class="text-stone-600 dark:text-neutral-300">{{ row.label }}</span><span class="font-mono text-stone-400 dark:text-neutral-500">{{ formatFileSize(row.bytes) }}</span></div>
|
|
440
|
-
<div class="overflow-hidden mt-1 h-1.5 bg-black/[0.06] dark:bg-white/[0.08] rounded-full"><div :class="'h-full rounded-full ' + row.color" :style="typeBarStyle(row.bytes)"></div></div>
|
|
441
|
-
</div>
|
|
442
|
-
</template>
|
|
443
|
-
</div>
|
|
444
|
-
</div>
|
|
445
|
-
|
|
446
|
-
<div :if="capacityKnown()" class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
447
|
-
<div class="flex items-center justify-between"><p class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Volume capacity</p><span class="font-mono text-[11px] text-stone-500 dark:text-neutral-400">{{ volumePercent().toFixed(0) }}%</span></div>
|
|
448
|
-
<div class="overflow-hidden mt-2 h-1.5 bg-black/[0.06] dark:bg-white/[0.08] rounded-full"><div class="h-full bg-blue-500 rounded-full" :style="'width: ' + volumePercent().toFixed(2) + '%'"></div></div>
|
|
449
|
-
<p class="mt-2 text-[11px] text-stone-500 dark:text-neutral-400">{{ formatFileSize(stats().disk.availableBytes) }} available of {{ formatFileSize(stats().disk.totalBytes) }}</p>
|
|
450
|
-
</div>
|
|
451
|
-
|
|
452
|
-
<nav aria-label="Storage folders" class="overflow-y-auto flex-1 p-2">
|
|
453
|
-
<button type="button" :class="navRowClass(currentFolderPath() === '')" @click="currentFolderPath.set('')"><span class="h-[18px] w-[18px] text-blue-500 i-hugeicons-home-02"></span>Home</button>
|
|
454
|
-
<template :for="folder in topLevelFolders">
|
|
455
|
-
<button type="button" :class="navRowClass(currentFolderPath() === folder.path)" @click="navigateToFolder(folder)"><span class="h-[18px] w-[18px] text-amber-500 i-hugeicons-folder-02"></span><span class="truncate">{{ folder.name }}</span><span class="ml-auto tabular-nums text-[11px] text-stone-400 dark:text-neutral-500">{{ (folder.items || []).length }}</span></button>
|
|
456
|
-
</template>
|
|
457
|
-
</nav>
|
|
458
|
-
|
|
459
|
-
<div class="p-3 border-black/[0.06] border-t dark:border-white/[0.08]">
|
|
460
|
-
<Button :disabled="loading()" fullWidth @click="openUpload()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-upload-01"></span>Upload files</Button>
|
|
461
|
-
</div>
|
|
447
|
+
<aside class="hidden lg:block shrink-0 bg-[#F5F3EE]/70 dark:bg-neutral-900/40 border-black/[0.06] border-r dark:border-white/[0.08] backdrop-blur-2xl duration-300 transition-all" :class="sidePanelOpen() ? 'w-64' : 'w-0 overflow-hidden'">
|
|
448
|
+
<div class="h-full min-h-[calc(100dvh-3rem)] w-64">
|
|
449
|
+
<FileStoragePanel :disks="disks()" :disk="selectedDisk()" :busy="loading()" :summary="storageSummary()" :type-rows="typeRows()" :folders="storageFolders()" :current-path="currentFolderPath()" @disk="selectStorageDisk" @navigate="openStorageFolder" @upload="uploadFromStoragePanel()" />
|
|
462
450
|
</div>
|
|
463
451
|
</aside>
|
|
464
452
|
|
|
@@ -466,7 +454,7 @@ onMount(() => { void loadFiles() })
|
|
|
466
454
|
<header class="sticky top-0 z-10 p-4 bg-white/85 dark:bg-neutral-900/85 border-b border-black/[0.06] dark:border-white/[0.08] backdrop-blur-xl">
|
|
467
455
|
<div class="flex flex-wrap gap-3 items-center justify-between">
|
|
468
456
|
<div class="flex items-center min-w-0">
|
|
469
|
-
<Button variant="ghost" iconOnly ariaLabel="Toggle storage sidebar" @click="
|
|
457
|
+
<Button variant="ghost" iconOnly ariaLabel="Toggle storage sidebar" @click="toggleSidePanel()"><span class="block h-5 w-5 i-hugeicons-menu-01"></span></Button>
|
|
470
458
|
<div class="ml-3 min-w-0"><p class="font-semibold text-stone-900 truncate dark:text-white">{{ currentFolderLabel }}</p><p class="mt-0.5 text-stone-500 text-xs dark:text-neutral-400">{{ selectedDisk }} disk</p></div>
|
|
471
459
|
</div>
|
|
472
460
|
<div class="flex flex-wrap gap-2 items-center">
|
|
@@ -541,6 +529,10 @@ onMount(() => { void loadFiles() })
|
|
|
541
529
|
</div>
|
|
542
530
|
</div>
|
|
543
531
|
|
|
532
|
+
<Drawer :isOpen="storageDrawerOpen()" title="Storage" :description="selectedDisk() + ' disk'" size="sm" @close="storageDrawerOpen.set(false)">
|
|
533
|
+
<FileStoragePanel :disks="disks()" :disk="selectedDisk()" :busy="loading()" :summary="storageSummary()" :type-rows="typeRows()" :folders="storageFolders()" :current-path="currentFolderPath()" @disk="selectStorageDisk" @navigate="openStorageFolder" @upload="uploadFromStoragePanel()" />
|
|
534
|
+
</Drawer>
|
|
535
|
+
|
|
544
536
|
<FileDetailsDrawer :item="selectedItem()" :disk="selectedDisk()" @close="selectedItem.set(null)" />
|
|
545
537
|
|
|
546
538
|
<CreateFolderDialog
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
interface StorageDisk {
|
|
3
|
+
name: string
|
|
4
|
+
public: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface StorageSummary {
|
|
8
|
+
files: number
|
|
9
|
+
folders: number
|
|
10
|
+
contentSize: string
|
|
11
|
+
capacityKnown: boolean
|
|
12
|
+
capacityPercent: number
|
|
13
|
+
available: string
|
|
14
|
+
total: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface StorageTypeRow {
|
|
18
|
+
key: string
|
|
19
|
+
label: string
|
|
20
|
+
size: string
|
|
21
|
+
color: string
|
|
22
|
+
barStyle: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StorageFolder {
|
|
26
|
+
path: string
|
|
27
|
+
name: string
|
|
28
|
+
count: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const emptySummary: StorageSummary = { files: 0, folders: 0, contentSize: '0 Bytes', capacityKnown: false, capacityPercent: 0, available: '', total: '' }
|
|
32
|
+
|
|
33
|
+
const disks = useReactiveProp('disks', [] as StorageDisk[])
|
|
34
|
+
const disk = useReactiveProp('disk', '')
|
|
35
|
+
const busy = useReactiveProp('busy', false)
|
|
36
|
+
const summary = useReactiveProp('summary', emptySummary)
|
|
37
|
+
const typeRows = useReactiveProp('typeRows', [] as StorageTypeRow[])
|
|
38
|
+
const folders = useReactiveProp('folders', [] as StorageFolder[])
|
|
39
|
+
const currentPath = useReactiveProp('currentPath', '')
|
|
40
|
+
const emit = defineEmits(['disk', 'navigate', 'upload'])
|
|
41
|
+
|
|
42
|
+
function navRowClass(active: boolean): string {
|
|
43
|
+
const base = 'group flex items-center gap-2.5 px-2.5 py-1.5 w-full text-left font-medium text-[13px] rounded-lg transition-colors'
|
|
44
|
+
return active
|
|
45
|
+
? `${base} bg-black/[0.06] dark:bg-white/[0.09] text-stone-900 dark:text-white`
|
|
46
|
+
: `${base} text-stone-600 dark:text-neutral-300 hover:bg-black/[0.04] dark:hover:bg-white/[0.06]`
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<div class="flex flex-col h-full">
|
|
51
|
+
<div class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
52
|
+
<label class="block">
|
|
53
|
+
<span class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Storage disk</span>
|
|
54
|
+
<select x-model="disk" :disabled="busy()" class="mt-2 px-2.5 py-2 w-full text-sm text-stone-800 dark:text-white bg-white/80 dark:bg-neutral-800 border border-stone-200 rounded-lg dark:border-neutral-700 disabled:opacity-60" @change="emit('disk', $event.target.value)">
|
|
55
|
+
<template :for="option in disks"><option :value="option.name">{{ option.name }}{{ option.public ? ' (public)' : '' }}</option></template>
|
|
56
|
+
</select>
|
|
57
|
+
</label>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
61
|
+
<div class="flex items-center justify-between">
|
|
62
|
+
<p class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Stored content</p>
|
|
63
|
+
<span class="font-mono text-[11px] text-stone-500 dark:text-neutral-400">{{ summary().contentSize }}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="grid gap-2 grid-cols-2 mt-3">
|
|
66
|
+
<div class="p-2.5 bg-white/65 dark:bg-white/[0.05] rounded-lg"><p class="font-semibold text-lg text-stone-900 dark:text-white">{{ summary().files }}</p><p class="text-[11px] text-stone-500 dark:text-neutral-400">Files</p></div>
|
|
67
|
+
<div class="p-2.5 bg-white/65 dark:bg-white/[0.05] rounded-lg"><p class="font-semibold text-lg text-stone-900 dark:text-white">{{ summary().folders }}</p><p class="text-[11px] text-stone-500 dark:text-neutral-400">Folders</p></div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
72
|
+
<p class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Content by type</p>
|
|
73
|
+
<div class="mt-3 space-y-3">
|
|
74
|
+
<template :for="row in typeRows">
|
|
75
|
+
<div>
|
|
76
|
+
<div class="flex items-center justify-between text-[11px]"><span class="text-stone-600 dark:text-neutral-300">{{ row.label }}</span><span class="font-mono text-stone-400 dark:text-neutral-500">{{ row.size }}</span></div>
|
|
77
|
+
<div class="overflow-hidden mt-1 h-1.5 bg-black/[0.06] dark:bg-white/[0.08] rounded-full"><div :class="'h-full rounded-full ' + row.color" :style="row.barStyle"></div></div>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div :if="summary().capacityKnown" class="p-4 border-b border-black/[0.06] dark:border-white/[0.08]">
|
|
84
|
+
<div class="flex items-center justify-between"><p class="font-semibold text-[11px] text-stone-400 tracking-wider uppercase dark:text-neutral-500">Volume capacity</p><span class="font-mono text-[11px] text-stone-500 dark:text-neutral-400">{{ summary().capacityPercent.toFixed(0) }}%</span></div>
|
|
85
|
+
<div class="overflow-hidden mt-2 h-1.5 bg-black/[0.06] dark:bg-white/[0.08] rounded-full"><div class="h-full bg-blue-500 rounded-full" :style="'width: ' + summary().capacityPercent.toFixed(2) + '%'"></div></div>
|
|
86
|
+
<p class="mt-2 text-[11px] text-stone-500 dark:text-neutral-400">{{ summary().available }} available of {{ summary().total }}</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<nav aria-label="Storage folders" class="overflow-y-auto flex-1 p-2">
|
|
90
|
+
<button type="button" :class="navRowClass(currentPath() === '')" @click="emit('navigate', '')"><span class="h-[18px] w-[18px] text-blue-500 i-hugeicons-home-02"></span>Home</button>
|
|
91
|
+
<template :for="folder in folders">
|
|
92
|
+
<button type="button" :class="navRowClass(currentPath() === folder.path)" @click="emit('navigate', folder.path)"><span class="h-[18px] w-[18px] text-amber-500 i-hugeicons-folder-02"></span><span class="truncate">{{ folder.name }}</span><span class="ml-auto tabular-nums text-[11px] text-stone-400 dark:text-neutral-500">{{ folder.count }}</span></button>
|
|
93
|
+
</template>
|
|
94
|
+
</nav>
|
|
95
|
+
|
|
96
|
+
<div class="p-3 border-black/[0.06] border-t dark:border-white/[0.08]">
|
|
97
|
+
<Button :disabled="busy()" fullWidth @click="emit('upload')"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-upload-01"></span>Upload files</Button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
@@ -207,7 +207,7 @@ onMount(() => {
|
|
|
207
207
|
|
|
208
208
|
<div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadPages()">Retry</Button></div>
|
|
209
209
|
|
|
210
|
-
<section aria-label="Page metrics" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
210
|
+
<section aria-label="Page metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
|
211
211
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ pages().length }}</p></article>
|
|
212
212
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Views</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(totalViews()) }}</p></article>
|
|
213
213
|
<article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Conversions</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(totalConversions()) }}</p></article>
|
|
@@ -60,7 +60,7 @@ function formatBytes(value: number): string {
|
|
|
60
60
|
</div>
|
|
61
61
|
<div class="flex-shrink-0 text-right">
|
|
62
62
|
<time class="block text-gray-500 text-xs dark:text-neutral-400" :datetime="selected()?.sentAt || ''">{{ formatDate(selected()?.sentAt || '') }}</time>
|
|
63
|
-
<span class="block mt-1 text-gray-
|
|
63
|
+
<span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ formatBytes(selected()?.size || 0) }}</span>
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
66
66
|
</header>
|
|
@@ -214,7 +214,7 @@ onMount(() => {
|
|
|
214
214
|
<section :if="links().length > 0">
|
|
215
215
|
<h2 class="font-semibold text-neutral-900 dark:text-white">Manifest relationships</h2>
|
|
216
216
|
<p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Accessible relationship records derived from the same manifests.</p>
|
|
217
|
-
<ul class="grid gap-3 md:grid-cols-2 mt-4">
|
|
217
|
+
<ul class="grid gap-3 grid-cols-1 md:grid-cols-2 mt-4">
|
|
218
218
|
<li :for="link in links()" class="flex gap-3 items-center p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-xl dark:border-neutral-700">
|
|
219
219
|
<span class="font-semibold text-neutral-900 text-sm truncate dark:text-white">{{ relationshipName(link.source) }}</span>
|
|
220
220
|
<span class="shrink-0 px-2 py-1 text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full">{{ link.type }}</span>
|
|
@@ -26,7 +26,10 @@ const sizeClasses: Record<TabsSize, string> = {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const containerClasses: Record<TabsVariant, string> = {
|
|
29
|
-
|
|
29
|
+
// An inset shadow, not a border: the list scrolls sideways on a narrow
|
|
30
|
+
// screen, and a scroller clips anything (like a tab pulled over a border)
|
|
31
|
+
// that hangs outside it. The selected tab's underline still covers it.
|
|
32
|
+
underline: 'shadow-[inset_0_-1px_0_#e5e5e5] dark:shadow-[inset_0_-1px_0_#404040]',
|
|
30
33
|
pills: 'gap-2 bg-neutral-100 dark:bg-neutral-800 p-1 rounded-lg',
|
|
31
34
|
boxed: 'border border-neutral-200 dark:border-neutral-700 rounded-lg p-1',
|
|
32
35
|
}
|
|
@@ -48,7 +51,7 @@ function selectTab(tab: DashboardTab): void {
|
|
|
48
51
|
function tabClass(tab: DashboardTab): string {
|
|
49
52
|
const selected = activeTab() === tab.id
|
|
50
53
|
const common = [
|
|
51
|
-
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-150',
|
|
54
|
+
'inline-flex shrink-0 items-center justify-center gap-2 font-medium whitespace-nowrap transition-all duration-150 focus-visible:outline-offset-[-2px]',
|
|
52
55
|
sizeClasses[size()],
|
|
53
56
|
fullWidth() ? 'flex-1' : '',
|
|
54
57
|
tab.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
@@ -67,7 +70,7 @@ function tabClass(tab: DashboardTab): string {
|
|
|
67
70
|
: 'text-neutral-500 hover:text-neutral-700 hover:bg-neutral-50 dark:text-neutral-400 dark:hover:text-neutral-200 dark:hover:bg-neutral-800/50')
|
|
68
71
|
}
|
|
69
72
|
else {
|
|
70
|
-
common.push('
|
|
73
|
+
common.push('border-b-2')
|
|
71
74
|
common.push(selected
|
|
72
75
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
73
76
|
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300 dark:text-neutral-400 dark:hover:text-neutral-200')
|
|
@@ -83,7 +86,7 @@ function badgeClass(tab: DashboardTab): string {
|
|
|
83
86
|
}
|
|
84
87
|
</script>
|
|
85
88
|
|
|
86
|
-
<div role="tablist" :class="'flex ' + (fullWidth() ? 'w-full ' : '') + containerClasses[variant()]">
|
|
89
|
+
<div role="tablist" :class="'flex overflow-x-auto ' + (fullWidth() ? 'w-full ' : '') + containerClasses[variant()]">
|
|
87
90
|
<template :for="tab in tabs()">
|
|
88
91
|
<button
|
|
89
92
|
type="button"
|