@stacksjs/defaults 0.70.362 → 0.70.365

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.
Files changed (137) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +2 -1
  2. package/ai/skills/stacks-browse/scripts/browse.ts +20 -2
  3. package/ai/skills/stacks-dashboard/SKILL.md +4 -0
  4. package/app/Actions/Auth/LogoutAction.ts +11 -4
  5. package/app/Actions/Auth/LogoutAllAction.ts +8 -4
  6. package/app/Actions/Dashboard/Models/ModelShowAction.ts +4 -1
  7. package/app/Actions/Dashboard/Models/model-write.ts +5 -0
  8. package/ide/vscode/package.json +1 -1
  9. package/package.json +1 -1
  10. package/resources/components/Buttons/Counter.stx +1 -1
  11. package/resources/components/Buttons/ToggleDark.stx +1 -1
  12. package/resources/components/Dashboard/App/RequestDetailsDialog.stx +42 -0
  13. package/resources/components/Dashboard/App/RequestsOverview.stx +1 -21
  14. package/resources/components/Dashboard/Auth/AccessTokenCreateDialog.stx +155 -0
  15. package/resources/components/Dashboard/Auth/AccessTokens.stx +41 -129
  16. package/resources/components/Dashboard/Ci/CiDashboard.stx +1 -157
  17. package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +127 -0
  18. package/resources/components/Dashboard/Commerce/CommerceCategoryDeleteDialog.stx +10 -8
  19. package/resources/components/Dashboard/Commerce/CommerceCategoryDetailsDialog.stx +22 -17
  20. package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +14 -13
  21. package/resources/components/Dashboard/Commerce/CommerceCouponDeleteDialog.stx +10 -7
  22. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +25 -19
  23. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +14 -11
  24. package/resources/components/Dashboard/Commerce/CommerceCustomerDeleteDialog.stx +10 -8
  25. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +24 -16
  26. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +14 -13
  27. package/resources/components/Dashboard/Commerce/CommerceGiftCardDeleteDialog.stx +10 -8
  28. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +16 -14
  29. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +17 -16
  30. package/resources/components/Dashboard/Commerce/CommerceManufacturerDeleteDialog.stx +14 -16
  31. package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +17 -16
  32. package/resources/components/Dashboard/Commerce/CommerceOrderDeleteDialog.stx +10 -8
  33. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +15 -10
  34. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +14 -13
  35. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +30 -25
  36. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +27 -20
  37. package/resources/components/Dashboard/Commerce/CommerceProductDeleteDialog.stx +14 -14
  38. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +14 -13
  39. package/resources/components/Dashboard/Commerce/CommerceReviewDeleteDialog.stx +10 -18
  40. package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +17 -16
  41. package/resources/components/Dashboard/Commerce/CommerceTaxDeleteDialog.stx +14 -14
  42. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +17 -16
  43. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +1 -1
  44. package/resources/components/Dashboard/Commerce/CommerceUnitDeleteDialog.stx +14 -17
  45. package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +17 -16
  46. package/resources/components/Dashboard/Commerce/CommerceVariantDeleteDialog.stx +10 -8
  47. package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +17 -16
  48. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRouteDeleteDialog.stx +11 -15
  49. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRouteDialog.stx +17 -16
  50. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryDeleteDialog.stx +11 -15
  51. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryDialog.stx +17 -16
  52. package/resources/components/Dashboard/Commerce/Delivery/DriverDeleteDialog.stx +11 -15
  53. package/resources/components/Dashboard/Commerce/Delivery/DriverDialog.stx +17 -16
  54. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeyDeleteDialog.stx +11 -15
  55. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeyDialog.stx +17 -16
  56. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDeleteDialog.stx +11 -15
  57. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +17 -16
  58. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDeleteDialog.stx +11 -15
  59. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +17 -16
  60. package/resources/components/Dashboard/Commerce/Delivery/ShippingZoneDeleteDialog.stx +11 -15
  61. package/resources/components/Dashboard/Commerce/Delivery/ShippingZoneDialog.stx +17 -16
  62. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +16 -15
  63. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +16 -15
  64. package/resources/components/Dashboard/Commerce/PrintDeviceDeleteDialog.stx +10 -11
  65. package/resources/components/Dashboard/Commerce/PrintDeviceDetailsDialog.stx +16 -14
  66. package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +17 -16
  67. package/resources/components/Dashboard/Commerce/PrintLogDeleteDialog.stx +10 -7
  68. package/resources/components/Dashboard/Commerce/PrintLogDetailsDialog.stx +13 -10
  69. package/resources/components/Dashboard/Commerce/ProductWaitlistDeleteDialog.stx +10 -18
  70. package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +96 -105
  71. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDeleteDialog.stx +10 -18
  72. package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +92 -95
  73. package/resources/components/Dashboard/Content/AuthorEditorDialog.stx +94 -0
  74. package/resources/components/Dashboard/Content/AuthorsDashboard.stx +39 -31
  75. package/resources/components/Dashboard/Content/BlogDashboard.stx +87 -176
  76. package/resources/components/Dashboard/Content/BlogPostEditorDialog.stx +195 -0
  77. package/resources/components/Dashboard/Content/CommentsDashboard.stx +14 -4
  78. package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +40 -55
  79. package/resources/components/Dashboard/Content/CreateFolderDialog.stx +45 -0
  80. package/resources/components/Dashboard/Content/FileDetailsDrawer.stx +31 -0
  81. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +47 -107
  82. package/resources/components/Dashboard/Content/FileUploadDialog.stx +89 -0
  83. package/resources/components/Dashboard/Content/PageEditorDialog.stx +93 -0
  84. package/resources/components/Dashboard/Content/PagesDashboard.stx +36 -26
  85. package/resources/components/Dashboard/Content/PostEditorDialog.stx +176 -0
  86. package/resources/components/Dashboard/Content/PostsDashboard.stx +40 -64
  87. package/resources/components/Dashboard/Content/SeoDashboard.stx +18 -84
  88. package/resources/components/Dashboard/Content/SeoEditorDialog.stx +108 -0
  89. package/resources/components/Dashboard/Content/TaxonomyEditorDialog.stx +106 -0
  90. package/resources/components/Dashboard/CreateRecordModal.stx +72 -83
  91. package/resources/components/Dashboard/Email/InboxMessageList.stx +1 -1
  92. package/resources/components/Dashboard/GlobalSearch.stx +3 -9
  93. package/resources/components/Dashboard/Infrastructure/CloudDetailsDialog.stx +2 -16
  94. package/resources/components/Dashboard/Infrastructure/DnsRecordDialog.stx +4 -16
  95. package/resources/components/Dashboard/Infrastructure/LogDetailsDialog.stx +4 -19
  96. package/resources/components/Dashboard/Infrastructure/MailboxDetailsDialog.stx +6 -16
  97. package/resources/components/Dashboard/Infrastructure/ServerInspectorDialog.stx +105 -0
  98. package/resources/components/Dashboard/Infrastructure/ServersDashboard.stx +5 -59
  99. package/resources/components/Dashboard/Jobs/JobHistory.stx +1 -11
  100. package/resources/components/Dashboard/Library/ComponentsDashboard.stx +11 -60
  101. package/resources/components/Dashboard/Library/FunctionsDashboard.stx +11 -15
  102. package/resources/components/Dashboard/Library/SourceScaffoldDialog.stx +95 -0
  103. package/resources/components/Dashboard/Management/AssignmentDrawer.stx +93 -0
  104. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +47 -129
  105. package/resources/components/Dashboard/Marketing/CampaignDeliveryDialog.stx +5 -8
  106. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +17 -17
  107. package/resources/components/Dashboard/Marketing/MarketingListDialog.stx +17 -17
  108. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +1 -1
  109. package/resources/components/Dashboard/Marketing/SocialPostDialog.stx +17 -16
  110. package/resources/components/Dashboard/Models/EditRecordModal.stx +81 -0
  111. package/resources/components/Dashboard/Models/ModelRecordActions.stx +30 -0
  112. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +42 -94
  113. package/resources/components/Dashboard/Navbar.stx +5 -5
  114. package/resources/components/Dashboard/Notifications/NotificationDeliveryDialog.stx +40 -60
  115. package/resources/components/Dashboard/UI/Button.stx +4 -0
  116. package/resources/components/Dashboard/UI/ConfirmDialog.stx +4 -0
  117. package/resources/components/Dashboard/UI/Drawer.stx +114 -0
  118. package/resources/components/Dashboard/UI/Modal.stx +19 -12
  119. package/resources/components/Docs/Demo/ComboboxDemo.stx +4 -5
  120. package/resources/components/Docs/Demo/DropdownDemo.stx +12 -5
  121. package/resources/components/Docs/Demo/ListboxDemo.stx +14 -6
  122. package/resources/components/Docs/Demo/PopoverDemo.stx +10 -5
  123. package/resources/components/Docs/Demo/RadioGroupDemo.stx +14 -6
  124. package/resources/components/Docs/Demo/StepperDemo.stx +4 -5
  125. package/resources/components/Docs/TeamMember.stx +16 -9
  126. package/resources/components/Forum/ForumReplyForm.stx +7 -7
  127. package/resources/components/Forum/ForumSidebar.stx +10 -6
  128. package/resources/components/Marketing/Faqs.stx +5 -5
  129. package/resources/components/Marketing/Feature.stx +11 -6
  130. package/resources/components/Marketing/Layout.stx +12 -8
  131. package/resources/components/Marketing/Pricing.stx +11 -5
  132. package/resources/components/Marketing/PrimaryFeatures.stx +4 -5
  133. package/resources/components/Marketing/PrimaryFeatures2.stx +6 -5
  134. package/resources/components/Marketing/SecondaryFeatures.stx +10 -5
  135. package/resources/components/Marketing/Testimonials.stx +8 -5
  136. package/resources/views/index.stx +4 -4
  137. package/views/system-tray/index.stx +1 -1
