ReviewOS

also looking at this

stacks/postline

Long-form composer mode for the blog target

#10
Merged glennmichael123 wants to merge feat/blog-longform into main
7 files +33 -8
app/Actions/Postline/CrosspostPublishAction.tsmodified+2-1
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -11,6 +11,7 @@ export default new Action({
1111
1212 async handle(request: RequestInstance) {
1313 const text = String(request.get('text') || request.get('body') || '').trim()
14 const title = String(request.get('title') || '').trim() || undefined
1415
1516 const available = new Set<string>(crosspostProviders())
1617 const providers = String(request.get('providers') || request.get('provider') || '')
@@ -54,7 +55,7 @@ export default new Action({
5455 catch {}
5556
5657 try {
57 const content = { external, media: media.length ? media : undefined }
58 const content = { title, external, media: media.length ? media : undefined }
5859 const data = thread.length > 1
5960 ? await crosspost.publishThread(thread, providers, content)
6061 : await crosspost.publish(thread[0] || text, providers, content)
app/Actions/Postline/QueueSaveAction.tsmodified+2-1
Changes to app/Actions/Postline/QueueSaveAction.ts
@@ -20,9 +20,10 @@ export default new Action({
2020 .filter(value => available.has(value)) as SocialProvider[]
2121
2222 const scheduledAt = String(request.get('scheduled_at') || '').trim() || null
23 const title = String(request.get('title') || '').trim() || null
2324
2425 try {
25 const data = await postQueue.save({ text, providers, scheduledAt })
26 const data = await postQueue.save({ text, providers, title, scheduledAt })
2627 return response.json({ ok: true, data })
2728 }
2829 catch (error) {
app/Services/Social/BlogService.tsmodified+8-4
Changes to app/Services/Social/BlogService.ts
@@ -58,7 +58,7 @@ export class BlogService {
5858
5959 async publishToPost(
6060 post: { id: number, body: string },
61 _content?: PublishContent,
61 content?: PublishContent,
6262 ): Promise<CrosspostTargetResult> {
6363 const targetUuid = uuid()
6464 const createdAt = now()
@@ -79,16 +79,20 @@ export class BlogService {
7979 .executeTakeFirstOrThrow()
8080
8181 try {
82 // Explicit title (long-form composer) keeps the whole text as body;
83 // otherwise the first line doubles as the title and is stripped.
84 const explicitTitle = content?.title?.trim()
8285 const lines = post.body.split('\n').map(line => line.trim())
83 const title = (lines.find(Boolean) || 'Untitled post').slice(0, 200)
86 const title = (explicitTitle || lines.find(Boolean) || 'Untitled post').slice(0, 200)
8487 const body = post.body.trim()
8588 const slug = await uniqueSlug(slugify(title))
8689 const accountId = await ensureAccount()
8790 const publishedAt = now()
8891
89 // Body minus the title line — the blog theme already renders the title.
9092 const firstContentIndex = post.body.split('\n').findIndex(line => line.trim())
91 const markdownBody = post.body.split('\n').slice(firstContentIndex + 1).join('\n').trim() || body
93 const markdownBody = explicitTitle
94 ? body
95 : post.body.split('\n').slice(firstContentIndex + 1).join('\n').trim() || body
9296
9397 await mkdir(CONTENT_DIR, { recursive: true })
9498 await Bun.write(join(CONTENT_DIR, `${slug}.md`), [
app/Services/Social/QueueService.tsmodified+3-1
Changes to app/Services/Social/QueueService.ts
@@ -30,6 +30,8 @@ export interface QueueItemView {
3030export interface SaveQueueInput {
3131 text: string
3232 providers: SocialProvider[]
33 /** Explicit title for long-form targets (blog). */
34 title?: string | null
3335 /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */
3436 scheduledAt?: string | null
3537}
@@ -67,7 +69,7 @@ export class QueueService {
6769
6870 await database.insertInto('posts').values({
6971 uuid: postUuid,
70 title: body.slice(0, 80),
72 title: input.title?.trim() || body.slice(0, 80),
7173 body,
7274 status,
7375 scheduled_at: scheduledAt,
app/Support/Social/types.tsmodified+2-0
Changes to app/Support/Social/types.ts
@@ -52,6 +52,8 @@ export interface PublishedPost {
5252
5353/** Optional content attached to a post: a link card and/or media. */
5454export interface PublishContent {
55 /** Explicit post title — used by long-form targets (blog); ignored by social providers. */
56 title?: string
5557 external?: {
5658 uri: string
5759 title: string
resources/assets/scripts/postline-social.tsmodified+3-0
Changes to resources/assets/scripts/postline-social.ts
@@ -22,6 +22,8 @@ export interface CrosspostResultItem {
2222export interface CrosspostInput {
2323 text: string
2424 providers: string[]
25 /** Explicit title for long-form targets (blog). */
26 title?: string
2527 external?: { uri: string, title: string, description?: string }
2628 image?: { url: string, altText?: string }
2729 /** Attached image file — uploaded to providers that accept bytes (Bluesky). */
@@ -59,6 +61,7 @@ export async function publishCrosspost(input: CrosspostInput): Promise<{ postId:
5961 const body = new FormData()
6062 body.set('text', input.text)
6163 body.set('providers', input.providers.join(','))
64 if (input.title) body.set('title', input.title)
6265 if (input.thread && input.thread.length > 1)
6366 body.set('thread', JSON.stringify(input.thread))
6467 if (input.external?.uri && input.external?.title) {
resources/views/index.stxmodified+13-1
Changes to resources/views/index.stx
@@ -41,6 +41,7 @@
4141 <span class="h-4 w-4 text-zinc-500 i-hugeicons-image-01" aria-hidden="true"></span>
4242 <input class="px-3 h-9 w-full text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" type="url" inputmode="url" placeholder="Image URL (required for Instagram)" aria-label="Image URL for Instagram" data-compose-image>
4343 </label>
44 <input class="hidden mb-2 w-full font-bold leading-snug text-[22px] text-neutral-950 placeholder:text-zinc-400 bg-transparent border-0 outline-none" type="text" maxlength="200" placeholder="Post title" aria-label="Blog post title" data-compose-title>
4445 <div class="grid overflow-y-auto content-start gap-4 pr-1 max-h-72" data-composer-posts>
4546 <article class="grid gap-2 pb-3 last:pb-0 border-b border-zinc-100 last:border-b-0" data-composer-post data-active="true">
4647 <header class="flex gap-3 items-center justify-between">
@@ -121,6 +122,8 @@
121122 const attachClear = root.querySelector('[data-attach-clear]')
122123
123124 const attachedFile = () => attachInput?.files?.[0] || undefined
125 const composeTitle = root.querySelector('[data-compose-title]')
126 const composedTitle = () => composeTitle?.value.trim() || undefined
124127
125128 const clearAttachment = () => {
126129 if (attachInput) attachInput.value = ''
@@ -237,6 +240,10 @@
237240 const needsImage = selected.has('instagram') && connectedProviders().some(provider => provider.provider === 'instagram')
238241 composeImageRow?.classList.toggle('hidden', !needsImage)
239242 composeImageRow?.classList.toggle('flex', needsImage)
243 // Long-form title field appears whenever the blog is a target.
244 const wantsTitle = selected.has('blog') && connectedProviders().some(provider => provider.provider === 'blog')
245 composeTitle?.classList.toggle('hidden', !wantsTitle)
246 composeTitle?.classList.toggle('block', wantsTitle)
240247 applyMaxLength()
241248 updateCount()
242249 }
@@ -526,6 +533,8 @@
526533 const form = new FormData()
527534 form.set('text', text)
528535 form.set('providers', targetProviders.join(','))
536 const queueTitle = composedTitle()
537 if (queueTitle) form.set('title', queueTitle)
529538 if (scheduleValue) form.set('scheduled_at', toUtcTimestamp(scheduleValue))
530539
531540 buttons.forEach(button => button.setAttribute('disabled', 'disabled'))
@@ -537,6 +546,7 @@
537546 const textarea = activeTextarea()
538547 if (textarea) textarea.value = ''
539548 if (scheduleInput) scheduleInput.value = ''
549 if (composeTitle) composeTitle.value = ''
540550 scheduleInput?.classList.add('hidden')
541551 renderLinkPreview(activePost, null)
542552 updateCount()
@@ -594,7 +604,7 @@
594604 : undefined
595605 const imageUrl = composeImage?.value.trim()
596606 const image = imageUrl ? { url: imageUrl } : undefined
597 return await social.publishCrosspost({ text, providers: targetProviders, external, image, imageFile: attachedFile() })
607 return await social.publishCrosspost({ text, title: composedTitle(), providers: targetProviders, external, image, imageFile: attachedFile() })
598608 }
599609
600610 const publish = async () => {
@@ -657,6 +667,7 @@
657667 const thread = targets.map(post => textareaFor(post)?.value.trim() || '')
658668 collect(await social.publishCrosspost({
659669 text: thread[0],
670 title: composedTitle(),
660671 thread,
661672 providers: targetProviders,
662673 external,
@@ -684,6 +695,7 @@
684695 updateCount()
685696 saveDraft()
686697 clearAttachment()
698 if (composeTitle) composeTitle.value = ''
687699 window.postlineToast?.dismiss?.('publish')
688700 if (failures.length && successCount === 0) {
689701 setStatus(`Publish failed — ${failures.join('; ')}`, 'error', { title: 'Publish failed' })