@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.
- package/ai/skills/stacks-browse/SKILL.md +2 -1
- package/ai/skills/stacks-browse/scripts/browse.ts +20 -2
- package/ai/skills/stacks-dashboard/SKILL.md +4 -0
- package/app/Actions/Auth/LogoutAction.ts +11 -4
- package/app/Actions/Auth/LogoutAllAction.ts +8 -4
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +4 -1
- package/app/Actions/Dashboard/Models/model-write.ts +5 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Buttons/Counter.stx +1 -1
- package/resources/components/Buttons/ToggleDark.stx +1 -1
- package/resources/components/Dashboard/App/RequestDetailsDialog.stx +42 -0
- package/resources/components/Dashboard/App/RequestsOverview.stx +1 -21
- package/resources/components/Dashboard/Auth/AccessTokenCreateDialog.stx +155 -0
- package/resources/components/Dashboard/Auth/AccessTokens.stx +41 -129
- package/resources/components/Dashboard/Ci/CiDashboard.stx +1 -157
- package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +127 -0
- package/resources/components/Dashboard/Commerce/CommerceCategoryDeleteDialog.stx +10 -8
- package/resources/components/Dashboard/Commerce/CommerceCategoryDetailsDialog.stx +22 -17
- package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +14 -13
- package/resources/components/Dashboard/Commerce/CommerceCouponDeleteDialog.stx +10 -7
- package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +25 -19
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +14 -11
- package/resources/components/Dashboard/Commerce/CommerceCustomerDeleteDialog.stx +10 -8
- package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +24 -16
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +14 -13
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDeleteDialog.stx +10 -8
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +16 -14
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/CommerceManufacturerDeleteDialog.stx +14 -16
- package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/CommerceOrderDeleteDialog.stx +10 -8
- package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +15 -10
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +14 -13
- package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +30 -25
- package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +27 -20
- package/resources/components/Dashboard/Commerce/CommerceProductDeleteDialog.stx +14 -14
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +14 -13
- package/resources/components/Dashboard/Commerce/CommerceReviewDeleteDialog.stx +10 -18
- package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/CommerceTaxDeleteDialog.stx +14 -14
- package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceUnitDeleteDialog.stx +14 -17
- package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/CommerceVariantDeleteDialog.stx +10 -8
- package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRouteDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRouteDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/DriverDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/DriverDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeyDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeyDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZoneDeleteDialog.stx +11 -15
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZoneDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +16 -15
- package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +16 -15
- package/resources/components/Dashboard/Commerce/PrintDeviceDeleteDialog.stx +10 -11
- package/resources/components/Dashboard/Commerce/PrintDeviceDetailsDialog.stx +16 -14
- package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +17 -16
- package/resources/components/Dashboard/Commerce/PrintLogDeleteDialog.stx +10 -7
- package/resources/components/Dashboard/Commerce/PrintLogDetailsDialog.stx +13 -10
- package/resources/components/Dashboard/Commerce/ProductWaitlistDeleteDialog.stx +10 -18
- package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +96 -105
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistDeleteDialog.stx +10 -18
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +92 -95
- package/resources/components/Dashboard/Content/AuthorEditorDialog.stx +94 -0
- package/resources/components/Dashboard/Content/AuthorsDashboard.stx +39 -31
- package/resources/components/Dashboard/Content/BlogDashboard.stx +87 -176
- package/resources/components/Dashboard/Content/BlogPostEditorDialog.stx +195 -0
- package/resources/components/Dashboard/Content/CommentsDashboard.stx +14 -4
- package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +40 -55
- package/resources/components/Dashboard/Content/CreateFolderDialog.stx +45 -0
- package/resources/components/Dashboard/Content/FileDetailsDrawer.stx +31 -0
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +47 -107
- package/resources/components/Dashboard/Content/FileUploadDialog.stx +89 -0
- package/resources/components/Dashboard/Content/PageEditorDialog.stx +93 -0
- package/resources/components/Dashboard/Content/PagesDashboard.stx +36 -26
- package/resources/components/Dashboard/Content/PostEditorDialog.stx +176 -0
- package/resources/components/Dashboard/Content/PostsDashboard.stx +40 -64
- package/resources/components/Dashboard/Content/SeoDashboard.stx +18 -84
- package/resources/components/Dashboard/Content/SeoEditorDialog.stx +108 -0
- package/resources/components/Dashboard/Content/TaxonomyEditorDialog.stx +106 -0
- package/resources/components/Dashboard/CreateRecordModal.stx +72 -83
- package/resources/components/Dashboard/Email/InboxMessageList.stx +1 -1
- package/resources/components/Dashboard/GlobalSearch.stx +3 -9
- package/resources/components/Dashboard/Infrastructure/CloudDetailsDialog.stx +2 -16
- package/resources/components/Dashboard/Infrastructure/DnsRecordDialog.stx +4 -16
- package/resources/components/Dashboard/Infrastructure/LogDetailsDialog.stx +4 -19
- package/resources/components/Dashboard/Infrastructure/MailboxDetailsDialog.stx +6 -16
- package/resources/components/Dashboard/Infrastructure/ServerInspectorDialog.stx +105 -0
- package/resources/components/Dashboard/Infrastructure/ServersDashboard.stx +5 -59
- package/resources/components/Dashboard/Jobs/JobHistory.stx +1 -11
- package/resources/components/Dashboard/Library/ComponentsDashboard.stx +11 -60
- package/resources/components/Dashboard/Library/FunctionsDashboard.stx +11 -15
- package/resources/components/Dashboard/Library/SourceScaffoldDialog.stx +95 -0
- package/resources/components/Dashboard/Management/AssignmentDrawer.stx +93 -0
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +47 -129
- package/resources/components/Dashboard/Marketing/CampaignDeliveryDialog.stx +5 -8
- package/resources/components/Dashboard/Marketing/CampaignDialog.stx +17 -17
- package/resources/components/Dashboard/Marketing/MarketingListDialog.stx +17 -17
- package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +1 -1
- package/resources/components/Dashboard/Marketing/SocialPostDialog.stx +17 -16
- package/resources/components/Dashboard/Models/EditRecordModal.stx +81 -0
- package/resources/components/Dashboard/Models/ModelRecordActions.stx +30 -0
- package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +42 -94
- package/resources/components/Dashboard/Navbar.stx +5 -5
- package/resources/components/Dashboard/Notifications/NotificationDeliveryDialog.stx +40 -60
- package/resources/components/Dashboard/UI/Button.stx +4 -0
- package/resources/components/Dashboard/UI/ConfirmDialog.stx +4 -0
- package/resources/components/Dashboard/UI/Drawer.stx +114 -0
- package/resources/components/Dashboard/UI/Modal.stx +19 -12
- package/resources/components/Docs/Demo/ComboboxDemo.stx +4 -5
- package/resources/components/Docs/Demo/DropdownDemo.stx +12 -5
- package/resources/components/Docs/Demo/ListboxDemo.stx +14 -6
- package/resources/components/Docs/Demo/PopoverDemo.stx +10 -5
- package/resources/components/Docs/Demo/RadioGroupDemo.stx +14 -6
- package/resources/components/Docs/Demo/StepperDemo.stx +4 -5
- package/resources/components/Docs/TeamMember.stx +16 -9
- package/resources/components/Forum/ForumReplyForm.stx +7 -7
- package/resources/components/Forum/ForumSidebar.stx +10 -6
- package/resources/components/Marketing/Faqs.stx +5 -5
- package/resources/components/Marketing/Feature.stx +11 -6
- package/resources/components/Marketing/Layout.stx +12 -8
- package/resources/components/Marketing/Pricing.stx +11 -5
- package/resources/components/Marketing/PrimaryFeatures.stx +4 -5
- package/resources/components/Marketing/PrimaryFeatures2.stx +6 -5
- package/resources/components/Marketing/SecondaryFeatures.stx +10 -5
- package/resources/components/Marketing/Testimonials.stx +8 -5
- package/resources/views/index.stx +4 -4
- package/views/system-tray/index.stx +1 -1
|
@@ -12,6 +12,13 @@ interface AuthorRecord {
|
|
|
12
12
|
createdAt: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface AuthorDraft {
|
|
16
|
+
name: string
|
|
17
|
+
email: string
|
|
18
|
+
bio: string
|
|
19
|
+
avatar: string
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
const authors = state<AuthorRecord[]>([])
|
|
16
23
|
const assignedPostCount = state(0)
|
|
17
24
|
const loading = state(true)
|
|
@@ -19,10 +26,6 @@ const loadError = state('')
|
|
|
19
26
|
const search = state('')
|
|
20
27
|
const formOpen = state(false)
|
|
21
28
|
const editingAuthor = state<AuthorRecord | null>(null)
|
|
22
|
-
const name = state('')
|
|
23
|
-
const email = state('')
|
|
24
|
-
const bio = state('')
|
|
25
|
-
const avatar = state('')
|
|
26
29
|
const saving = state(false)
|
|
27
30
|
const saveError = state('')
|
|
28
31
|
const deletingAuthor = state<AuthorRecord | null>(null)
|
|
@@ -80,22 +83,15 @@ async function loadAuthors(): Promise<void> {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
function resetForm(author: AuthorRecord | null): void {
|
|
84
|
-
editingAuthor.set(author)
|
|
85
|
-
name.set(author?.name || '')
|
|
86
|
-
email.set(author?.email || '')
|
|
87
|
-
bio.set(author?.bio || '')
|
|
88
|
-
avatar.set(author?.avatar || '')
|
|
89
|
-
saveError.set('')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
86
|
function openCreate(): void {
|
|
93
|
-
|
|
87
|
+
editingAuthor.set(null)
|
|
88
|
+
saveError.set('')
|
|
94
89
|
formOpen.set(true)
|
|
95
90
|
}
|
|
96
91
|
|
|
97
92
|
function openEdit(author: AuthorRecord): void {
|
|
98
|
-
|
|
93
|
+
editingAuthor.set(author)
|
|
94
|
+
saveError.set('')
|
|
99
95
|
formOpen.set(true)
|
|
100
96
|
}
|
|
101
97
|
|
|
@@ -104,14 +100,14 @@ function closeForm(): void {
|
|
|
104
100
|
formOpen.set(false)
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
async function saveAuthor(): Promise<void> {
|
|
103
|
+
async function saveAuthor(draft: AuthorDraft): Promise<void> {
|
|
108
104
|
const author = editingAuthor()
|
|
109
105
|
saving.set(true)
|
|
110
106
|
saveError.set('')
|
|
111
107
|
try {
|
|
112
108
|
await dashboardApi(author ? `/api/dashboard/authors/${author.id}` : '/api/dashboard/authors', {
|
|
113
109
|
method: author ? 'PATCH' : 'POST',
|
|
114
|
-
body:
|
|
110
|
+
body: draft,
|
|
115
111
|
})
|
|
116
112
|
formOpen.set(false)
|
|
117
113
|
pushToast('success', author ? 'Author updated' : 'Author created')
|
|
@@ -135,6 +131,13 @@ function closeDelete(): void {
|
|
|
135
131
|
deletingAuthor.set(null)
|
|
136
132
|
}
|
|
137
133
|
|
|
134
|
+
function deleteDescription(): string {
|
|
135
|
+
const author = deletingAuthor()
|
|
136
|
+
return author
|
|
137
|
+
? `${author.name} will be removed. Their posts will be kept without an assigned author.`
|
|
138
|
+
: ''
|
|
139
|
+
}
|
|
140
|
+
|
|
138
141
|
async function deleteAuthor(): Promise<void> {
|
|
139
142
|
const author = deletingAuthor()
|
|
140
143
|
if (!author)
|
|
@@ -191,17 +194,22 @@ onMount(() => {
|
|
|
191
194
|
</section>
|
|
192
195
|
</div>
|
|
193
196
|
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
197
|
+
<AuthorEditorDialog
|
|
198
|
+
:open="formOpen()"
|
|
199
|
+
:author="editingAuthor()"
|
|
200
|
+
:busy="saving()"
|
|
201
|
+
:error="saveError()"
|
|
202
|
+
@close="closeForm"
|
|
203
|
+
@submit="saveAuthor"
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
<ConfirmDialog
|
|
207
|
+
:isOpen="Boolean(deletingAuthor())"
|
|
208
|
+
title="Delete author?"
|
|
209
|
+
:description="deleteDescription()"
|
|
210
|
+
confirmLabel="Delete author"
|
|
211
|
+
:busy="deleting()"
|
|
212
|
+
:error="deleteError()"
|
|
213
|
+
@close="closeDelete"
|
|
214
|
+
@confirm="deleteAuthor"
|
|
215
|
+
/>
|
|
@@ -168,101 +168,31 @@ export const blogUrl = publicApplicationUrl('/blog')
|
|
|
168
168
|
</section>
|
|
169
169
|
</div>
|
|
170
170
|
|
|
171
|
-
<!-- Editor -->
|
|
172
|
-
<div x-show="isEditorOpen()" class="flex fixed inset-0 overflow-y-auto z-40 items-start justify-center p-4 sm:p-6 dashboard-modal-layer">
|
|
173
|
-
<div class="fixed inset-0 bg-neutral-950/60" @click="closeEditor()"></div>
|
|
174
|
-
|
|
175
|
-
<div role="dialog" aria-modal="true" aria-labelledby="editor-title" class="relative z-10 my-8 max-w-3xl w-full bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
|
|
176
|
-
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
|
177
|
-
<h2 id="editor-title" class="font-semibold text-lg">{{ editingSlug() ? 'Edit post' : 'New post' }}</h2>
|
|
178
|
-
<Button variant="ghost" size="sm" iconOnly ariaLabel="Close editor" @click="closeEditor()">
|
|
179
|
-
<span class="block h-4 w-4 i-hugeicons-cancel-01"></span>
|
|
180
|
-
</Button>
|
|
181
|
-
</div>
|
|
182
|
-
|
|
183
|
-
<div class="overflow-y-auto p-5 space-y-4 max-h-[70vh]">
|
|
184
|
-
<div x-show="formError()" class="flex gap-2 items-start px-3 py-2 text-red-800 text-sm dark:text-red-200 bg-red-50 dark:bg-red-950/40 border border-red-200 rounded-md dark:border-red-900">
|
|
185
|
-
<span class="shrink-0 mt-0.5 h-4 w-4 i-hugeicons-alert-02"></span>
|
|
186
|
-
<span>{{ formError() }}</span>
|
|
187
|
-
</div>
|
|
188
|
-
|
|
189
|
-
<div class="grid gap-4 sm:grid-cols-2">
|
|
190
|
-
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
191
|
-
<label for="post-title" class="font-medium text-sm">Title</label>
|
|
192
|
-
<input id="post-title" type="text" x-model="formTitle" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
193
|
-
</div>
|
|
194
|
-
|
|
195
|
-
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
196
|
-
<label for="post-slug" class="font-medium text-sm">Slug</label>
|
|
197
|
-
<input id="post-slug" type="text" x-model="formSlug" :placeholder="suggestedSlug()" class="placeholder-neutral-500 px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
198
|
-
<p class="text-neutral-500 text-xs dark:text-neutral-400">The file name and public URL: /blog/{{ formSlug() || suggestedSlug() || 'your-post' }}</p>
|
|
199
|
-
</div>
|
|
200
|
-
|
|
201
|
-
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
202
|
-
<label for="post-description" class="font-medium text-sm">Description</label>
|
|
203
|
-
<input id="post-description" type="text" x-model="formDescription" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
204
|
-
<p class="text-neutral-500 text-xs dark:text-neutral-400">Used as the excerpt, the meta description, and the RSS summary.</p>
|
|
205
|
-
</div>
|
|
206
|
-
|
|
207
|
-
<div class="flex flex-col gap-2">
|
|
208
|
-
<label for="post-date" class="font-medium text-sm">Date</label>
|
|
209
|
-
<input id="post-date" type="date" x-model="formDate" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
210
|
-
</div>
|
|
211
|
-
|
|
212
|
-
<div class="flex flex-col gap-2">
|
|
213
|
-
<label for="post-author" class="font-medium text-sm">Author</label>
|
|
214
|
-
<input id="post-author" type="text" x-model="formAuthor" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
215
|
-
</div>
|
|
216
|
-
|
|
217
|
-
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
218
|
-
<label for="post-poster" class="font-medium text-sm">Poster image</label>
|
|
219
|
-
<input id="post-poster" type="text" x-model="formPoster" placeholder="/assets/images/example.svg" class="placeholder-neutral-500 px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
220
|
-
</div>
|
|
221
|
-
|
|
222
|
-
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
223
|
-
<label for="post-body" class="font-medium text-sm">Content</label>
|
|
224
|
-
<textarea id="post-body" x-model="formBody" rows="14" class="px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 resize-y"></textarea>
|
|
225
|
-
<p class="text-neutral-500 text-xs dark:text-neutral-400">Markdown. {{ bodyWordCount() }} words, about {{ bodyReadingTime() }} min read.</p>
|
|
226
|
-
</div>
|
|
227
|
-
</div>
|
|
228
|
-
|
|
229
|
-
<div class="flex flex-col gap-3 sm:flex-row sm:gap-6 pt-2 border-neutral-200 border-t dark:border-neutral-800">
|
|
230
|
-
<label class="flex gap-2 items-center text-sm">
|
|
231
|
-
<input type="checkbox" x-model="formDraft" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
|
|
232
|
-
<span>Draft, keep off the public blog</span>
|
|
233
|
-
</label>
|
|
234
|
-
<label class="flex gap-2 items-center text-sm">
|
|
235
|
-
<input type="checkbox" x-model="formFeatured" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
|
|
236
|
-
<span>Feature at the top of the listing</span>
|
|
237
|
-
</label>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
<div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800">
|
|
242
|
-
<Button variant="secondary" :disabled="isSaving()" @click="closeEditor()">Cancel</Button>
|
|
243
|
-
<Button :loading="isSaving()" @click="submit()">
|
|
244
|
-
{{ isSaving() ? 'Saving' : 'Save post' }}
|
|
245
|
-
</Button>
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
|
|
250
|
-
<!-- Delete confirmation -->
|
|
251
|
-
<div x-show="postToDelete()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
|
|
252
|
-
<div class="fixed inset-0 bg-neutral-950/60" @click="cancelDelete()"></div>
|
|
253
|
-
|
|
254
|
-
<div role="dialog" aria-modal="true" aria-labelledby="delete-title" class="relative z-10 p-5 max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
|
|
255
|
-
<h2 id="delete-title" class="font-semibold text-lg">Delete this post?</h2>
|
|
256
|
-
<p class="mt-2 text-neutral-600 text-sm dark:text-neutral-300">
|
|
257
|
-
This deletes the file <code class="px-1 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-800 rounded">content/blog/{{ deleteTargetSlug() }}.md</code>. It stays in git history until you commit the deletion.
|
|
258
|
-
</p>
|
|
259
|
-
<div class="flex gap-2 justify-end mt-5">
|
|
260
|
-
<Button variant="secondary" @click="cancelDelete()">Cancel</Button>
|
|
261
|
-
<Button variant="danger" @click="confirmDelete()">Delete post</Button>
|
|
262
|
-
</div>
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
171
|
</div>
|
|
172
|
+
|
|
173
|
+
<BlogPostEditorDialog
|
|
174
|
+
:open="isEditorOpen()"
|
|
175
|
+
:post="editingPost()"
|
|
176
|
+
:loadedBody="loadedBody()"
|
|
177
|
+
:bodyLoading="bodyLoading()"
|
|
178
|
+
:bodyLoadFailed="bodyLoadFailed()"
|
|
179
|
+
:busy="isSaving()"
|
|
180
|
+
:error="formError()"
|
|
181
|
+
@close="closeEditor"
|
|
182
|
+
@retry="retryEditorBody"
|
|
183
|
+
@submit="submit"
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
<ConfirmDialog
|
|
187
|
+
:isOpen="Boolean(postToDelete())"
|
|
188
|
+
title="Delete this post?"
|
|
189
|
+
:description="deleteDescription()"
|
|
190
|
+
confirmLabel="Delete post"
|
|
191
|
+
:busy="isDeleting()"
|
|
192
|
+
:error="deleteError()"
|
|
193
|
+
@close="cancelDelete"
|
|
194
|
+
@confirm="confirmDelete"
|
|
195
|
+
/>
|
|
266
196
|
</template>
|
|
267
197
|
|
|
268
198
|
<script client>
|
|
@@ -282,33 +212,28 @@ const loadError = state('')
|
|
|
282
212
|
const searchQuery = state('')
|
|
283
213
|
const statusFilter = state('all')
|
|
284
214
|
|
|
285
|
-
// Editor state. `editingSlug` doubles as the mode flag: empty means "new post",
|
|
286
|
-
// and it holds the ORIGINAL slug so a slug edit is sent as a rename.
|
|
287
215
|
const isEditorOpen = state(false)
|
|
288
|
-
const
|
|
216
|
+
const editingPost = state(null)
|
|
217
|
+
const loadedBody = state('')
|
|
218
|
+
const bodyLoading = state(false)
|
|
219
|
+
const bodyLoadFailed = state(false)
|
|
289
220
|
const formError = state('')
|
|
290
|
-
const formTitle = state('')
|
|
291
|
-
const formSlug = state('')
|
|
292
|
-
const formDescription = state('')
|
|
293
|
-
const formDate = state('')
|
|
294
|
-
const formAuthor = state('')
|
|
295
|
-
const formPoster = state('')
|
|
296
|
-
const formBody = state('')
|
|
297
|
-
const formDraft = state(false)
|
|
298
|
-
const formFeatured = state(false)
|
|
299
221
|
|
|
300
222
|
const postToDelete = state(null)
|
|
223
|
+
const isDeleting = state(false)
|
|
224
|
+
const deleteError = state('')
|
|
225
|
+
let editorRequestId = 0
|
|
301
226
|
|
|
302
227
|
function publicPostUrl(slug) {
|
|
303
228
|
return `${publicBlogBaseUrl}/${encodeURIComponent(String(slug))}`
|
|
304
229
|
}
|
|
305
230
|
|
|
306
|
-
|
|
307
|
-
// never has to reach through a possibly-null post.
|
|
308
|
-
const deleteTargetSlug = derived(() => {
|
|
231
|
+
const deleteDescription = derived(() => {
|
|
309
232
|
const post = postToDelete()
|
|
310
233
|
|
|
311
|
-
return post
|
|
234
|
+
return post
|
|
235
|
+
? `Deletes content/blog/${post.slug}.md. The file stays in git history until you commit the deletion.`
|
|
236
|
+
: ''
|
|
312
237
|
})
|
|
313
238
|
|
|
314
239
|
const publishedCount = derived(() => posts().filter(p => !p.draft).length)
|
|
@@ -331,20 +256,6 @@ const filteredPosts = derived(() => {
|
|
|
331
256
|
})
|
|
332
257
|
})
|
|
333
258
|
|
|
334
|
-
// Mirrors slugify() in blog-admin.ts so the placeholder matches what the server
|
|
335
|
-
// will actually write when the slug field is left empty.
|
|
336
|
-
function slugify(input) {
|
|
337
|
-
return String(input)
|
|
338
|
-
.toLowerCase()
|
|
339
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
340
|
-
.replace(/^-+|-+$/g, '')
|
|
341
|
-
.slice(0, 80)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const suggestedSlug = derived(() => slugify(formTitle()))
|
|
345
|
-
const bodyWordCount = derived(() => formBody().split(/\s+/).filter(Boolean).length)
|
|
346
|
-
const bodyReadingTime = derived(() => Math.max(1, Math.round(bodyWordCount() / 200)))
|
|
347
|
-
|
|
348
259
|
function statusClass(draft) {
|
|
349
260
|
return draft
|
|
350
261
|
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
|
|
@@ -368,78 +279,69 @@ function clearFilters() {
|
|
|
368
279
|
}
|
|
369
280
|
|
|
370
281
|
async function openEditor(post) {
|
|
282
|
+
const requestId = ++editorRequestId
|
|
371
283
|
formError.set('')
|
|
372
|
-
|
|
284
|
+
editingPost.set(post)
|
|
285
|
+
loadedBody.set('')
|
|
286
|
+
bodyLoading.set(Boolean(post))
|
|
287
|
+
bodyLoadFailed.set(false)
|
|
373
288
|
isEditorOpen.set(true)
|
|
374
289
|
|
|
375
|
-
if (!post)
|
|
376
|
-
formTitle.set('')
|
|
377
|
-
formSlug.set('')
|
|
378
|
-
formDescription.set('')
|
|
379
|
-
formDate.set(new Date().toISOString().slice(0, 10))
|
|
380
|
-
formAuthor.set('')
|
|
381
|
-
formPoster.set('')
|
|
382
|
-
formBody.set('')
|
|
383
|
-
formDraft.set(true)
|
|
384
|
-
formFeatured.set(false)
|
|
290
|
+
if (!post)
|
|
385
291
|
return
|
|
386
|
-
}
|
|
387
292
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
formAuthor.set(post.author)
|
|
396
|
-
formPoster.set(post.poster)
|
|
397
|
-
formDraft.set(post.draft)
|
|
398
|
-
formFeatured.set(post.featured)
|
|
399
|
-
formBody.set('')
|
|
293
|
+
await loadEditorBody(post, requestId)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function loadEditorBody(post, requestId = ++editorRequestId) {
|
|
297
|
+
bodyLoading.set(true)
|
|
298
|
+
bodyLoadFailed.set(false)
|
|
299
|
+
formError.set('')
|
|
400
300
|
|
|
401
301
|
try {
|
|
402
302
|
const full = await fetchBlogPost(post.slug)
|
|
403
303
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
formBody.set(full.body)
|
|
304
|
+
if (requestId === editorRequestId && isEditorOpen() && editingPost()?.slug === post.slug)
|
|
305
|
+
loadedBody.set(full.body)
|
|
407
306
|
}
|
|
408
307
|
catch (error) {
|
|
409
|
-
|
|
308
|
+
if (requestId === editorRequestId) {
|
|
309
|
+
bodyLoadFailed.set(true)
|
|
310
|
+
formError.set(`Couldn't load this post's content: ${error.message}`)
|
|
311
|
+
}
|
|
410
312
|
}
|
|
313
|
+
finally {
|
|
314
|
+
if (requestId === editorRequestId && isEditorOpen() && editingPost()?.slug === post.slug)
|
|
315
|
+
bodyLoading.set(false)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function retryEditorBody() {
|
|
320
|
+
const post = editingPost()
|
|
321
|
+
if (post)
|
|
322
|
+
void loadEditorBody(post)
|
|
411
323
|
}
|
|
412
324
|
|
|
413
325
|
function closeEditor() {
|
|
326
|
+
if (isSaving())
|
|
327
|
+
return
|
|
328
|
+
|
|
414
329
|
isEditorOpen.set(false)
|
|
415
|
-
|
|
330
|
+
editorRequestId += 1
|
|
331
|
+
editingPost.set(null)
|
|
332
|
+
loadedBody.set('')
|
|
333
|
+
bodyLoading.set(false)
|
|
334
|
+
bodyLoadFailed.set(false)
|
|
416
335
|
formError.set('')
|
|
417
336
|
}
|
|
418
337
|
|
|
419
|
-
async function submit() {
|
|
420
|
-
if (!formTitle().trim()) {
|
|
421
|
-
formError.set('A title is required.')
|
|
422
|
-
return
|
|
423
|
-
}
|
|
424
|
-
|
|
338
|
+
async function submit(draft) {
|
|
425
339
|
isSaving.set(true)
|
|
426
340
|
formError.set('')
|
|
427
341
|
|
|
428
342
|
try {
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
title: formTitle().trim(),
|
|
432
|
-
description: formDescription().trim(),
|
|
433
|
-
date: formDate(),
|
|
434
|
-
author: formAuthor().trim(),
|
|
435
|
-
authorBio: '',
|
|
436
|
-
poster: formPoster().trim(),
|
|
437
|
-
featured: formFeatured(),
|
|
438
|
-
draft: formDraft(),
|
|
439
|
-
body: formBody(),
|
|
440
|
-
}, editingSlug())
|
|
441
|
-
|
|
442
|
-
const original = editingSlug()
|
|
343
|
+
const original = editingPost()?.slug || ''
|
|
344
|
+
const saved = await saveBlogPost(draft, original)
|
|
443
345
|
|
|
444
346
|
posts.update(list => (original
|
|
445
347
|
? list.map(p => (p.slug === original ? saved : p))
|
|
@@ -457,11 +359,15 @@ async function submit() {
|
|
|
457
359
|
}
|
|
458
360
|
|
|
459
361
|
function askDelete(post) {
|
|
362
|
+
deleteError.set('')
|
|
460
363
|
postToDelete.set(post)
|
|
461
364
|
}
|
|
462
365
|
|
|
463
366
|
function cancelDelete() {
|
|
464
|
-
|
|
367
|
+
if (!isDeleting()) {
|
|
368
|
+
postToDelete.set(null)
|
|
369
|
+
deleteError.set('')
|
|
370
|
+
}
|
|
465
371
|
}
|
|
466
372
|
|
|
467
373
|
async function confirmDelete() {
|
|
@@ -469,14 +375,19 @@ async function confirmDelete() {
|
|
|
469
375
|
if (!post)
|
|
470
376
|
return
|
|
471
377
|
|
|
472
|
-
|
|
378
|
+
isDeleting.set(true)
|
|
379
|
+
deleteError.set('')
|
|
473
380
|
|
|
474
381
|
try {
|
|
475
382
|
await deleteBlogPost(post.slug)
|
|
476
383
|
posts.update(list => list.filter(p => p.slug !== post.slug))
|
|
384
|
+
postToDelete.set(null)
|
|
477
385
|
}
|
|
478
386
|
catch (error) {
|
|
479
|
-
|
|
387
|
+
deleteError.set(`Couldn't delete "${post.title}": ${error.message}`)
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
isDeleting.set(false)
|
|
480
391
|
}
|
|
481
392
|
}
|
|
482
393
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
interface BlogPostRecord {
|
|
3
|
+
slug: string
|
|
4
|
+
title: string
|
|
5
|
+
description: string
|
|
6
|
+
date: string
|
|
7
|
+
author: string
|
|
8
|
+
poster: string
|
|
9
|
+
draft: boolean
|
|
10
|
+
featured: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BlogPostDraft {
|
|
14
|
+
slug: string
|
|
15
|
+
title: string
|
|
16
|
+
description: string
|
|
17
|
+
date: string
|
|
18
|
+
author: string
|
|
19
|
+
authorBio: string
|
|
20
|
+
poster: string
|
|
21
|
+
featured: boolean
|
|
22
|
+
draft: boolean
|
|
23
|
+
body: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const open = useReactiveProp('open', false)
|
|
27
|
+
const post = useReactiveProp('post', null as BlogPostRecord | null)
|
|
28
|
+
const loadedBody = useReactiveProp('loadedBody', '')
|
|
29
|
+
const bodyLoading = useReactiveProp('bodyLoading', false)
|
|
30
|
+
const bodyLoadFailed = useReactiveProp('bodyLoadFailed', false)
|
|
31
|
+
const busy = useReactiveProp('busy', false)
|
|
32
|
+
const error = useReactiveProp('error', '')
|
|
33
|
+
const title = state('')
|
|
34
|
+
const slug = state('')
|
|
35
|
+
const description = state('')
|
|
36
|
+
const date = state('')
|
|
37
|
+
const author = state('')
|
|
38
|
+
const poster = state('')
|
|
39
|
+
const body = state('')
|
|
40
|
+
const draft = state(true)
|
|
41
|
+
const featured = state(false)
|
|
42
|
+
const emit = defineEmits()
|
|
43
|
+
let wasOpen = false
|
|
44
|
+
let activeSlug = ''
|
|
45
|
+
|
|
46
|
+
function slugify(input: string): string {
|
|
47
|
+
return input
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
50
|
+
.replace(/^-+|-+$/g, '')
|
|
51
|
+
.slice(0, 80)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const suggestedSlug = derived(() => slugify(title()))
|
|
55
|
+
const wordCount = derived(() => body().split(/\s+/).filter(Boolean).length)
|
|
56
|
+
const readingTime = derived(() => Math.max(1, Math.round(wordCount() / 200)))
|
|
57
|
+
const dialogTitle = derived(() => post() ? 'Edit post' : 'New post')
|
|
58
|
+
const actionLabel = derived(() => post() ? 'Save post' : 'Create post')
|
|
59
|
+
|
|
60
|
+
effect(() => {
|
|
61
|
+
const isOpen = open()
|
|
62
|
+
if (isOpen && !wasOpen) {
|
|
63
|
+
const record = post()
|
|
64
|
+
activeSlug = record?.slug || ''
|
|
65
|
+
title.set(record?.title || '')
|
|
66
|
+
slug.set(record?.slug || '')
|
|
67
|
+
description.set(record?.description || '')
|
|
68
|
+
date.set(record?.date || new Date().toISOString().slice(0, 10))
|
|
69
|
+
author.set(record?.author || '')
|
|
70
|
+
poster.set(record?.poster || '')
|
|
71
|
+
draft.set(record?.draft ?? true)
|
|
72
|
+
featured.set(Boolean(record?.featured))
|
|
73
|
+
body.set(record ? '' : loadedBody())
|
|
74
|
+
}
|
|
75
|
+
wasOpen = isOpen
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
effect(() => {
|
|
79
|
+
if (open() && post()?.slug === activeSlug && !bodyLoading())
|
|
80
|
+
body.set(loadedBody())
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
function valid(): boolean {
|
|
84
|
+
return title().trim().length > 0 && !bodyLoading() && !bodyLoadFailed()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function close(): void {
|
|
88
|
+
if (!busy())
|
|
89
|
+
emit('close')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function submit(): void {
|
|
93
|
+
if (!valid())
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
const postDraft: BlogPostDraft = {
|
|
97
|
+
slug: slug().trim() || suggestedSlug(),
|
|
98
|
+
title: title().trim(),
|
|
99
|
+
description: description().trim(),
|
|
100
|
+
date: date(),
|
|
101
|
+
author: author().trim(),
|
|
102
|
+
authorBio: '',
|
|
103
|
+
poster: poster().trim(),
|
|
104
|
+
featured: featured(),
|
|
105
|
+
draft: draft(),
|
|
106
|
+
body: body(),
|
|
107
|
+
}
|
|
108
|
+
emit('submit', postDraft)
|
|
109
|
+
}
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<Modal
|
|
113
|
+
:isOpen="open()"
|
|
114
|
+
:title="dialogTitle()"
|
|
115
|
+
description="Edit the markdown file and its publishing metadata together."
|
|
116
|
+
size="xl"
|
|
117
|
+
:closable="!busy()"
|
|
118
|
+
@close="close"
|
|
119
|
+
>
|
|
120
|
+
<form id="blog-post-editor-form" class="grid gap-4" @submit.prevent="submit">
|
|
121
|
+
<div :if="error()" role="alert" class="flex flex-wrap gap-3 items-center justify-between px-3 py-2 text-red-800 text-sm dark:text-red-200 bg-red-50 dark:bg-red-950/40 border border-red-200 rounded-md dark:border-red-900">
|
|
122
|
+
<span class="flex gap-2 items-start min-w-0">
|
|
123
|
+
<span class="shrink-0 mt-0.5 h-4 w-4 i-hugeicons-alert-02"></span>
|
|
124
|
+
<span>{{ error() }}</span>
|
|
125
|
+
</span>
|
|
126
|
+
<Button :if="bodyLoadFailed()" variant="secondary" size="sm" @click="emit('retry')">Retry loading content</Button>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
130
|
+
<label class="flex flex-col gap-2 sm:col-span-2">
|
|
131
|
+
<span class="font-medium text-sm">Title</span>
|
|
132
|
+
<input x-model="title" name="title" type="text" required autofocus class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
133
|
+
</label>
|
|
134
|
+
|
|
135
|
+
<label class="flex flex-col gap-2 sm:col-span-2">
|
|
136
|
+
<span class="font-medium text-sm">Slug</span>
|
|
137
|
+
<input x-model="slug" name="slug" type="text" :placeholder="suggestedSlug()" class="placeholder-neutral-500 px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
138
|
+
<span class="text-neutral-500 text-xs dark:text-neutral-400">The file name and public URL: /blog/{{ slug() || suggestedSlug() || 'your-post' }}</span>
|
|
139
|
+
</label>
|
|
140
|
+
|
|
141
|
+
<label class="flex flex-col gap-2 sm:col-span-2">
|
|
142
|
+
<span class="font-medium text-sm">Description</span>
|
|
143
|
+
<input x-model="description" name="description" type="text" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
144
|
+
<span class="text-neutral-500 text-xs dark:text-neutral-400">Used as the excerpt, meta description, and RSS summary.</span>
|
|
145
|
+
</label>
|
|
146
|
+
|
|
147
|
+
<label class="flex flex-col gap-2">
|
|
148
|
+
<span class="font-medium text-sm">Date</span>
|
|
149
|
+
<input x-model="date" name="date" type="date" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
150
|
+
</label>
|
|
151
|
+
|
|
152
|
+
<label class="flex flex-col gap-2">
|
|
153
|
+
<span class="font-medium text-sm">Author</span>
|
|
154
|
+
<input x-model="author" name="author" type="text" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
155
|
+
</label>
|
|
156
|
+
|
|
157
|
+
<label class="flex flex-col gap-2 sm:col-span-2">
|
|
158
|
+
<span class="font-medium text-sm">Poster image</span>
|
|
159
|
+
<input x-model="poster" name="poster" type="text" placeholder="/assets/images/example.svg" class="placeholder-neutral-500 px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
160
|
+
</label>
|
|
161
|
+
|
|
162
|
+
<label class="flex flex-col gap-2 sm:col-span-2">
|
|
163
|
+
<span class="flex items-center justify-between gap-3">
|
|
164
|
+
<span class="font-medium text-sm">Content</span>
|
|
165
|
+
<span :if="bodyLoading()" role="status" class="inline-flex gap-1.5 items-center text-neutral-500 text-xs dark:text-neutral-400">
|
|
166
|
+
<span class="h-3.5 w-3.5 animate-spin i-hugeicons-loading-03"></span>
|
|
167
|
+
Loading content
|
|
168
|
+
</span>
|
|
169
|
+
</span>
|
|
170
|
+
<textarea x-model="body" name="body" rows="14" :disabled="bodyLoading() || bodyLoadFailed()" class="px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md disabled:opacity-60 dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 resize-y"></textarea>
|
|
171
|
+
<span class="text-neutral-500 text-xs dark:text-neutral-400">Markdown. {{ wordCount() }} words, about {{ readingTime() }} min read.</span>
|
|
172
|
+
</label>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:gap-6 pt-2 border-neutral-200 border-t dark:border-neutral-800">
|
|
176
|
+
<label class="flex gap-2 items-center text-sm">
|
|
177
|
+
<input x-model="draft" name="draft" type="checkbox" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
|
|
178
|
+
<span>Draft, keep off the public blog</span>
|
|
179
|
+
</label>
|
|
180
|
+
<label class="flex gap-2 items-center text-sm">
|
|
181
|
+
<input x-model="featured" name="featured" type="checkbox" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
|
|
182
|
+
<span>Feature at the top of the listing</span>
|
|
183
|
+
</label>
|
|
184
|
+
</div>
|
|
185
|
+
</form>
|
|
186
|
+
|
|
187
|
+
<template #footer>
|
|
188
|
+
<div class="flex gap-2 justify-end w-full">
|
|
189
|
+
<Button variant="secondary" :disabled="busy()" @click="close">Cancel</Button>
|
|
190
|
+
<Button type="submit" form="blog-post-editor-form" :disabled="!valid()" :loading="busy()">
|
|
191
|
+
{{ actionLabel() }}
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
</template>
|
|
195
|
+
</Modal>
|