@@ -116,6 +116,10 @@ function closeDelete(): void {
116
116
  deletingComment.set(null)
117
117
  }
118
118
 
119
+ function deleteDescription(): string {
120
+ return `The comment from ${deletingComment()?.authorName || 'Anonymous'} cannot be restored.`
121
+ }
122
+
119
123
  async function deleteComment(): Promise<void> {
120
124
  const comment = deletingComment()
121
125
  if (!comment)
@@ -166,7 +170,13 @@ onMount(() => {
166
170
  </section>
167
171
  </div>
168
172
 
169
- <div :if="Boolean(deletingComment())" class="flex fixed inset-y-0 right-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
170
- <button type="button" aria-label="Close delete comment dialog" class="absolute inset-0 bg-black/45" @click="closeDelete()"></button>
171
- <section role="dialog" aria-modal="true" aria-label="Permanently delete comment?" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl"><h2 class="font-semibold text-gray-900 text-lg dark:text-white">Permanently delete comment?</h2><p class="mt-2 text-gray-500 text-sm dark:text-neutral-400">The comment from <strong>{{ deletingComment()?.authorName || 'Anonymous' }}</strong> cannot be restored.</p><div :if="deleteError()" role="alert" class="mt-4 p-3 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">{{ deleteError() }}</div><div class="flex gap-2 justify-end mt-6"><Button :disabled="deleting()" variant="secondary" @click="closeDelete()">Cancel</Button><Button :loading="deleting()" variant="danger" @click="deleteComment()">Delete permanently</Button></div></section>
172
- </div>
173
+ <ConfirmDialog
174
+ :isOpen="Boolean(deletingComment())"
175
+ title="Permanently delete comment?"
176
+ :description="deleteDescription()"
177
+ confirmLabel="Delete permanently"
178
+ :busy="deleting()"
179
+ :error="deleteError()"
180
+ @close="closeDelete"
181
+ @confirm="deleteComment"
182
+ />
@@ -11,6 +11,12 @@ interface TaxonomyRecord {
11
11
  postCount: number
12
12
  }
13
13
 
14
+ interface TaxonomyDraft {
15
+ name: string
16
+ slug: string
17
+ description: string
18
+ }
19
+
14
20
  const resource = useReactiveProp('resource', 'categories')
15
21
  const title = useReactiveProp('title', 'Categories')
16
22
  const description = useReactiveProp('description', '')
@@ -19,10 +25,6 @@ const loading = state(true)
19
25
  const loadError = state('')
20
26
  const search = state('')
21
27
  const dialogOpen = state(false)
22
- const name = state('')
23
- const slug = state('')
24
- const taxonomyDescription = state('')
25
- const slugTouched = state(false)
26
28
  const saving = state(false)
27
29
  const saveError = state('')
28
30
  const deletingRecord = state<TaxonomyRecord | null>(null)
@@ -63,33 +65,6 @@ function secondaryMetric(): number {
63
65
  : records().filter(record => record.active).length
64
66
  }
65
67
 
66
- function slugify(value: string): string {
67
- return value
68
- .normalize('NFKD')
69
- .replace(/[\u0300-\u036F]/g, '')
70
- .trim()
71
- .toLowerCase()
72
- .replace(/['"]/g, '')
73
- .replace(/[^a-z0-9]+/g, '-')
74
- .replace(/^-+|-+$/g, '')
75
- }
76
-
77
- effect(() => {
78
- if (!dialogOpen() || slugTouched())
79
- return
80
- slug.set(slugify(name()))
81
- })
82
-
83
- function markSlugTouched(): void {
84
- slugTouched.set(true)
85
- }
86
-
87
- function normalizeSlug(): void {
88
- const normalized = slugify(slug())
89
- if (normalized !== slug())
90
- slug.set(normalized)
91
- }
92
-
93
68
  async function loadRecords(): Promise<void> {
94
69
  loading.set(true)
95
70
  loadError.set('')
@@ -110,10 +85,6 @@ async function loadRecords(): Promise<void> {
110
85
  }
111
86
 
112
87
  function openCreate(): void {
113
- name.set('')
114
- slug.set('')
115
- taxonomyDescription.set('')
116
- slugTouched.set(false)
117
88
  saveError.set('')
118
89
  dialogOpen.set(true)
119
90
  }
@@ -123,17 +94,13 @@ function closeCreate(): void {
123
94
  dialogOpen.set(false)
124
95
  }
125
96
 
126
- async function createRecord(): Promise<void> {
97
+ async function createRecord(draft: TaxonomyDraft): Promise<void> {
127
98
  saving.set(true)
128
99
  saveError.set('')
129
100
  try {
130
101
  await dashboardApi(`/api/dashboard/${resource()}`, {
131
102
  method: 'POST',
132
- body: {
133
- name: name().trim(),
134
- slug: slugify(slug()),
135
- description: taxonomyDescription().trim(),
136
- },
103
+ body: draft,
137
104
  })
138
105
  dialogOpen.set(false)
139
106
  pushToast('success', `${singular().replace(/^\w/, letter => letter.toUpperCase())} created`)
@@ -157,6 +124,19 @@ function closeDelete(): void {
157
124
  deletingRecord.set(null)
158
125
  }
159
126
 
127
+ function deleteTitle(): string {
128
+ return `Delete ${singular()}?`
129
+ }
130
+
131
+ function deleteDescription(): string {
132
+ const record = deletingRecord()
133
+ return record ? `${record.name} will be permanently removed.` : ''
134
+ }
135
+
136
+ function deleteLabel(): string {
137
+ return `Delete ${singular()}`
138
+ }
139
+
160
140
  async function deleteRecord(): Promise<void> {
161
141
  const record = deletingRecord()
162
142
  if (!record)
@@ -218,17 +198,22 @@ onMount(() => {
218
198
  </section>
219
199
  </div>
220
200
 
221
- <div :if="dialogOpen()" class="flex fixed inset-y-0 right-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
222
- <button type="button" aria-label="Close taxonomy dialog" class="absolute inset-0 bg-black/45" @click="closeCreate()"></button>
223
- <section role="dialog" aria-modal="true" :aria-label="'New ' + singular()" class="relative max-w-lg w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
224
- <form @submit.prevent="createRecord()">
225
- <div class="p-6"><h2 class="font-semibold text-gray-900 text-lg dark:text-white">New {{ singular() }}</h2><div :if="saveError()" role="alert" class="mt-4 p-3 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">{{ saveError() }}</div><div class="grid gap-4 mt-5"><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Name</span><input x-model="name" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Slug</span><input x-model="slug" required class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" @input="markSlugTouched" @blur="normalizeSlug" /></label><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Description</span><textarea x-model="taxonomyDescription" rows="3" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"></textarea></label></div></div>
226
- <footer class="flex gap-2 justify-end px-6 py-4 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700"><Button :disabled="saving()" variant="secondary" @click="closeCreate()">Cancel</Button><Button type="submit" :disabled="saving() || !name().trim() || !slug().trim()" :loading="saving()">Create {{ singular() }}</Button></footer>
227
- </form>
228
- </section>
229
- </div>
230
-
231
- <div :if="Boolean(deletingRecord())" class="flex fixed inset-y-0 right-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
232
- <button type="button" aria-label="Close delete taxonomy dialog" class="absolute inset-0 bg-black/45" @click="closeDelete()"></button>
233
- <section role="dialog" aria-modal="true" :aria-label="'Delete ' + singular() + '?'" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl"><h2 class="font-semibold text-gray-900 text-lg dark:text-white">Delete {{ singular() }}?</h2><p class="mt-2 text-gray-500 text-sm dark:text-neutral-400"><strong>{{ deletingRecord()?.name || '' }}</strong> will be permanently removed.</p><div :if="deleteError()" role="alert" class="mt-4 p-3 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">{{ deleteError() }}</div><div class="flex gap-2 justify-end mt-6"><Button :disabled="deleting()" variant="secondary" @click="closeDelete()">Cancel</Button><Button :loading="deleting()" variant="danger" @click="deleteRecord()">Delete {{ singular() }}</Button></div></section>
234
- </div>
201
+ <TaxonomyEditorDialog
202
+ :open="dialogOpen()"
203
+ :kind="singular()"
204
+ :busy="saving()"
205
+ :error="saveError()"
206
+ @close="closeCreate"
207
+ @submit="createRecord"
208
+ />
209
+
210
+ <ConfirmDialog
211
+ :isOpen="Boolean(deletingRecord())"
212
+ :title="deleteTitle()"
213
+ :description="deleteDescription()"
214
+ :confirmLabel="deleteLabel()"
215
+ :busy="deleting()"
216
+ :error="deleteError()"
217
+ @close="closeDelete"
218
+ @confirm="deleteRecord"
219
+ />
@@ -0,0 +1,45 @@
1
+ <script client>
2
+ const open = useReactiveProp('open', false)
3
+ const location = useReactiveProp('location', 'Storage root')
4
+ const busy = useReactiveProp('busy', false)
5
+ const error = useReactiveProp('error', '')
6
+ const folderName = state('')
7
+ const emit = defineEmits()
8
+ let wasOpen = false
9
+
10
+ effect(() => {
11
+ const isOpen = open()
12
+ if (isOpen && !wasOpen)
13
+ folderName.set('')
14
+ wasOpen = isOpen
15
+ })
16
+
17
+ function close(): void {
18
+ if (!busy())
19
+ emit('close')
20
+ }
21
+
22
+ function submit(): void {
23
+ const name = folderName().trim()
24
+ if (name)
25
+ emit('submit', name)
26
+ }
27
+ </script>
28
+
29
+ <Modal :isOpen="open()" title="Create folder" :description="'Add a folder inside ' + location() + '.'" size="sm" :closable="!busy()" @close="close">
30
+ <form id="create-folder-form" @submit.prevent="submit">
31
+ <label class="block">
32
+ <span class="font-medium text-stone-700 text-sm dark:text-neutral-200">Folder name</span>
33
+ <input x-model.trim="folderName" name="name" required maxlength="255" autocomplete="off" class="mt-1 px-3 py-2 w-full text-stone-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-stone-300 rounded-lg dark:border-neutral-700" />
34
+ </label>
35
+ </form>
36
+
37
+ <div :if="error()" role="alert" class="mt-4 p-3 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">{{ error() }}</div>
38
+
39
+ <template #footer>
40
+ <div class="flex gap-2 justify-end w-full">
41
+ <Button :disabled="busy()" variant="secondary" @click="close">Cancel</Button>
42
+ <Button type="submit" form="create-folder-form" :disabled="!folderName().trim()" :loading="busy()">Create folder</Button>
43
+ </div>
44
+ </template>
45
+ </Modal>
@@ -0,0 +1,31 @@
1
+ <script client>
2
+ interface FileItem {
3
+ id: string
4
+ name: string
5
+ type: string
6
+ size: number
7
+ path: string
8
+ lastModified: string | null
9
+ mime_type?: string
10
+ url?: string
11
+ thumbnail?: string
12
+ starred: false
13
+ shared: boolean
14
+ }
15
+
16
+ const item = useReactiveProp('item', null as FileItem | null)
17
+ const disk = useReactiveProp('disk', '')
18
+ const title = derived(() => item()?.name || 'File details')
19
+ const description = derived(() => `File details · ${disk()} disk`)
20
+ const emit = defineEmits()
21
+ </script>
22
+
23
+ <Drawer :isOpen="Boolean(item())" :title="title()" :description="description()" size="sm" @close="emit('close')">
24
+ <div class="p-5">
25
+ <MediaInfoPanel :item="item()" />
26
+ <Button :if="item()?.url" tag="a" :href="item()?.url" target="_blank" rel="noopener" fullWidth class="mt-5">
27
+ <span class="h-4 w-4 i-hugeicons-link-square-02"></span>
28
+ Open file
29
+ </Button>
30
+ </div>
31
+ </Drawer>
@@ -82,20 +82,15 @@ const scanLimit = state(1000)
82
82
 
83
83
  const selectedItem = state<FileItem | null>(null)
84
84
  const showCreateFolderModal = state(false)
85
- const newFolderName = state('')
86
85
  const creatingFolder = state(false)
87
86
  const createError = state('')
88
87
  const showUploadModal = state(false)
89
- const selectedFiles = state<File[]>([])
90
88
  const uploading = state(false)
91
89
  const uploadError = state('')
92
90
  const itemToDelete = state<FileItem | null>(null)
93
91
  const deleting = state(false)
94
92
  const deleteError = state('')
95
93
 
96
- const folderNameInput = useRef<HTMLInputElement>('fileManagerFolderName')
97
- const uploadInput = useRef<HTMLInputElement>('fileManagerUploadInput')
98
-
99
94
  const fileTypeIcons: Record<string, string> = {
100
95
  pdf: 'i-hugeicons-pdf-01',
101
96
  doc: 'i-hugeicons-doc-01',
@@ -176,14 +171,6 @@ const volumePercent = derived(() => {
176
171
  const capacityKnown = derived(() => stats().disk.totalBytes !== null && stats().disk.availableBytes !== null)
177
172
  const currentFolderLabel = derived(() => currentFolderPath() || 'Storage root')
178
173
  const emptyHint = derived(() => searchQuery() ? 'No items match your search.' : 'This folder is empty.')
179
- const selectedFilesLabel = derived(() => {
180
- if (!selectedFiles().length)
181
- return 'No files selected'
182
- if (selectedFiles().length === 1)
183
- return selectedFiles()[0]?.name || '1 file selected'
184
- return `${selectedFiles().length} files selected`
185
- })
186
-
187
174
  const sidePanelClass = derived(() => `border-r border-black/[0.06] dark:border-white/[0.08] bg-[#F5F3EE]/70 dark:bg-neutral-900/40 backdrop-blur-2xl transition-all duration-300 ${showSidePanel() ? 'w-64' : 'w-0 overflow-hidden'}`)
188
175
  const segmentBase = 'inline-flex items-center px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors'
189
176
  const segmentActive = 'bg-white dark:bg-neutral-700 text-stone-800 dark:text-white shadow-sm'
@@ -295,10 +282,8 @@ function toggleSortOrder(): void {
295
282
  }
296
283
 
297
284
  function openCreateFolder(): void {
298
- newFolderName.set('')
299
285
  createError.set('')
300
286
  showCreateFolderModal.set(true)
301
- void nextTick(() => folderNameInput.current?.focus())
302
287
  }
303
288
 
304
289
  function closeCreateFolder(): void {
@@ -306,10 +291,7 @@ function closeCreateFolder(): void {
306
291
  showCreateFolderModal.set(false)
307
292
  }
308
293
 
309
- async function createFolder(): Promise<void> {
310
- const name = newFolderName().trim()
311
- if (!name)
312
- return
294
+ async function createFolder(name: string): Promise<void> {
313
295
  creatingFolder.set(true)
314
296
  createError.set('')
315
297
  try {
@@ -329,47 +311,18 @@ async function createFolder(): Promise<void> {
329
311
  }
330
312
  }
331
313
 
332
- function resetUploadSelection(): void {
333
- selectedFiles.set([])
334
- if (uploadInput.current)
335
- uploadInput.current.value = ''
336
- }
337
-
338
314
  function openUpload(): void {
339
315
  uploadError.set('')
340
- resetUploadSelection()
341
316
  showUploadModal.set(true)
342
317
  }
343
318
 
344
319
  function closeUpload(): void {
345
- if (!uploading()) {
320
+ if (!uploading())
346
321
  showUploadModal.set(false)
347
- resetUploadSelection()
348
- }
349
- }
350
-
351
- function acceptFiles(files: FileList | File[]): void {
352
- selectedFiles.set(Array.from(files))
353
- uploadError.set('')
354
322
  }
355
323
 
356
- function handleFileSelection(event: Event): void {
357
- const input = event.target as HTMLInputElement
358
- if (input.files)
359
- acceptFiles(input.files)
360
- }
361
-
362
- function handleDrop(event: DragEvent): void {
363
- if (event.dataTransfer?.files)
364
- acceptFiles(event.dataTransfer.files)
365
- }
366
-
367
- function handleDragOver(): void {
368
- uploadError.set('')
369
- }
370
-
371
- async function uploadFiles(): Promise<void> {
372
- if (!selectedFiles().length)
324
+ async function uploadFiles(files: File[]): Promise<void> {
325
+ if (!files.length)
373
326
  return
374
327
  uploading.set(true)
375
328
  uploadError.set('')
@@ -377,12 +330,11 @@ async function uploadFiles(): Promise<void> {
377
330
  const formData = new FormData()
378
331
  formData.append('disk', selectedDisk())
379
332
  formData.append('path', currentFolderPath())
380
- for (const file of selectedFiles())
333
+ for (const file of files)
381
334
  formData.append('files', file)
382
335
  await dashboardApi('/api/dashboard/files/uploads', { method: 'POST', formData })
383
- const count = selectedFiles().length
336
+ const count = files.length
384
337
  showUploadModal.set(false)
385
- resetUploadSelection()
386
338
  pushToast('success', `${count} ${count === 1 ? 'file' : 'files'} uploaded`)
387
339
  await loadFiles()
388
340
  }
@@ -405,6 +357,17 @@ function closeDelete(): void {
405
357
  itemToDelete.set(null)
406
358
  }
407
359
 
360
+ function deleteTitle(): string {
361
+ const item = itemToDelete()
362
+ return item ? `Delete ${item.name}` : 'Delete item'
363
+ }
364
+
365
+ function deleteDescription(): string {
366
+ return itemToDelete()?.type === 'folder'
367
+ ? 'The folder and everything inside it will be permanently deleted.'
368
+ : 'This file will be permanently deleted.'
369
+ }
370
+
408
371
  async function deleteItem(): Promise<void> {
409
372
  const item = itemToDelete()
410
373
  if (!item)
@@ -430,25 +393,6 @@ async function deleteItem(): Promise<void> {
430
393
  }
431
394
  }
432
395
 
433
- function handleKeydown(event: KeyboardEvent): void {
434
- if (event.key !== 'Escape')
435
- return
436
- if (itemToDelete()) {
437
- closeDelete()
438
- return
439
- }
440
- if (showUploadModal()) {
441
- closeUpload()
442
- return
443
- }
444
- if (showCreateFolderModal()) {
445
- closeCreateFolder()
446
- return
447
- }
448
- selectedItem.set(null)
449
- }
450
-
451
- useEventListener('keydown', handleKeydown)
452
396
  onMount(() => { void loadFiles() })
453
397
  </script>
454
398
 
@@ -568,37 +512,33 @@ onMount(() => { void loadFiles() })
568
512
  </div>
569
513
  </div>
570
514
 
571
- <aside :if="selectedItem()" aria-label="File details" class="flex fixed inset-y-0 right-0 z-40 flex-col max-w-sm w-full bg-white/95 dark:bg-neutral-900/95 border-black/[0.06] border-l dark:border-white/[0.08] shadow-2xl backdrop-blur-2xl">
572
- <header class="flex items-center justify-between p-4 border-b border-black/[0.06] dark:border-white/[0.08]"><div><p class="font-semibold text-sm text-stone-900 dark:text-white">File details</p><p class="mt-0.5 text-stone-500 text-xs dark:text-neutral-400">{{ selectedDisk }} disk</p></div><Button variant="ghost" iconOnly ariaLabel="Close file details" @click="selectedItem.set(null)"><span class="block h-5 w-5 i-hugeicons-cancel-01"></span></Button></header>
573
- <div class="overflow-y-auto flex-1 p-5"><MediaInfoPanel :item="selectedItem()" /><Button :if="selectedItem()?.url" tag="a" :href="selectedItem()?.url" target="_blank" rel="noopener" fullWidth class="mt-5"><span class="h-4 w-4 i-hugeicons-link-square-02"></span>Open file</Button></div>
574
- </aside>
575
-
576
- <div :if="showCreateFolderModal()" class="flex fixed inset-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
577
- <button type="button" aria-label="Close new folder dialog" class="absolute inset-0 bg-black/45" @click="closeCreateFolder()"></button>
578
- <form role="dialog" aria-modal="true" aria-labelledby="file-manager-folder-title" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-stone-200 rounded-xl dark:border-neutral-700 shadow-xl" @submit.prevent="createFolder()">
579
- <h2 id="file-manager-folder-title" class="font-semibold text-lg text-stone-900 dark:text-white">Create folder</h2><p class="mt-1 text-sm text-stone-500 dark:text-neutral-400">Add a folder inside {{ currentFolderLabel }}.</p>
580
- <div :if="createError()" role="alert" class="mt-4 p-3 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">{{ createError() }}</div>
581
- <label class="block mt-5"><span class="font-medium text-sm text-stone-700 dark:text-neutral-200">Folder name</span><input ref="fileManagerFolderName" x-model.trim="newFolderName" required maxlength="255" autocomplete="off" class="mt-1 px-3 py-2 w-full text-sm text-stone-900 dark:text-white bg-white dark:bg-neutral-900 border border-stone-300 rounded-lg dark:border-neutral-700" /></label>
582
- <div class="flex gap-2 justify-end mt-6"><Button :disabled="creatingFolder()" variant="secondary" @click="closeCreateFolder()">Cancel</Button><Button type="submit" :disabled="creatingFolder() || !newFolderName().trim()" :loading="creatingFolder()">Create folder</Button></div>
583
- </form>
584
- </div>
585
-
586
- <div :if="showUploadModal()" class="flex fixed inset-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
587
- <button type="button" aria-label="Close upload dialog" class="absolute inset-0 bg-black/45" @click="closeUpload()"></button>
588
- <form role="dialog" aria-modal="true" aria-labelledby="file-manager-upload-title" class="relative p-6 max-w-lg w-full bg-white dark:bg-neutral-800 border border-stone-200 rounded-xl dark:border-neutral-700 shadow-xl" @submit.prevent="uploadFiles()">
589
- <h2 id="file-manager-upload-title" class="font-semibold text-lg text-stone-900 dark:text-white">Upload files</h2><p class="mt-1 text-sm text-stone-500 dark:text-neutral-400">Upload to {{ currentFolderLabel }}. Each file can be up to 10 MB.</p>
590
- <div :if="uploadError()" role="alert" class="mt-4 p-3 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">{{ uploadError() }}</div>
591
- <label class="flex flex-col items-center justify-center mt-5 p-8 text-center border-2 border-dashed border-stone-300 rounded-xl dark:border-neutral-600 hover:border-blue-400 dark:hover:border-blue-600 cursor-pointer" @dragover.prevent="handleDragOver()" @drop.prevent="handleDrop($event)"><span class="h-10 w-10 text-stone-400 i-hugeicons-upload-01"></span><span class="mt-3 font-semibold text-sm text-stone-800 dark:text-white">Choose files or drop them here</span><span class="mt-1 text-stone-500 text-xs dark:text-neutral-400">{{ selectedFilesLabel }}</span><input ref="fileManagerUploadInput" type="file" class="sr-only" multiple @change="handleFileSelection($event)" /></label>
592
- <ul :if="selectedFiles().length" class="overflow-y-auto mt-4 space-y-1 max-h-32 text-sm"><template :for="file in selectedFiles"><li class="flex gap-4 items-center justify-between px-3 py-2 bg-stone-50 dark:bg-neutral-900 rounded-lg"><span class="text-stone-700 truncate dark:text-neutral-200">{{ file.name }}</span><span class="flex-shrink-0 font-mono text-stone-400 text-xs dark:text-neutral-500">{{ formatFileSize(file.size) }}</span></li></template></ul>
593
- <div class="flex gap-2 justify-end mt-6"><Button :disabled="uploading()" variant="secondary" @click="closeUpload()">Cancel</Button><Button type="submit" :disabled="uploading() || !selectedFiles().length" :loading="uploading()">Upload</Button></div>
594
- </form>
595
- </div>
596
-
597
- <div :if="itemToDelete()" class="flex fixed inset-0 z-[60] items-center justify-center p-4 dashboard-modal-layer">
598
- <button type="button" aria-label="Close delete dialog" class="absolute inset-0 bg-black/45" @click="closeDelete()"></button>
599
- <section role="alertdialog" aria-modal="true" aria-labelledby="file-manager-delete-title" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-stone-200 rounded-xl dark:border-neutral-700 shadow-xl">
600
- <div class="flex gap-3 items-start"><span class="flex flex-shrink-0 items-center justify-center h-10 w-10 text-red-600 bg-red-50 dark:bg-red-950/40 rounded-full"><span class="h-5 w-5 i-hugeicons-delete-02"></span></span><div><h2 id="file-manager-delete-title" class="font-semibold text-lg text-stone-900 dark:text-white">Delete {{ itemToDelete()?.name }}</h2><p class="mt-1 text-sm text-stone-500 dark:text-neutral-400">{{ itemToDelete()?.type === 'folder' ? 'The folder and everything inside it will be permanently deleted.' : 'This file will be permanently deleted.' }}</p></div></div>
601
- <div :if="deleteError()" role="alert" class="mt-4 p-3 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">{{ deleteError() }}</div>
602
- <div class="flex gap-2 justify-end mt-6"><Button :disabled="deleting()" variant="secondary" @click="closeDelete()">Cancel</Button><Button :loading="deleting()" variant="danger" @click="deleteItem()">Delete permanently</Button></div>
603
- </section>
604
- </div>
515
+ <FileDetailsDrawer :item="selectedItem()" :disk="selectedDisk()" @close="selectedItem.set(null)" />
516
+
517
+ <CreateFolderDialog
518
+ :open="showCreateFolderModal()"
519
+ :location="currentFolderLabel()"
520
+ :busy="creatingFolder()"
521
+ :error="createError()"
522
+ @close="closeCreateFolder"
523
+ @submit="createFolder"
524
+ />
525
+
526
+ <FileUploadDialog
527
+ :open="showUploadModal()"
528
+ :location="currentFolderLabel()"
529
+ :busy="uploading()"
530
+ :error="uploadError()"
531
+ @close="closeUpload"
532
+ @submit="uploadFiles"
533
+ />
534
+
535
+ <ConfirmDialog
536
+ :isOpen="Boolean(itemToDelete())"
537
+ :title="deleteTitle()"
538
+ :description="deleteDescription()"
539
+ confirmLabel="Delete permanently"
540
+ :busy="deleting()"
541
+ :error="deleteError()"
542
+ @close="closeDelete"
543
+ @confirm="deleteItem"
544
+ />
@@ -0,0 +1,89 @@
1
+ <script client>
2
+ const open = useReactiveProp('open', false)
3
+ const location = useReactiveProp('location', 'Storage root')
4
+ const busy = useReactiveProp('busy', false)
5
+ const error = useReactiveProp('error', '')
6
+ const files = state<File[]>([])
7
+ const fileInput = useRef<HTMLInputElement>('dashboardFileUploadInput')
8
+ const emit = defineEmits()
9
+ let wasOpen = false
10
+
11
+ const selectedFilesLabel = derived(() => {
12
+ if (!files().length)
13
+ return 'No files selected'
14
+ if (files().length === 1)
15
+ return files()[0]?.name || '1 file selected'
16
+ return `${files().length} files selected`
17
+ })
18
+
19
+ effect(() => {
20
+ const isOpen = open()
21
+ if (isOpen && !wasOpen)
22
+ reset()
23
+ wasOpen = isOpen
24
+ })
25
+
26
+ function formatFileSize(bytes: number): string {
27
+ if (bytes === 0)
28
+ return '0 Bytes'
29
+ const units = ['Bytes', 'KB', 'MB', 'GB', 'TB']
30
+ const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
31
+ return `${Number.parseFloat((bytes / 1024 ** index).toFixed(2))} ${units[index]}`
32
+ }
33
+
34
+ function reset(): void {
35
+ files.set([])
36
+ if (fileInput.current)
37
+ fileInput.current.value = ''
38
+ }
39
+
40
+ function acceptFiles(value: FileList | File[]): void {
41
+ files.set(Array.from(value))
42
+ }
43
+
44
+ function handleFileSelection(event: Event): void {
45
+ const input = event.target as HTMLInputElement
46
+ if (input.files)
47
+ acceptFiles(input.files)
48
+ }
49
+
50
+ function handleDrop(event: DragEvent): void {
51
+ if (event.dataTransfer?.files)
52
+ acceptFiles(event.dataTransfer.files)
53
+ }
54
+
55
+ function close(): void {
56
+ if (!busy())
57
+ emit('close')
58
+ }
59
+
60
+ function submit(): void {
61
+ if (files().length)
62
+ emit('submit', [...files()])
63
+ }
64
+ </script>
65
+
66
+ <Modal :isOpen="open()" title="Upload files" :description="'Upload to ' + location() + '. Each file can be up to 10 MB.'" :closable="!busy()" @close="close">
67
+ <form id="file-upload-form" @submit.prevent="submit">
68
+ <label class="flex flex-col items-center justify-center p-8 text-center border-2 border-dashed border-stone-300 rounded-xl dark:border-neutral-600 hover:border-blue-400 dark:hover:border-blue-600 cursor-pointer" @dragover.prevent @drop.prevent="handleDrop($event)">
69
+ <span class="h-10 w-10 text-stone-400 i-hugeicons-upload-01"></span>
70
+ <span class="mt-3 font-semibold text-stone-800 text-sm dark:text-white">Choose files or drop them here</span>
71
+ <span class="mt-1 text-stone-500 text-xs dark:text-neutral-400">{{ selectedFilesLabel() }}</span>
72
+ <input ref="dashboardFileUploadInput" name="files" type="file" class="sr-only" multiple @change="handleFileSelection($event)" />
73
+ </label>
74
+ <ul :if="files().length" class="overflow-y-auto mt-4 space-y-1 max-h-32 text-sm">
75
+ <template :for="file in files">
76
+ <li class="flex gap-4 items-center justify-between px-3 py-2 bg-stone-50 dark:bg-neutral-900 rounded-lg"><span class="text-stone-700 truncate dark:text-neutral-200">{{ file.name }}</span><span class="flex-shrink-0 font-mono text-stone-400 text-xs dark:text-neutral-500">{{ formatFileSize(file.size) }}</span></li>
77
+ </template>
78
+ </ul>
79
+ </form>
80
+
81
+ <div :if="error()" role="alert" class="mt-4 p-3 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">{{ error() }}</div>
82
+
83
+ <template #footer>
84
+ <div class="flex gap-2 justify-end w-full">
85
+ <Button :disabled="busy()" variant="secondary" @click="close">Cancel</Button>
86
+ <Button type="submit" form="file-upload-form" :disabled="!files().length" :loading="busy()">Upload</Button>
87
+ </div>
88
+ </template>
89
+ </Modal>