ReviewOS

also looking at this

stacks/postline

Edit queued and scheduled posts (#21)

#22
Merged glennmichael123 wants to merge feat/edit-queued-posts into main
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.

app/Actions/Postline/QueueGetAction.tsadded+27-0
Changes to app/Actions/Postline/QueueGetAction.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 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})
app/Actions/Postline/QueueUpdateAction.tsadded+60-0
Changes to app/Actions/Postline/QueueUpdateAction.ts
@@ -0,0 +1,60 @@
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'
7import { readUploadedImage } from '../../Support/Social/uploads'
8
9export 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})
app/Services/Social/QueueService.tsmodified+121-0
Changes to app/Services/Social/QueueService.ts
@@ -66,6 +66,23 @@ export interface SaveQueueInput {
6666 image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null
6767}
6868
69/** Edit input. `image`: undefined = keep, null = remove, object = replace. */
70export 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. */
75export 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
6986export class QueueService {
7087 /**
7188 * Persist a post without publishing it. With `scheduledAt` the post is
@@ -194,6 +211,110 @@ export class QueueService {
194211 await database.deleteFrom('posts').where('id', '=', post.id).execute()
195212 }
196213
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
197318 /** Publish a draft/scheduled/failed post immediately. */
198319 async publishNow(id: number): Promise<{ postId: number, results: CrosspostTargetResult[] }> {
199320 const post = await this.findActionable(id)
resources/views/index.stxmodified+148-7
Changes to resources/views/index.stx
@@ -18,9 +18,13 @@
1818 </header>
1919 <div class="grid">
2020 <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>
2125 <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200">
2226 <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>
2428 <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>
2529 <span class="h-2 w-2 bg-zinc-300 rounded-full" data-bluesky-connection-dot aria-hidden="true"></span>
2630 <span data-bluesky-connection-label>Checking accounts…</span>
@@ -150,6 +154,13 @@
150154 let providers = []
151155 const selected = new Set()
152156
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
153164 const setStatus = (message, tone = 'neutral', options = {}) => {
154165 if (toast) {
155166 toast({
@@ -500,11 +511,122 @@
500511
501512 const updatePublishLabel = () => {
502513 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'
504516 else if (scheduleInput?.value) publishLabel.textContent = 'Schedule'
505517 else publishLabel.textContent = 'Publish'
506518 }
507519
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
508630 const toUtcTimestamp = (localValue) => {
509631 const date = new Date(localValue)
510632 if (Number.isNaN(date.getTime())) throw new Error('Pick a valid schedule time.')
@@ -618,6 +740,11 @@
618740 }
619741
620742 const publish = async () => {
743 // Editing a queued post always updates it in place (never publishes).
744 if (editingId) {
745 await updateQueued()
746 return
747 }
621748 if (activeMode === 'draft' || scheduleInput?.value) {
622749 await saveToQueue(scheduleInput?.value || null)
623750 return
@@ -789,7 +916,11 @@
789916 attachChip?.classList.remove('hidden')
790917 attachChip?.classList.add('inline-flex')
791918 })
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 })
793924 addPostButton?.addEventListener('click', addPost)
794925 previewButton?.addEventListener('click', () => {
795926 const text = activeTextarea()?.value.trim()
@@ -827,8 +958,13 @@
827958 })
828959 modeButtons.forEach(button => button.addEventListener('click', () => setMode(button.dataset.composerMode || 'single')))
829960 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 }
832968 setMode(activeMode, { silent: true, skipSave: true, skipThreadPost: true })
833969 renamePosts()
834970 selectPost(allPosts()[savedActiveIndex] || activePost, { skipSave: true })
@@ -836,11 +972,16 @@
836972 void updateLinkPreview(activePost)
837973 // Wait for the social module (a deferred `type=module` script) before the
838974 // 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 }
839980 if (document.readyState === 'loading') {
840 document.addEventListener('DOMContentLoaded', () => void refreshConnection(), { once: true })
981 document.addEventListener('DOMContentLoaded', () => void start(), { once: true })
841982 }
842983 else {
843 void refreshConnection()
984 void start()
844985 }
845986 })()
846987 </script>
resources/views/queue.stxmodified+1-0
Changes to resources/views/queue.stx
@@ -112,6 +112,7 @@
112112 ${failure ? `<p class="m-0 text-red-700 text-xs">${escapeHtml(failure)}</p>` : ''}
113113 <footer class="flex gap-2 items-center justify-end">
114114 ${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>` : ''}
115116 ${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>` : ''}
116117 ${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>` : ''}
117118 </footer>
routes/api.tsmodified+2-0
Changes to routes/api.ts
@@ -58,7 +58,9 @@ route.group({ prefix: '/postline' }, () => {
5858 route.get('/analytics', 'Actions/Postline/AnalyticsAction').middleware('auth').skipCsrf()
5959 route.get('/blog', 'Actions/Postline/BlogListAction').middleware('auth').skipCsrf()
6060 route.get('/queue', 'Actions/Postline/QueueListAction').middleware('auth').skipCsrf()
61 route.get('/queue/item', 'Actions/Postline/QueueGetAction').middleware('auth').skipCsrf()
6162 route.post('/queue', 'Actions/Postline/QueueSaveAction').middleware('auth').skipCsrf()
63 route.post('/queue/update', 'Actions/Postline/QueueUpdateAction').middleware('auth').skipCsrf()
6264 route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').middleware('auth').skipCsrf()
6365 route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').middleware('auth').skipCsrf()
6466 route.post('/metrics/sync', 'Actions/Postline/MetricsSyncAction').middleware('auth').skipCsrf()