ReviewOS

also looking at this

stacks/postline

UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target

#8
Merged glennmichael123 wants to merge feat/ui-polish into main
54 files +2036 -589

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.

app/Actions/Postline/QueueDeleteAction.tsadded+27-0
Changes to app/Actions/Postline/QueueDeleteAction.ts
@@ -0,0 +1,27 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { postQueue } from '../../Services/Social/QueueService'
5
6export default new Action({
7 name: 'Postline Queue Delete',
8 description: 'Remove a draft, scheduled, or failed post from the queue.',
9 method: 'POST',
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 await postQueue.remove(id)
18 return response.json({ ok: true, data: { id } })
19 }
20 catch (error) {
21 return response.json({
22 ok: false,
23 error: error instanceof Error ? error.message : String(error),
24 }, { status: 422 })
25 }
26 },
27})
app/Actions/Postline/QueueListAction.tsadded+22-0
Changes to app/Actions/Postline/QueueListAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { postQueue } from '../../Services/Social/QueueService'
4
5export default new Action({
6 name: 'Postline Queue List',
7 description: 'List recent posts with their per-provider targets.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 const items = await postQueue.list()
13 return response.json({ ok: true, data: { items } })
14 }
15 catch (error) {
16 return response.json({
17 ok: false,
18 error: error instanceof Error ? error.message : String(error),
19 }, { status: 500 })
20 }
21 },
22})
app/Actions/Postline/QueuePublishNowAction.tsadded+28-0
Changes to app/Actions/Postline/QueuePublishNowAction.ts
@@ -0,0 +1,28 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { postQueue } from '../../Services/Social/QueueService'
5
6export default new Action({
7 name: 'Postline Queue Publish Now',
8 description: 'Publish a draft, scheduled, or failed post immediately.',
9 method: 'POST',
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.publishNow(id)
18 const allFailed = data.results.length > 0 && data.results.every(result => !result.ok)
19 return response.json({ ok: !allFailed, data }, { status: allFailed ? 422 : 200 })
20 }
21 catch (error) {
22 return response.json({
23 ok: false,
24 error: error instanceof Error ? error.message : String(error),
25 }, { status: 422 })
26 }
27 },
28})
app/Actions/Postline/QueueSaveAction.tsadded+35-0
Changes to app/Actions/Postline/QueueSaveAction.ts
@@ -0,0 +1,35 @@
1import type { SocialProvider } from '../../Support/Social/types'
2import type { RequestInstance } from '@stacksjs/types'
3import { Action } from '@stacksjs/actions'
4import { response } from '@stacksjs/router'
5import { crosspostProviders } from '../../Services/Social/CrosspostService'
6import { postQueue } from '../../Services/Social/QueueService'
7
8export default new Action({
9 name: 'Postline Queue Save',
10 description: 'Save a post as a draft or schedule it for later publishing.',
11 method: 'POST',
12
13 async handle(request: RequestInstance) {
14 const text = String(request.get('text') || request.get('body') || '').trim()
15
16 const available = new Set<string>(crosspostProviders())
17 const providers = String(request.get('providers') || request.get('provider') || '')
18 .split(',')
19 .map(value => value.trim().toLowerCase())
20 .filter(value => available.has(value)) as SocialProvider[]
21
22 const scheduledAt = String(request.get('scheduled_at') || '').trim() || null
23
24 try {
25 const data = await postQueue.save({ text, providers, scheduledAt })
26 return response.json({ ok: true, data })
27 }
28 catch (error) {
29 return response.json({
30 ok: false,
31 error: error instanceof Error ? error.message : String(error),
32 }, { status: 422 })
33 }
34 },
35})
app/Jobs/PublishScheduledPosts.tsadded+19-0
Changes to app/Jobs/PublishScheduledPosts.ts
@@ -0,0 +1,19 @@
1import { Job } from '@stacksjs/queue'
2import { Every } from '@stacksjs/types'
3import { postQueue } from '../Services/Social/QueueService'
4
5export default new Job({
6 name: 'PublishScheduledPosts',
7 description: 'Publish scheduled Postline posts whose time has come',
8 queue: 'default',
9 tries: 1,
10 backoff: 3,
11 rate: Every.Minute,
12
13 handle: async () => {
14 const { published, failed } = await postQueue.publishDue()
15 if (published || failed)
16 console.log(`[postline] scheduled publish run: ${published} published, ${failed} failed`)
17 return { published, failed }
18 },
19})
app/Scheduler.tsmodified+5-0
Changes to app/Scheduler.ts
@@ -16,6 +16,11 @@ export default function () {
1616 .hourly()
1717 .setTimeZone('America/Los_Angeles')
1818
19 // Publish scheduled Postline posts as they come due
20 schedule
21 .job('PublishScheduledPosts')
22 .everyMinute()
23
1924 // Run a custom action every five minutes
2025 // schedule.action('CleanupTempFiles').everyFiveMinutes()
2126
app/Services/Social/CrosspostService.tsmodified+20-5
Changes to app/Services/Social/CrosspostService.ts
@@ -67,11 +67,7 @@ export class CrosspostService {
6767 .where('uuid', '=', postUuid)
6868 .executeTakeFirstOrThrow()
6969
70 const results: CrosspostTargetResult[] = []
71 for (const provider of selected) {
72 const publisher = publishers[provider]!
73 results.push(await publisher.publishToPost({ id: Number(post.id), body }, content))
74 }
70 const results = await this.publishExisting({ id: Number(post.id), body }, selected, content)
7571
7672 const anyOk = results.some(result => result.ok)
7773 const finishedAt = now()
@@ -83,6 +79,25 @@ export class CrosspostService {
8379
8480 return { postId: Number(post.id), results }
8581 }
82
83 /**
84 * Publish an existing `posts` row to the given providers. Used by the
85 * fresh-publish path above and by the queue when a scheduled or drafted
86 * post is (re)published.
87 */
88 async publishExisting(
89 post: { id: number, body: string },
90 providers: SocialProvider[],
91 content?: PublishContent,
92 ): Promise<CrosspostTargetResult[]> {
93 const results: CrosspostTargetResult[] = []
94 for (const provider of providers) {
95 const publisher = publishers[provider]
96 if (!publisher) continue
97 results.push(await publisher.publishToPost({ id: post.id, body: post.body }, content))
98 }
99 return results
100 }
86101}
87102
88103export const crosspost = new CrosspostService()
app/Services/Social/QueueService.tsadded+235-0
Changes to app/Services/Social/QueueService.ts
@@ -0,0 +1,235 @@
1import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { crosspost, crosspostProviders } from './CrosspostService'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9/** Post statuses a user may still act on (publish now, delete). */
10const ACTIONABLE = new Set(['draft', 'scheduled', 'failed'])
11
12export interface QueueTargetView {
13 provider: SocialProvider
14 status: string
15 remoteUri: string | null
16 failureReason: string | null
17}
18
19export interface QueueItemView {
20 id: number
21 title: string | null
22 body: string
23 status: string
24 scheduledAt: string | null
25 publishedAt: string | null
26 createdAt: string
27 providers: QueueTargetView[]
28}
29
30export interface SaveQueueInput {
31 text: string
32 providers: SocialProvider[]
33 /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */
34 scheduledAt?: string | null
35}
36
37export class QueueService {
38 /**
39 * Persist a post without publishing it. With `scheduledAt` the post is
40 * queued (`scheduled`) and picked up by the PublishScheduledPosts job when
41 * due; without it the post is stored as a `draft`. One placeholder
42 * `post_targets` row per provider records where the post should go — the
43 * real result rows replace them at publish time.
44 */
45 async save(input: SaveQueueInput): Promise<{ postId: number, status: 'draft' | 'scheduled', scheduledAt: string | null }> {
46 const body = input.text.trim()
47 if (!body)
48 throw new Error('Post text is required.')
49
50 const available = new Set<string>(crosspostProviders())
51 const providers = input.providers.filter(provider => available.has(provider))
52 if (providers.length === 0)
53 throw new Error('Select at least one connected provider.')
54
55 const scheduledAt = input.scheduledAt?.trim() || null
56 if (scheduledAt) {
57 if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(scheduledAt))
58 throw new Error('Scheduled time must be a UTC YYYY-MM-DD HH:MM:SS timestamp.')
59 if (scheduledAt <= now())
60 throw new Error('Scheduled time must be in the future.')
61 }
62
63 const status = scheduledAt ? 'scheduled' as const : 'draft' as const
64 const accountId = await ensureAccount()
65 const postUuid = uuid()
66 const createdAt = now()
67
68 await database.insertInto('posts').values({
69 uuid: postUuid,
70 title: body.slice(0, 80),
71 body,
72 status,
73 scheduled_at: scheduledAt,
74 timezone: env.TZ || 'America/Los_Angeles',
75 source: 'composer',
76 account_id: accountId,
77 created_at: createdAt,
78 updated_at: createdAt,
79 }).execute()
80
81 const post = await database
82 .selectFrom('posts')
83 .select(['id'])
84 .where('uuid', '=', postUuid)
85 .executeTakeFirstOrThrow()
86
87 for (const provider of providers) {
88 await database.insertInto('post_targets').values({
89 uuid: uuid(),
90 provider,
91 status,
92 scheduled_at: scheduledAt,
93 post_id: post.id,
94 created_at: createdAt,
95 updated_at: createdAt,
96 }).execute()
97 }
98
99 return { postId: Number(post.id), status, scheduledAt }
100 }
101
102 /** Recent posts with their per-provider targets, newest first. */
103 async list(limit = 50): Promise<QueueItemView[]> {
104 const posts = await database
105 .selectFrom('posts')
106 .selectAll()
107 .where('status', '!=', 'archived')
108 .orderBy('created_at', 'desc')
109 .limit(limit)
110 .execute()
111
112 if (posts.length === 0)
113 return []
114
115 const targets = await database
116 .selectFrom('post_targets')
117 .selectAll()
118 .where('post_id', 'in', posts.map((post: any) => post.id))
119 .execute()
120
121 return posts.map((post: any): QueueItemView => ({
122 id: Number(post.id),
123 title: post.title || null,
124 body: String(post.body || ''),
125 status: String(post.status),
126 scheduledAt: post.scheduled_at || null,
127 publishedAt: post.published_at || null,
128 createdAt: String(post.created_at),
129 providers: targets
130 .filter((target: any) => Number(target.post_id) === Number(post.id))
131 .map((target: any): QueueTargetView => ({
132 provider: target.provider,
133 status: String(target.status),
134 remoteUri: target.remote_uri || null,
135 failureReason: target.failure_reason || null,
136 })),
137 }))
138 }
139
140 /** Delete a draft, scheduled, or failed post along with its targets. */
141 async remove(id: number): Promise<void> {
142 const post = await this.findActionable(id)
143 await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute()
144 await database.deleteFrom('posts').where('id', '=', post.id).execute()
145 }
146
147 /** Publish a draft/scheduled/failed post immediately. */
148 async publishNow(id: number): Promise<{ postId: number, results: CrosspostTargetResult[] }> {
149 const post = await this.findActionable(id)
150
151 const placeholders = await database
152 .selectFrom('post_targets')
153 .selectAll()
154 .where('post_id', '=', post.id)
155 .where('status', 'in', ['draft', 'scheduled', 'failed'])
156 .execute()
157
158 const providers = [...new Set(placeholders.map((target: any) => target.provider))] as SocialProvider[]
159 if (providers.length === 0)
160 throw new Error('This post has no pending targets to publish.')
161
162 await database.updateTable('posts').set({
163 status: 'publishing',
164 updated_at: now(),
165 }).where('id', '=', post.id).execute()
166
167 // The provider services insert fresh result rows; drop the placeholders
168 // so the queue doesn't show both. (Deleted one-by-one — the query
169 // builder mis-compiles `where in` on DELETE statements.)
170 for (const target of placeholders) {
171 await database.deleteFrom('post_targets').where('id', '=', target.id).execute()
172 }
173
174 const results = await crosspost.publishExisting(
175 { id: Number(post.id), body: String(post.body) },
176 providers,
177 )
178
179 const anyOk = results.some(result => result.ok)
180 const finishedAt = now()
181 await database.updateTable('posts').set({
182 status: anyOk ? 'published' : 'failed',
183 ...(anyOk ? { published_at: finishedAt } : {}),
184 updated_at: finishedAt,
185 }).where('id', '=', post.id).execute()
186
187 return { postId: Number(post.id), results }
188 }
189
190 /**
191 * Publish every scheduled post whose time has come. Called by the
192 * PublishScheduledPosts job every minute; failures on one post never block
193 * the rest.
194 */
195 async publishDue(): Promise<{ published: number, failed: number }> {
196 const due = await database
197 .selectFrom('posts')
198 .select(['id'])
199 .where('status', '=', 'scheduled')
200 .where('scheduled_at', '<=', now())
201 .execute()
202
203 let published = 0
204 let failed = 0
205 for (const post of due) {
206 try {
207 const { results } = await this.publishNow(Number(post.id))
208 if (results.some(result => result.ok)) published += 1
209 else failed += 1
210 }
211 catch {
212 failed += 1
213 }
214 }
215
216 return { published, failed }
217 }
218
219 private async findActionable(id: number): Promise<any> {
220 const post = await database
221 .selectFrom('posts')
222 .selectAll()
223 .where('id', '=', id)
224 .executeTakeFirst()
225
226 if (!post)
227 throw new Error('Post not found.')
228 if (!ACTIONABLE.has(String(post.status)))
229 throw new Error('Only draft, scheduled, or failed posts can be changed.')
230
231 return post
232 }
233}
234
235export const postQueue = new QueueService()
resources/views/index.stxmodified+99-2
Changes to resources/views/index.stx
@@ -60,7 +60,11 @@
6060
6161 <footer class="flex gap-3 items-center justify-between p-3 border-t border-zinc-200">
6262 <span class="text-xs text-zinc-500" data-bluesky-count>0 / 300 characters</span>
63 <div class="flex gap-2 items-center">
63 <div class="flex flex-wrap gap-2 items-center justify-end">
64 <input class="hidden px-2 h-9 text-neutral-950 text-xs bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" type="datetime-local" aria-label="Schedule time" data-compose-schedule>
65 <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Schedule post" title="Schedule post" data-schedule-toggle>
66 <span class="h-4 w-4 i-hugeicons-calendar-03" aria-hidden="true"></span>
67 </button>
6468 <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Add post" title="Add post" data-add-post>
6569 <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span>
6670 </button>
@@ -95,6 +99,8 @@
9599 const providerToggles = root.querySelector('[data-provider-toggles]')
96100 const composeImageRow = root.querySelector('[data-compose-image-row]')
97101 const composeImage = root.querySelector('[data-compose-image]')
102 const scheduleToggle = root.querySelector('[data-schedule-toggle]')
103 const scheduleInput = root.querySelector('[data-compose-schedule]')
98104 const postsRoot = root.querySelector('[data-composer-posts]')
99105 const addPostButton = root.querySelector('[data-add-post]')
100106 const previewButton = root.querySelector('[data-preview-post]')
@@ -459,6 +465,75 @@
459465 saveDraft()
460466 }
461467
468 const updatePublishLabel = () => {
469 if (!publishLabel) return
470 if (activeMode === 'draft') publishLabel.textContent = 'Save draft'
471 else if (scheduleInput?.value) publishLabel.textContent = 'Schedule'
472 else publishLabel.textContent = 'Publish'
473 }
474
475 const toUtcTimestamp = (localValue) => {
476 const date = new Date(localValue)
477 if (Number.isNaN(date.getTime())) throw new Error('Pick a valid schedule time.')
478 return date.toISOString().slice(0, 19).replace('T', ' ')
479 }
480
481 // Draft mode and schedule-time both save to the server-side queue
482 // instead of publishing immediately.
483 const saveToQueue = async (scheduleValue) => {
484 const text = activeTextarea()?.value.trim()
485 if (!text) {
486 setStatus('Write something before saving.', 'error')
487 activeTextarea()?.focus()
488 return
489 }
490 const targetProviders = selectedConnected().map(provider => provider.provider)
491 if (targetProviders.length === 0) {
492 setStatus('Select a connected account before saving to the queue.', 'error', { title: 'Not connected' })
493 return
494 }
495 if (activeMode === 'thread' && allPosts().filter(post => textareaFor(post)?.value.trim()).length > 1) {
496 setStatus('Scheduling threads is not supported yet — schedule posts one at a time.', 'error', { title: 'Queue' })
497 return
498 }
499
500 const form = new FormData()
501 form.set('text', text)
502 form.set('providers', targetProviders.join(','))
503 if (scheduleValue) form.set('scheduled_at', toUtcTimestamp(scheduleValue))
504
505 buttons.forEach(button => button.setAttribute('disabled', 'disabled'))
506 try {
507 const response = await fetch('/api/postline/queue', { method: 'POST', body: form })
508 const payload = await response.json()
509 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not save to the queue.')
510
511 const textarea = activeTextarea()
512 if (textarea) textarea.value = ''
513 if (scheduleInput) scheduleInput.value = ''
514 scheduleInput?.classList.add('hidden')
515 renderLinkPreview(activePost, null)
516 updateCount()
517 updatePublishLabel()
518 saveDraft()
519 setStatus(
520 payload.data.status === 'scheduled'
521 ? `Scheduled — check the Queue page to manage it.`
522 : 'Draft saved to the queue.',
523 'success',
524 { title: payload.data.status === 'scheduled' ? 'Scheduled' : 'Draft saved' },
525 )
526 }
527 catch (error) {
528 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Queue' })
529 }
530 finally {
531 buttons.forEach((button) => {
532 if (canPublish) button.removeAttribute('disabled')
533 })
534 }
535 }
536
462537 const setMode = (mode, options = {}) => {
463538 const nextMode = ['single', 'thread', 'draft'].includes(mode) ? mode : 'single'
464539 activeMode = nextMode
@@ -472,6 +547,7 @@
472547 if (nextMode === 'thread' && allPosts().length === 1 && !options.skipThreadPost) {
473548 addPost({ announce: false, focus: false, save: false })
474549 }
550 updatePublishLabel()
475551 if (!options.silent) setStatus(`${nextMode[0].toUpperCase()}${nextMode.slice(1)} mode selected.`, 'success')
476552 if (!options.skipSave) saveDraft()
477553 }
@@ -496,6 +572,11 @@
496572 }
497573
498574 const publish = async () => {
575 if (activeMode === 'draft' || scheduleInput?.value) {
576 await saveToQueue(scheduleInput?.value || null)
577 return
578 }
579
499580 const targetProviders = selectedConnected().map(provider => provider.provider)
500581 if (targetProviders.length === 0) {
501582 setStatus('Select a connected account on the Accounts page before publishing.', 'error', { title: 'Not connected' })
@@ -580,7 +661,7 @@
580661 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Publish failed' })
581662 }
582663 finally {
583 if (publishLabel) publishLabel.textContent = 'Publish'
664 updatePublishLabel()
584665 buttons.forEach(button => {
585666 if (canPublish) button.removeAttribute('disabled')
586667 })
@@ -609,6 +690,22 @@
609690 selectPost(post)
610691 updateCount()
611692 })
693 scheduleToggle?.addEventListener('click', () => {
694 if (!scheduleInput) return
695 const showing = scheduleInput.classList.toggle('hidden') === false
696 if (showing) {
697 // Local-time floor so the picker can't select the past.
698 const soon = new Date(Date.now() + 5 * 60 * 1000)
699 soon.setSeconds(0, 0)
700 scheduleInput.min = new Date(soon.getTime() - soon.getTimezoneOffset() * 60 * 1000).toISOString().slice(0, 16)
701 scheduleInput.focus()
702 }
703 else {
704 scheduleInput.value = ''
705 }
706 updatePublishLabel()
707 })
708 scheduleInput?.addEventListener('input', updatePublishLabel)
612709 addPostButton?.addEventListener('click', addPost)
613710 previewButton?.addEventListener('click', () => {
614711 const text = activeTextarea()?.value.trim()
resources/views/queue.stxmodified+172-50
Changes to resources/views/queue.stx
@@ -3,47 +3,15 @@
33@section('title', 'Postline - Queue')
44@section('description', 'Review scheduled and drafted social posts in Postline.')
55
6<script server>
7const statusStyles = {
8 Ready: 'text-emerald-700 bg-emerald-50 border border-emerald-200',
9 Draft: 'text-amber-800 bg-amber-50 border border-amber-200',
10 Planned: 'text-zinc-600 bg-zinc-100 border border-zinc-200',
11}
12
13const scheduledPosts = [
14 {
15 time: 'Today 3:30 PM',
16 channel: 'Bluesky',
17 status: 'Ready',
18 text: 'Working on a calmer way to plan posts: drafts, timelines, scheduling, and publishing in one native-feeling place.',
19 note: 'Auto-publish when the Bluesky driver token is present.',
20 },
21 {
22 time: 'Tomorrow 9:00 AM',
23 channel: 'Bluesky',
24 status: 'Draft',
25 text: 'Tiny systems compound. A clean publishing queue might be the difference between ideas fading and ideas shipping.',
26 note: 'Needs final copy pass.',
27 },
28 {
29 time: 'Friday 11:15 AM',
30 channel: 'Mastodon',
31 status: 'Planned',
32 text: 'Cross-posting should feel intentional, not like copy/paste chores across six tabs.',
33 note: 'Driver support planned after Bluesky settles.',
34 },
35].map(post => ({ ...post, statusClass: statusStyles[post.status] || statusStyles.Planned }))
36</script>
37
386@section('content')
397 <section class="grid gap-5" data-postline-page="queue" data-testid="queue-page">
40 <!-- Crosswind safelist: status pill classes come from server data. -->
41 <div class="hidden text-amber-800 text-emerald-700 text-zinc-600 bg-amber-50 bg-emerald-50 bg-zinc-100 border-amber-200 border-emerald-200 border-zinc-200" aria-hidden="true"></div>
8 <!-- Crosswind safelist: status pill + action classes are set from JS. -->
9 <div class="hidden text-amber-800 text-blue-700 text-emerald-700 text-red-700 text-zinc-600 bg-amber-50 bg-blue-50 bg-emerald-50 bg-red-50 bg-zinc-100 hover:bg-neutral-800 hover:bg-zinc-50 border-amber-200 border-blue-200 border-emerald-200 border-red-200 border-zinc-200 transition-colors" aria-hidden="true"></div>
4210 <header class="flex gap-4 items-center justify-between min-h-11">
4311 <div>
4412 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Queue</p>
4513 <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Scheduled posts</h1>
46 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">What's drafted, planned, and ready to publish next.</p>
14 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">What's drafted, scheduled, and recently published.</p>
4715 </div>
4816 <a href="/" data-stx-link class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl transition-colors">
4917 <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span>
@@ -53,26 +21,180 @@ const scheduledPosts = [
5321
5422 <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm">
5523 <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200">
56 <strong class="text-[13px]">Upcoming</strong>
57 <span class="text-xs text-zinc-500">{{ scheduledPosts.length }} posts</span>
24 <strong class="text-[13px]">Upcoming &amp; drafts</strong>
25 <span class="text-xs text-zinc-500" data-queue-meta>Loading…</span>
26 </header>
27 <div class="grid gap-2.5 p-3" data-queue-pending>
28 <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-pending-empty>Loading queue…</p>
29 </div>
30 </section>
31
32 <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm">
33 <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200">
34 <strong class="text-[13px]">Recent</strong>
35 <span class="text-xs text-zinc-500" data-queue-recent-meta></span>
5836 </header>
59 <div class="grid gap-2.5 p-3">
60 @foreach (scheduledPosts as post)
61 <article class="grid gap-2.5 p-3.5 bg-white border border-zinc-200 rounded-2xl">
37 <div class="grid gap-2.5 p-3" data-queue-recent>
38 <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-recent-empty>Loading…</p>
39 </div>
40 </section>
41 </section>
42
43 <script>
44 ;(() => {
45 const root = document.querySelector('[data-testid="queue-page"]')
46 if (!root) return
47
48 const pendingList = root.querySelector('[data-queue-pending]')
49 const recentList = root.querySelector('[data-queue-recent]')
50 const meta = root.querySelector('[data-queue-meta]')
51 const recentMeta = root.querySelector('[data-queue-recent-meta]')
52 const toast = window.postlineToast?.show
53
54 const PENDING = new Set(['draft', 'scheduled', 'publishing', 'failed'])
55
56 const pillClass = (status) => {
57 const base = 'px-2 py-1 font-bold text-[11px] rounded-full border whitespace-nowrap'
58 if (status === 'published') return `${base} text-emerald-700 bg-emerald-50 border-emerald-200`
59 if (status === 'scheduled') return `${base} text-blue-700 bg-blue-50 border-blue-200`
60 if (status === 'publishing') return `${base} text-amber-800 bg-amber-50 border-amber-200`
61 if (status === 'failed') return `${base} text-red-700 bg-red-50 border-red-200`
62 return `${base} text-zinc-600 bg-zinc-100 border-zinc-200`
63 }
64
65 const escapeHtml = (value) => String(value || '')
66 .replaceAll('&', '&amp;')
67 .replaceAll('<', '&lt;')
68 .replaceAll('>', '&gt;')
69 .replaceAll('"', '&quot;')
70 .replaceAll("'", '&#039;')
71
72 const providerLabel = (provider) => window.postlineSocial?.providerLabel(provider) || provider
73
74 // Server timestamps are UTC "YYYY-MM-DD HH:MM:SS" — render in local time.
75 const formatTime = (value) => {
76 if (!value) return ''
77 const date = new Date(`${value.replace(' ', 'T')}Z`)
78 if (Number.isNaN(date.getTime())) return value
79 return date.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
80 }
81
82 const bskyUrl = (uri) => {
83 const match = String(uri || '').match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/)
84 return match ? `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}` : null
85 }
86
87 const itemHtml = (item) => {
88 const when = item.status === 'scheduled'
89 ? `Scheduled · ${formatTime(item.scheduledAt)}`
90 : item.status === 'published'
91 ? `Published · ${formatTime(item.publishedAt || item.createdAt)}`
92 : `${item.status[0].toUpperCase()}${item.status.slice(1)} · ${formatTime(item.createdAt)}`
93 const channels = item.providers.map(target => providerLabel(target.provider)).join(', ') || '—'
94 const failure = item.providers.find(target => target.failureReason)?.failureReason
95 const liveUri = item.providers.find(target => target.remoteUri)?.remoteUri
96 const liveUrl = bskyUrl(liveUri)
97 const actionable = ['draft', 'scheduled', 'failed'].includes(item.status)
98
99 return `
100 <article class="grid gap-2.5 p-3.5 bg-white border border-zinc-200 rounded-2xl" data-queue-item="${item.id}">
62101 <header class="flex gap-3 items-center justify-between">
63102 <div class="grid gap-1">
64 <span class="font-bold text-xs">{{ post.time }}</span>
65 <span class="text-xs text-zinc-500">{{ post.channel }}</span>
103 <span class="font-bold text-xs">${escapeHtml(when)}</span>
104 <span class="text-xs text-zinc-500">${escapeHtml(channels)}</span>
66105 </div>
67 <span class="px-2 py-1 font-bold text-[11px] rounded-full {{ post.statusClass }}">{{ post.status }}</span>
106 <span class="${pillClass(item.status)}">${escapeHtml(item.status)}</span>
68107 </header>
69 <p class="m-0 leading-normal text-[13px] text-slate-700">{{ post.text }}</p>
70 <footer class="flex items-center justify-between text-xs text-zinc-500">
71 <span>{{ post.note }}</span>
108 <p class="m-0 leading-normal text-[13px] text-slate-700">${escapeHtml(item.body)}</p>
109 ${failure ? `<p class="m-0 text-red-700 text-xs">${escapeHtml(failure)}</p>` : ''}
110 <footer class="flex gap-2 items-center justify-end">
111 ${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>` : ''}
112 ${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>` : ''}
113 ${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>` : ''}
72114 </footer>
73115 </article>
74 @endforeach
75 </div>
76 </section>
77 </section>
116 `
117 }
118
119 const render = (items) => {
120 const pending = items.filter(item => PENDING.has(item.status))
121 const recent = items.filter(item => !PENDING.has(item.status))
122
123 if (pendingList) {
124 pendingList.innerHTML = pending.length
125 ? pending.map(itemHtml).join('')
126 : '<p class="m-0 p-3 text-[13px] text-zinc-500">Nothing queued yet. Schedule a post from the composer and it will show up here.</p>'
127 }
128 if (recentList) {
129 recentList.innerHTML = recent.length
130 ? recent.slice(0, 10).map(itemHtml).join('')
131 : '<p class="m-0 p-3 text-[13px] text-zinc-500">No published posts yet.</p>'
132 }
133 if (meta) {
134 const scheduled = pending.filter(item => item.status === 'scheduled').length
135 const drafts = pending.filter(item => item.status === 'draft').length
136 meta.textContent = `${scheduled} scheduled · ${drafts} draft${drafts === 1 ? '' : 's'}`
137 }
138 if (recentMeta) recentMeta.textContent = `${recent.length} post${recent.length === 1 ? '' : 's'}`
139 }
140
141 const refresh = async () => {
142 try {
143 const response = await fetch('/api/postline/queue')
144 const payload = await response.json()
145 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load the queue.')
146 render(payload.data.items || [])
147 }
148 catch (error) {
149 const message = error instanceof Error ? error.message : String(error)
150 if (pendingList) pendingList.innerHTML = `<p class="m-0 p-3 text-[13px] text-zinc-500">${escapeHtml(message)}</p>`
151 if (meta) meta.textContent = 'Could not load'
152 if (toast) toast({ title: 'Queue', message, tone: 'error' })
153 }
154 }
155
156 const post = async (path, id) => {
157 const form = new FormData()
158 form.set('id', String(id))
159 const response = await fetch(path, { method: 'POST', body: form })
160 const payload = await response.json()
161 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Request failed.')
162 return payload.data
163 }
164
165 root.addEventListener('click', async (event) => {
166 const target = event.target
167 if (!(target instanceof HTMLElement)) return
168
169 const publishButton = target.closest('[data-queue-publish]')
170 if (publishButton) {
171 publishButton.setAttribute('disabled', 'disabled')
172 try {
173 await post('/api/postline/queue/publish-now', publishButton.dataset.queuePublish)
174 if (toast) toast({ title: 'Published', message: 'Post published.', tone: 'success' })
175 }
176 catch (error) {
177 if (toast) toast({ title: 'Publish failed', message: error instanceof Error ? error.message : String(error), tone: 'error' })
178 }
179 await refresh()
180 return
181 }
182
183 const deleteButton = target.closest('[data-queue-delete]')
184 if (deleteButton) {
185 deleteButton.setAttribute('disabled', 'disabled')
186 try {
187 await post('/api/postline/queue/delete', deleteButton.dataset.queueDelete)
188 if (toast) toast({ title: 'Removed', message: 'Post removed from the queue.', tone: 'success' })
189 }
190 catch (error) {
191 if (toast) toast({ title: 'Delete failed', message: error instanceof Error ? error.message : String(error), tone: 'error' })
192 }
193 await refresh()
194 }
195 })
196
197 void refresh()
198 })()
199 </script>
78200@endsection
routes/api.tsmodified+4-0
Changes to routes/api.ts
@@ -48,4 +48,8 @@ route.group({ prefix: '/postline/threads' }, () => {
4848route.group({ prefix: '/postline' }, () => {
4949 route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf()
5050 route.post('/publish', 'Actions/Postline/CrosspostPublishAction').skipCsrf()
51 route.get('/queue', 'Actions/Postline/QueueListAction').skipCsrf()
52 route.post('/queue', 'Actions/Postline/QueueSaveAction').skipCsrf()
53 route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').skipCsrf()
54 route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').skipCsrf()
5155})