also looking at this
UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target
#8
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.
| @@ -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 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 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { postQueue } from '../../Services/Social/QueueService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,28 @@ | ||
| 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 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 | }) | |
| @@ -0,0 +1,35 @@ | ||
| 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 | ||
| 8 | export 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 | }) | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { Job } from '@stacksjs/queue' | |
| 2 | import { Every } from '@stacksjs/types' | |
| 3 | import { postQueue } from '../Services/Social/QueueService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -16,6 +16,11 @@ export default function () { | ||
| 16 | 16 | .hourly() |
| 17 | 17 | .setTimeZone('America/Los_Angeles') |
| 18 | 18 | |
| 19 | // Publish scheduled Postline posts as they come due | |
| 20 | schedule | |
| 21 | .job('PublishScheduledPosts') | |
| 22 | .everyMinute() | |
| 23 | ||
| 19 | 24 | // Run a custom action every five minutes |
| 20 | 25 | // schedule.action('CleanupTempFiles').everyFiveMinutes() |
| 21 | 26 | |
| @@ -67,11 +67,7 @@ export class CrosspostService { | ||
| 67 | 67 | .where('uuid', '=', postUuid) |
| 68 | 68 | .executeTakeFirstOrThrow() |
| 69 | 69 | |
| 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) | |
| 75 | 71 | |
| 76 | 72 | const anyOk = results.some(result => result.ok) |
| 77 | 73 | const finishedAt = now() |
| @@ -83,6 +79,25 @@ export class CrosspostService { | ||
| 83 | 79 | |
| 84 | 80 | return { postId: Number(post.id), results } |
| 85 | 81 | } |
| 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 | } | |
| 86 | 101 | } |
| 87 | 102 | |
| 88 | 103 | export const crosspost = new CrosspostService() |
| @@ -0,0 +1,235 @@ | ||
| 1 | import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { crosspost, crosspostProviders } from './CrosspostService' | |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 6 | ||
| 7 | const database = db as any | |
| 8 | ||
| 9 | /** Post statuses a user may still act on (publish now, delete). */ | |
| 10 | const ACTIONABLE = new Set(['draft', 'scheduled', 'failed']) | |
| 11 | ||
| 12 | export interface QueueTargetView { | |
| 13 | provider: SocialProvider | |
| 14 | status: string | |
| 15 | remoteUri: string | null | |
| 16 | failureReason: string | null | |
| 17 | } | |
| 18 | ||
| 19 | export 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 | ||
| 30 | export 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 | ||
| 37 | export 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 | ||
| 235 | export const postQueue = new QueueService() | |
| @@ -60,7 +60,11 @@ | ||
| 60 | 60 | |
| 61 | 61 | <footer class="flex gap-3 items-center justify-between p-3 border-t border-zinc-200"> |
| 62 | 62 | <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> | |
| 64 | 68 | <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> |
| 65 | 69 | <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span> |
| 66 | 70 | </button> |
| @@ -95,6 +99,8 @@ | ||
| 95 | 99 | const providerToggles = root.querySelector('[data-provider-toggles]') |
| 96 | 100 | const composeImageRow = root.querySelector('[data-compose-image-row]') |
| 97 | 101 | const composeImage = root.querySelector('[data-compose-image]') |
| 102 | const scheduleToggle = root.querySelector('[data-schedule-toggle]') | |
| 103 | const scheduleInput = root.querySelector('[data-compose-schedule]') | |
| 98 | 104 | const postsRoot = root.querySelector('[data-composer-posts]') |
| 99 | 105 | const addPostButton = root.querySelector('[data-add-post]') |
| 100 | 106 | const previewButton = root.querySelector('[data-preview-post]') |
| @@ -459,6 +465,75 @@ | ||
| 459 | 465 | saveDraft() |
| 460 | 466 | } |
| 461 | 467 | |
| 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 | ||
| 462 | 537 | const setMode = (mode, options = {}) => { |
| 463 | 538 | const nextMode = ['single', 'thread', 'draft'].includes(mode) ? mode : 'single' |
| 464 | 539 | activeMode = nextMode |
| @@ -472,6 +547,7 @@ | ||
| 472 | 547 | if (nextMode === 'thread' && allPosts().length === 1 && !options.skipThreadPost) { |
| 473 | 548 | addPost({ announce: false, focus: false, save: false }) |
| 474 | 549 | } |
| 550 | updatePublishLabel() | |
| 475 | 551 | if (!options.silent) setStatus(`${nextMode[0].toUpperCase()}${nextMode.slice(1)} mode selected.`, 'success') |
| 476 | 552 | if (!options.skipSave) saveDraft() |
| 477 | 553 | } |
| @@ -496,6 +572,11 @@ | ||
| 496 | 572 | } |
| 497 | 573 | |
| 498 | 574 | const publish = async () => { |
| 575 | if (activeMode === 'draft' || scheduleInput?.value) { | |
| 576 | await saveToQueue(scheduleInput?.value || null) | |
| 577 | return | |
| 578 | } | |
| 579 | ||
| 499 | 580 | const targetProviders = selectedConnected().map(provider => provider.provider) |
| 500 | 581 | if (targetProviders.length === 0) { |
| 501 | 582 | setStatus('Select a connected account on the Accounts page before publishing.', 'error', { title: 'Not connected' }) |
| @@ -580,7 +661,7 @@ | ||
| 580 | 661 | setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Publish failed' }) |
| 581 | 662 | } |
| 582 | 663 | finally { |
| 583 | if (publishLabel) publishLabel.textContent = 'Publish' | |
| 664 | updatePublishLabel() | |
| 584 | 665 | buttons.forEach(button => { |
| 585 | 666 | if (canPublish) button.removeAttribute('disabled') |
| 586 | 667 | }) |
| @@ -609,6 +690,22 @@ | ||
| 609 | 690 | selectPost(post) |
| 610 | 691 | updateCount() |
| 611 | 692 | }) |
| 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) | |
| 612 | 709 | addPostButton?.addEventListener('click', addPost) |
| 613 | 710 | previewButton?.addEventListener('click', () => { |
| 614 | 711 | const text = activeTextarea()?.value.trim() |
| @@ -3,47 +3,15 @@ | ||
| 3 | 3 | @section('title', 'Postline - Queue') |
| 4 | 4 | @section('description', 'Review scheduled and drafted social posts in Postline.') |
| 5 | 5 | |
| 6 | <script server> | |
| 7 | const 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 | ||
| 13 | const 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 | ||
| 38 | 6 | @section('content') |
| 39 | 7 | <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> | |
| 42 | 10 | <header class="flex gap-4 items-center justify-between min-h-11"> |
| 43 | 11 | <div> |
| 44 | 12 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Queue</p> |
| 45 | 13 | <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> | |
| 47 | 15 | </div> |
| 48 | 16 | <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"> |
| 49 | 17 | <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span> |
| @@ -53,26 +21,180 @@ const scheduledPosts = [ | ||
| 53 | 21 | |
| 54 | 22 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> |
| 55 | 23 | <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 & 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> | |
| 58 | 36 | </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('&', '&') | |
| 67 | .replaceAll('<', '<') | |
| 68 | .replaceAll('>', '>') | |
| 69 | .replaceAll('"', '"') | |
| 70 | .replaceAll("'", ''') | |
| 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}"> | |
| 62 | 101 | <header class="flex gap-3 items-center justify-between"> |
| 63 | 102 | <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> | |
| 66 | 105 | </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> | |
| 68 | 107 | </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>` : ''} | |
| 72 | 114 | </footer> |
| 73 | 115 | </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> | |
| 78 | 200 | @endsection |
| @@ -48,4 +48,8 @@ route.group({ prefix: '/postline/threads' }, () => { | ||
| 48 | 48 | route.group({ prefix: '/postline' }, () => { |
| 49 | 49 | route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf() |
| 50 | 50 | 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() | |
| 51 | 55 | }) |