also looking at this
Edit queued and scheduled posts (#21)
#22
6 files
+359
-7
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,27 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { postQueue } from '../../Services/Social/QueueService' | |
| 5 | ||
| 6 | export default new Action({ | |
| 7 | name: 'Postline Queue Get', | |
| 8 | description: 'Fetch one queued post\'s editable state for the composer.', | |
| 9 | method: 'GET', | |
| 10 | ||
| 11 | async handle(request: RequestInstance) { | |
| 12 | const id = Number(request.get('id') || 0) | |
| 13 | if (!id) | |
| 14 | return response.json({ ok: false, error: 'Post id is required.' }, { status: 422 }) | |
| 15 | ||
| 16 | try { | |
| 17 | const data = await postQueue.get(id) | |
| 18 | return response.json({ ok: true, data }) | |
| 19 | } | |
| 20 | catch (error) { | |
| 21 | return response.json({ | |
| 22 | ok: false, | |
| 23 | error: error instanceof Error ? error.message : String(error), | |
| 24 | }, { status: 404 }) | |
| 25 | } | |
| 26 | }, | |
| 27 | }) | |
| @@ -0,0 +1,60 @@ | ||
| 1 | import type { SocialProvider } from '../../Support/Social/types' | |
| 2 | import type { RequestInstance } from '@stacksjs/types' | |
| 3 | import { Action } from '@stacksjs/actions' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { crosspostProviders } from '../../Services/Social/CrosspostService' | |
| 6 | import { postQueue } from '../../Services/Social/QueueService' | |
| 7 | import { readUploadedImage } from '../../Support/Social/uploads' | |
| 8 | ||
| 9 | export default new Action({ | |
| 10 | name: 'Postline Queue Update', | |
| 11 | description: 'Edit a draft, scheduled, or failed post in place.', | |
| 12 | method: 'POST', | |
| 13 | ||
| 14 | async handle(request: RequestInstance) { | |
| 15 | const id = Number(request.get('id') || 0) | |
| 16 | if (!id) | |
| 17 | return response.json({ ok: false, error: 'Post id is required.' }, { status: 422 }) | |
| 18 | ||
| 19 | const text = String(request.get('text') || request.get('body') || '').trim() | |
| 20 | const available = new Set<string>(crosspostProviders()) | |
| 21 | const providers = String(request.get('providers') || '') | |
| 22 | .split(',') | |
| 23 | .map(value => value.trim().toLowerCase()) | |
| 24 | .filter(value => available.has(value)) as SocialProvider[] | |
| 25 | ||
| 26 | const scheduledAt = String(request.get('scheduled_at') || '').trim() || null | |
| 27 | const title = String(request.get('title') || '').trim() || null | |
| 28 | ||
| 29 | const externalUri = String(request.get('external_uri') || '').trim() | |
| 30 | const externalTitle = String(request.get('external_title') || '').trim() | |
| 31 | const external = externalUri && externalTitle | |
| 32 | ? { uri: externalUri, title: externalTitle, description: String(request.get('external_description') || '').trim() || undefined } | |
| 33 | : null | |
| 34 | ||
| 35 | // Image tri-state: a new upload/url replaces; `remove_image=1` clears; | |
| 36 | // otherwise (undefined) the stored image is kept. | |
| 37 | const imageAlt = String(request.get('image_alt') || '').trim() || undefined | |
| 38 | const uploaded = await readUploadedImage(request.file?.('image')) | |
| 39 | const imageUrl = String(request.get('image_url') || '').trim() | |
| 40 | const removeImage = String(request.get('remove_image') || '') === '1' | |
| 41 | const image = uploaded | |
| 42 | ? { ...uploaded, altText: imageAlt } | |
| 43 | : imageUrl | |
| 44 | ? { url: imageUrl, altText: imageAlt } | |
| 45 | : removeImage | |
| 46 | ? null | |
| 47 | : undefined | |
| 48 | ||
| 49 | try { | |
| 50 | const data = await postQueue.update(id, { text, providers, title, scheduledAt, external, image }) | |
| 51 | return response.json({ ok: true, data }) | |
| 52 | } | |
| 53 | catch (error) { | |
| 54 | return response.json({ | |
| 55 | ok: false, | |
| 56 | error: error instanceof Error ? error.message : String(error), | |
| 57 | }, { status: 422 }) | |
| 58 | } | |
| 59 | }, | |
| 60 | }) | |
| @@ -66,6 +66,23 @@ export interface SaveQueueInput { | ||
| 66 | 66 | image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /** Edit input. `image`: undefined = keep, null = remove, object = replace. */ | |
| 70 | export interface UpdateQueueInput extends Omit<SaveQueueInput, 'image'> { | |
| 71 | image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null | |
| 72 | } | |
| 73 | ||
| 74 | /** Editable snapshot of a queued post for prefilling the composer. */ | |
| 75 | export interface QueueEditView { | |
| 76 | id: number | |
| 77 | text: string | |
| 78 | title: string | null | |
| 79 | status: string | |
| 80 | scheduledAt: string | null | |
| 81 | providers: SocialProvider[] | |
| 82 | external: { uri: string, title: string, description?: string } | null | |
| 83 | image: { kind: 'file' | 'url', url: string | null } | null | |
| 84 | } | |
| 85 | ||
| 69 | 86 | export class QueueService { |
| 70 | 87 | /** |
| 71 | 88 | * Persist a post without publishing it. With `scheduledAt` the post is |
| @@ -194,6 +211,110 @@ export class QueueService { | ||
| 194 | 211 | await database.deleteFrom('posts').where('id', '=', post.id).execute() |
| 195 | 212 | } |
| 196 | 213 | |
| 214 | /** | |
| 215 | * Full editable state for one draft/scheduled/failed post, for prefilling | |
| 216 | * the composer. Media is described (present/kind), not returned as bytes — | |
| 217 | * the composer keeps the existing image unless the user replaces/removes it. | |
| 218 | */ | |
| 219 | async get(id: number): Promise<QueueEditView> { | |
| 220 | const post = await this.findActionable(id) | |
| 221 | const stored = parseStoredContent(post.content) | |
| 222 | const targets = await database | |
| 223 | .selectFrom('post_targets') | |
| 224 | .select(['provider']) | |
| 225 | .where('post_id', '=', post.id) | |
| 226 | .execute() | |
| 227 | ||
| 228 | const media = stored?.media?.[0] | |
| 229 | return { | |
| 230 | id: Number(post.id), | |
| 231 | text: String(post.body || ''), | |
| 232 | title: stored?.title || null, | |
| 233 | status: String(post.status), | |
| 234 | scheduledAt: post.scheduled_at || null, | |
| 235 | providers: [...new Set(targets.map((t: any) => t.provider))] as SocialProvider[], | |
| 236 | external: stored?.external || null, | |
| 237 | image: media ? { kind: media.path ? 'file' : 'url', url: media.url || null } : null, | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * Update a draft/scheduled/failed post in place: text, title, providers, | |
| 243 | * schedule, link card, and image. `image` semantics — undefined keeps the | |
| 244 | * existing media, null removes it, an object replaces it. Placeholder | |
| 245 | * targets are rebuilt to match the new provider set. | |
| 246 | */ | |
| 247 | async update(id: number, input: UpdateQueueInput): Promise<{ postId: number, status: 'draft' | 'scheduled' }> { | |
| 248 | const post = await this.findActionable(id) | |
| 249 | ||
| 250 | const body = input.text.trim() | |
| 251 | if (!body) | |
| 252 | throw new Error('Post text is required.') | |
| 253 | ||
| 254 | const available = new Set<string>(crosspostProviders()) | |
| 255 | const providers = input.providers.filter(provider => available.has(provider)) | |
| 256 | if (providers.length === 0) | |
| 257 | throw new Error('Select at least one connected provider.') | |
| 258 | ||
| 259 | const scheduledAt = input.scheduledAt?.trim() || null | |
| 260 | if (scheduledAt) { | |
| 261 | if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(scheduledAt)) | |
| 262 | throw new Error('Scheduled time must be a UTC YYYY-MM-DD HH:MM:SS timestamp.') | |
| 263 | if (scheduledAt <= now()) | |
| 264 | throw new Error('Scheduled time must be in the future.') | |
| 265 | } | |
| 266 | ||
| 267 | const status = scheduledAt ? 'scheduled' as const : 'draft' as const | |
| 268 | const existing = parseStoredContent(post.content) | |
| 269 | const stored: StoredContent = {} | |
| 270 | if (input.title?.trim()) stored.title = input.title.trim() | |
| 271 | if (input.external?.uri && input.external.title) stored.external = input.external | |
| 272 | ||
| 273 | // Media: replace / remove / keep. Replacing or removing drops the old file. | |
| 274 | if (input.image === null || input.image?.bytes?.length || input.image?.url) { | |
| 275 | await this.removeStoredMedia(post) | |
| 276 | } | |
| 277 | if (input.image?.bytes?.length) { | |
| 278 | const extension = (input.image.mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg' | |
| 279 | const filename = `${post.uuid}-${uuid().slice(0, 8)}.${extension}` | |
| 280 | await mkdir(MEDIA_DIR, { recursive: true }) | |
| 281 | await Bun.write(join(MEDIA_DIR, filename), input.image.bytes) | |
| 282 | stored.media = [{ path: filename, mimeType: input.image.mimeType, altText: input.image.altText }] | |
| 283 | } | |
| 284 | else if (input.image?.url) { | |
| 285 | stored.media = [{ url: input.image.url, altText: input.image.altText }] | |
| 286 | } | |
| 287 | else if (input.image === undefined && existing?.media?.length) { | |
| 288 | stored.media = existing.media | |
| 289 | } | |
| 290 | ||
| 291 | const updatedAt = now() | |
| 292 | await database.updateTable('posts').set({ | |
| 293 | title: input.title?.trim() || body.slice(0, 80), | |
| 294 | body, | |
| 295 | status, | |
| 296 | content: Object.keys(stored).length ? JSON.stringify(stored) : null, | |
| 297 | scheduled_at: scheduledAt, | |
| 298 | updated_at: updatedAt, | |
| 299 | }).where('id', '=', post.id).execute() | |
| 300 | ||
| 301 | // Rebuild placeholder targets to match the new provider set. | |
| 302 | await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute() | |
| 303 | for (const provider of providers) { | |
| 304 | await database.insertInto('post_targets').values({ | |
| 305 | uuid: uuid(), | |
| 306 | provider, | |
| 307 | status, | |
| 308 | scheduled_at: scheduledAt, | |
| 309 | post_id: post.id, | |
| 310 | created_at: updatedAt, | |
| 311 | updated_at: updatedAt, | |
| 312 | }).execute() | |
| 313 | } | |
| 314 | ||
| 315 | return { postId: Number(post.id), status } | |
| 316 | } | |
| 317 | ||
| 197 | 318 | /** Publish a draft/scheduled/failed post immediately. */ |
| 198 | 319 | async publishNow(id: number): Promise<{ postId: number, results: CrosspostTargetResult[] }> { |
| 199 | 320 | const post = await this.findActionable(id) |
| @@ -18,9 +18,13 @@ | ||
| 18 | 18 | </header> |
| 19 | 19 | <div class="grid"> |
| 20 | 20 | <section class="grid content-start grid-rows-[auto_auto_auto] self-start bg-white border border-zinc-200 rounded-2xl shadow-sm" aria-label="Post composer"> |
| 21 | <div class="hidden flex-wrap gap-3 items-center justify-between px-3 py-2 text-xs bg-blue-50 border-b border-blue-200" data-edit-banner> | |
| 22 | <span class="font-bold text-blue-800">Editing a queued post</span> | |
| 23 | <a class="inline-flex items-center px-2.5 min-h-6 font-bold text-blue-800 bg-white hover:bg-blue-100 border border-blue-200 rounded-lg transition-colors" href="/" data-stx-link>Cancel</a> | |
| 24 | </div> | |
| 21 | 25 | <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200"> |
| 22 | 26 | <div class="flex gap-2 items-center"> |
| 23 | <strong class="text-[13px]">New post</strong> | |
| 27 | <strong class="text-[13px]" data-composer-heading>New post</strong> | |
| 24 | 28 | <a class="inline-flex gap-1.5 items-center px-2.5 min-h-7 font-bold text-xs text-zinc-600 bg-zinc-50 hover:bg-white border border-zinc-200 rounded-lg transition-colors" href="/accounts" data-stx-link data-bluesky-connection data-no-window-drag> |
| 25 | 29 | <span class="h-2 w-2 bg-zinc-300 rounded-full" data-bluesky-connection-dot aria-hidden="true"></span> |
| 26 | 30 | <span data-bluesky-connection-label>Checking accounts…</span> |
| @@ -150,6 +154,13 @@ | ||
| 150 | 154 | let providers = [] |
| 151 | 155 | const selected = new Set() |
| 152 | 156 | |
| 157 | // Edit mode: /?edit=<id> loads a queued post into the composer. | |
| 158 | const editBanner = root.querySelector('[data-edit-banner]') | |
| 159 | const composerHeading = root.querySelector('[data-composer-heading]') | |
| 160 | let editingId = null | |
| 161 | let editHadImage = false | |
| 162 | let editRemoveImage = false | |
| 163 | ||
| 153 | 164 | const setStatus = (message, tone = 'neutral', options = {}) => { |
| 154 | 165 | if (toast) { |
| 155 | 166 | toast({ |
| @@ -500,11 +511,122 @@ | ||
| 500 | 511 | |
| 501 | 512 | const updatePublishLabel = () => { |
| 502 | 513 | if (!publishLabel) return |
| 503 | if (activeMode === 'draft') publishLabel.textContent = 'Save draft' | |
| 514 | if (editingId) publishLabel.textContent = scheduleInput?.value ? 'Update schedule' : 'Update post' | |
| 515 | else if (activeMode === 'draft') publishLabel.textContent = 'Save draft' | |
| 504 | 516 | else if (scheduleInput?.value) publishLabel.textContent = 'Schedule' |
| 505 | 517 | else publishLabel.textContent = 'Publish' |
| 506 | 518 | } |
| 507 | 519 | |
| 520 | // Convert a UTC "YYYY-MM-DD HH:MM:SS" to the datetime-local input value. | |
| 521 | const utcToLocalInput = (utc) => { | |
| 522 | const date = new Date(`${utc.replace(' ', 'T')}Z`) | |
| 523 | if (Number.isNaN(date.getTime())) return '' | |
| 524 | return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16) | |
| 525 | } | |
| 526 | ||
| 527 | const enterEditMode = (item) => { | |
| 528 | editingId = item.id | |
| 529 | editBanner?.classList.remove('hidden') | |
| 530 | editBanner?.classList.add('flex') | |
| 531 | if (composerHeading) composerHeading.textContent = `Editing post #${item.id}` | |
| 532 | ||
| 533 | // Text (single-post edit — threads edit one segment). | |
| 534 | const textarea = activeTextarea() | |
| 535 | if (textarea) textarea.value = item.text || '' | |
| 536 | if (composeTitle) composeTitle.value = item.title || '' | |
| 537 | ||
| 538 | // Providers: select exactly the post's set (intersected with connected). | |
| 539 | selected.clear() | |
| 540 | for (const provider of item.providers || []) selected.add(provider) | |
| 541 | renderProviderToggles() | |
| 542 | updatePublishability() | |
| 543 | ||
| 544 | // Schedule. | |
| 545 | if (item.scheduledAt && scheduleInput) { | |
| 546 | scheduleInput.value = utcToLocalInput(item.scheduledAt) | |
| 547 | scheduleInput.classList.remove('hidden') | |
| 548 | } | |
| 549 | ||
| 550 | // Link card. | |
| 551 | if (item.external?.uri) { | |
| 552 | activePost.dataset.externalUri = item.external.uri | |
| 553 | activePost.dataset.externalTitle = item.external.title || '' | |
| 554 | activePost.dataset.externalDescription = item.external.description || '' | |
| 555 | } | |
| 556 | ||
| 557 | // Existing image → show a "kept" chip; attaching a new file replaces, | |
| 558 | // clearing it marks removal. | |
| 559 | editHadImage = Boolean(item.image) | |
| 560 | editRemoveImage = false | |
| 561 | if (item.image) { | |
| 562 | if (attachName) attachName.textContent = item.image.url ? 'Current image (URL)' : 'Current image' | |
| 563 | attachChip?.classList.remove('hidden') | |
| 564 | attachChip?.classList.add('inline-flex') | |
| 565 | } | |
| 566 | ||
| 567 | updateCount() | |
| 568 | void updateLinkPreview(activePost) | |
| 569 | updatePublishLabel() | |
| 570 | } | |
| 571 | ||
| 572 | const loadEditPost = async (id) => { | |
| 573 | try { | |
| 574 | const response = await fetch(`/api/postline/queue/item?id=${encodeURIComponent(id)}`) | |
| 575 | const payload = await response.json() | |
| 576 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load the post.') | |
| 577 | enterEditMode(payload.data) | |
| 578 | } | |
| 579 | catch (error) { | |
| 580 | setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Edit' }) | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | const updateQueued = async () => { | |
| 585 | const text = activeTextarea()?.value.trim() | |
| 586 | if (!text) { | |
| 587 | setStatus('Write something before updating.', 'error') | |
| 588 | activeTextarea()?.focus() | |
| 589 | return | |
| 590 | } | |
| 591 | const targetProviders = selectedConnected().map(provider => provider.provider) | |
| 592 | if (targetProviders.length === 0) { | |
| 593 | setStatus('Select a connected account.', 'error', { title: 'Not connected' }) | |
| 594 | return | |
| 595 | } | |
| 596 | ||
| 597 | const form = new FormData() | |
| 598 | form.set('id', String(editingId)) | |
| 599 | form.set('text', text) | |
| 600 | form.set('providers', targetProviders.join(',')) | |
| 601 | const editTitle = composedTitle() | |
| 602 | if (editTitle) form.set('title', editTitle) | |
| 603 | if (scheduleInput?.value) form.set('scheduled_at', toUtcTimestamp(scheduleInput.value)) | |
| 604 | if (activePost?.dataset.externalUri && activePost.dataset.externalTitle) { | |
| 605 | form.set('external_uri', activePost.dataset.externalUri) | |
| 606 | form.set('external_title', activePost.dataset.externalTitle) | |
| 607 | form.set('external_description', activePost.dataset.externalDescription || '') | |
| 608 | } | |
| 609 | const editImageUrl = composeImage?.value.trim() | |
| 610 | if (editImageUrl) form.set('image_url', editImageUrl) | |
| 611 | const editFile = attachedFile() | |
| 612 | if (editFile) form.set('image', editFile, editFile.name) | |
| 613 | else if (editRemoveImage) form.set('remove_image', '1') | |
| 614 | ||
| 615 | buttons.forEach(button => button.setAttribute('disabled', 'disabled')) | |
| 616 | try { | |
| 617 | const response = await fetch('/api/postline/queue/update', { method: 'POST', body: form }) | |
| 618 | const payload = await response.json() | |
| 619 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not update the post.') | |
| 620 | setStatus('Post updated.', 'success', { title: 'Updated' }) | |
| 621 | // Back to the queue to see the change. | |
| 622 | window.location.assign('/queue') | |
| 623 | } | |
| 624 | catch (error) { | |
| 625 | setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Update failed' }) | |
| 626 | buttons.forEach((button) => { if (canPublish) button.removeAttribute('disabled') }) | |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 508 | 630 | const toUtcTimestamp = (localValue) => { |
| 509 | 631 | const date = new Date(localValue) |
| 510 | 632 | if (Number.isNaN(date.getTime())) throw new Error('Pick a valid schedule time.') |
| @@ -618,6 +740,11 @@ | ||
| 618 | 740 | } |
| 619 | 741 | |
| 620 | 742 | const publish = async () => { |
| 743 | // Editing a queued post always updates it in place (never publishes). | |
| 744 | if (editingId) { | |
| 745 | await updateQueued() | |
| 746 | return | |
| 747 | } | |
| 621 | 748 | if (activeMode === 'draft' || scheduleInput?.value) { |
| 622 | 749 | await saveToQueue(scheduleInput?.value || null) |
| 623 | 750 | return |
| @@ -789,7 +916,11 @@ | ||
| 789 | 916 | attachChip?.classList.remove('hidden') |
| 790 | 917 | attachChip?.classList.add('inline-flex') |
| 791 | 918 | }) |
| 792 | attachClear?.addEventListener('click', clearAttachment) | |
| 919 | attachClear?.addEventListener('click', () => { | |
| 920 | // In edit mode, clearing an existing image marks it for removal. | |
| 921 | if (editingId && editHadImage && !attachedFile()) editRemoveImage = true | |
| 922 | clearAttachment() | |
| 923 | }) | |
| 793 | 924 | addPostButton?.addEventListener('click', addPost) |
| 794 | 925 | previewButton?.addEventListener('click', () => { |
| 795 | 926 | const text = activeTextarea()?.value.trim() |
| @@ -827,8 +958,13 @@ | ||
| 827 | 958 | }) |
| 828 | 959 | modeButtons.forEach(button => button.addEventListener('click', () => setMode(button.dataset.composerMode || 'single'))) |
| 829 | 960 | buttons.forEach(button => button.addEventListener('click', publish)) |
| 830 | window.addEventListener('beforeunload', saveDraft) | |
| 831 | restoreDraft() | |
| 961 | // Editing a queued post — don't restore a local draft over it, and don't | |
| 962 | // save the edited content back into the draft. | |
| 963 | const editParam = new URLSearchParams(window.location.search).get('edit') | |
| 964 | if (!editParam) { | |
| 965 | window.addEventListener('beforeunload', saveDraft) | |
| 966 | restoreDraft() | |
| 967 | } | |
| 832 | 968 | setMode(activeMode, { silent: true, skipSave: true, skipThreadPost: true }) |
| 833 | 969 | renamePosts() |
| 834 | 970 | selectPost(allPosts()[savedActiveIndex] || activePost, { skipSave: true }) |
| @@ -836,11 +972,16 @@ | ||
| 836 | 972 | void updateLinkPreview(activePost) |
| 837 | 973 | // Wait for the social module (a deferred `type=module` script) before the |
| 838 | 974 | // first provider fetch so window.postlineSocial is guaranteed to exist. |
| 975 | // Load the edit post only after connection so provider toggles resolve. | |
| 976 | const start = async () => { | |
| 977 | await refreshConnection() | |
| 978 | if (editParam) await loadEditPost(editParam) | |
| 979 | } | |
| 839 | 980 | if (document.readyState === 'loading') { |
| 840 | document.addEventListener('DOMContentLoaded', () => void refreshConnection(), { once: true }) | |
| 981 | document.addEventListener('DOMContentLoaded', () => void start(), { once: true }) | |
| 841 | 982 | } |
| 842 | 983 | else { |
| 843 | void refreshConnection() | |
| 984 | void start() | |
| 844 | 985 | } |
| 845 | 986 | })() |
| 846 | 987 | </script> |
| @@ -112,6 +112,7 @@ | ||
| 112 | 112 | ${failure ? `<p class="m-0 text-red-700 text-xs">${escapeHtml(failure)}</p>` : ''} |
| 113 | 113 | <footer class="flex gap-2 items-center justify-end"> |
| 114 | 114 | ${liveUrl ? `<a class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-neutral-950 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" href="${escapeHtml(liveUrl)}" target="_blank" rel="noopener noreferrer">View</a>` : ''} |
| 115 | ${actionable ? `<a class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-neutral-950 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" href="/?edit=${item.id}" data-stx-link>Edit</a>` : ''} | |
| 115 | 116 | ${actionable ? `<button class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-white text-xs bg-neutral-950 hover:bg-neutral-800 rounded-lg transition-colors" type="button" data-queue-publish="${item.id}">Publish now</button>` : ''} |
| 116 | 117 | ${actionable ? `<button class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-red-700 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" data-queue-delete="${item.id}">Delete</button>` : ''} |
| 117 | 118 | </footer> |
| @@ -58,7 +58,9 @@ route.group({ prefix: '/postline' }, () => { | ||
| 58 | 58 | route.get('/analytics', 'Actions/Postline/AnalyticsAction').middleware('auth').skipCsrf() |
| 59 | 59 | route.get('/blog', 'Actions/Postline/BlogListAction').middleware('auth').skipCsrf() |
| 60 | 60 | route.get('/queue', 'Actions/Postline/QueueListAction').middleware('auth').skipCsrf() |
| 61 | route.get('/queue/item', 'Actions/Postline/QueueGetAction').middleware('auth').skipCsrf() | |
| 61 | 62 | route.post('/queue', 'Actions/Postline/QueueSaveAction').middleware('auth').skipCsrf() |
| 63 | route.post('/queue/update', 'Actions/Postline/QueueUpdateAction').middleware('auth').skipCsrf() | |
| 62 | 64 | route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').middleware('auth').skipCsrf() |
| 63 | 65 | route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').middleware('auth').skipCsrf() |
| 64 | 66 | route.post('/metrics/sync', 'Actions/Postline/MetricsSyncAction').middleware('auth').skipCsrf() |