ReviewOS

also looking at this

stacks/postline

feat: crosspost to LinkedIn and Instagram

#1
Merged chris wants to merge feat/crosspost-linkedin-instagram into main
31 files +2335 -87

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.

resources/assets/scripts/postline-instagram.tsadded+43-0
Changes to resources/assets/scripts/postline-instagram.ts
@@ -0,0 +1,43 @@
1export interface InstagramStatusPayload {
2 connected: boolean
3 provider: string
4 handle: string | null
5 displayName: string | null
6 canPublish: boolean
7 characterLimit: number
8 requiresMedia: boolean
9 configuredFromEnv: boolean
10 oauthConfigured: boolean
11 authStatus: string
12}
13
14export async function fetchInstagramStatus(): Promise<InstagramStatusPayload> {
15 const response = await fetch('/api/postline/instagram/status')
16 const payload = await response.json()
17 if (!response.ok || !payload.ok) {
18 throw new Error(payload.error || 'Could not load Instagram status.')
19 }
20 return payload.data as InstagramStatusPayload
21}
22
23export function formatInstagramConnectionLabel(status: InstagramStatusPayload): string {
24 if (status.canPublish && status.handle) return `@${status.handle}`
25 if (status.handle) return `@${status.handle} — reconnect`
26 if (status.configuredFromEnv) return 'Configured in .env'
27 if (status.oauthConfigured) return 'Connect with Instagram'
28 return 'Not configured'
29}
30
31declare global {
32 interface Window {
33 postlineInstagram?: {
34 fetchStatus: typeof fetchInstagramStatus
35 formatConnectionLabel: typeof formatInstagramConnectionLabel
36 }
37 }
38}
39
40window.postlineInstagram = {
41 fetchStatus: fetchInstagramStatus,
42 formatConnectionLabel: formatInstagramConnectionLabel,
43}
resources/assets/scripts/postline-linkedin.tsadded+43-0
Changes to resources/assets/scripts/postline-linkedin.ts
@@ -0,0 +1,43 @@
1export interface LinkedInStatusPayload {
2 connected: boolean
3 provider: string
4 handle: string | null
5 displayName: string | null
6 canPublish: boolean
7 characterLimit: number
8 configuredFromEnv: boolean
9 oauthConfigured: boolean
10 authStatus: string
11}
12
13export async function fetchLinkedInStatus(): Promise<LinkedInStatusPayload> {
14 const response = await fetch('/api/postline/linkedin/status')
15 const payload = await response.json()
16 if (!response.ok || !payload.ok) {
17 throw new Error(payload.error || 'Could not load LinkedIn status.')
18 }
19 return payload.data as LinkedInStatusPayload
20}
21
22export function formatLinkedInConnectionLabel(status: LinkedInStatusPayload): string {
23 if (status.canPublish && status.displayName) return status.displayName
24 if (status.canPublish && status.handle) return status.handle
25 if (status.handle) return `${status.handle} — reconnect`
26 if (status.configuredFromEnv) return 'Configured in .env'
27 if (status.oauthConfigured) return 'Connect with LinkedIn'
28 return 'Not configured'
29}
30
31declare global {
32 interface Window {
33 postlineLinkedin?: {
34 fetchStatus: typeof fetchLinkedInStatus
35 formatConnectionLabel: typeof formatLinkedInConnectionLabel
36 }
37 }
38}
39
40window.postlineLinkedin = {
41 fetchStatus: fetchLinkedInStatus,
42 formatConnectionLabel: formatLinkedInConnectionLabel,
43}
resources/assets/scripts/postline-social.tsadded+88-0
Changes to resources/assets/scripts/postline-social.ts
@@ -0,0 +1,88 @@
1export interface ProviderStatus {
2 provider: string
3 connected: boolean
4 canPublish: boolean
5 handle: string | null
6 displayName?: string | null
7 characterLimit: number
8 authStatus: string
9 configuredFromEnv?: boolean
10 oauthConfigured?: boolean
11}
12
13export interface CrosspostResultItem {
14 provider: string
15 ok: boolean
16 url?: string
17 uri?: string
18 error?: string
19 targetId?: number
20}
21
22export interface CrosspostInput {
23 text: string
24 providers: string[]
25 external?: { uri: string, title: string, description?: string }
26 image?: { url: string, altText?: string }
27}
28
29const PROVIDER_LABELS: Record<string, string> = {
30 bluesky: 'Bluesky',
31 linkedin: 'LinkedIn',
32 twitter: 'Twitter/X',
33 mastodon: 'Mastodon',
34 facebook: 'Facebook',
35 instagram: 'Instagram',
36 tiktok: 'TikTok',
37}
38
39export function providerLabel(provider: string): string {
40 return PROVIDER_LABELS[provider] || provider.charAt(0).toUpperCase() + provider.slice(1)
41}
42
43export async function fetchProviders(): Promise<ProviderStatus[]> {
44 const response = await fetch('/api/postline/providers')
45 const payload = await response.json()
46 if (!response.ok || !payload.ok) {
47 throw new Error(payload.error || 'Could not load connected accounts.')
48 }
49 return (payload.data?.providers || []) as ProviderStatus[]
50}
51
52export async function publishCrosspost(input: CrosspostInput): Promise<{ postId: number, results: CrosspostResultItem[] }> {
53 const body = new FormData()
54 body.set('text', input.text)
55 body.set('providers', input.providers.join(','))
56 if (input.external?.uri && input.external?.title) {
57 body.set('external_uri', input.external.uri)
58 body.set('external_title', input.external.title)
59 body.set('external_description', input.external.description || '')
60 }
61 if (input.image?.url) {
62 body.set('image_url', input.image.url)
63 if (input.image.altText) body.set('image_alt', input.image.altText)
64 }
65
66 const response = await fetch('/api/postline/publish', { method: 'POST', body })
67 const payload = await response.json()
68 if (!payload?.data) {
69 throw new Error(payload?.error || 'Publish failed.')
70 }
71 return payload.data as { postId: number, results: CrosspostResultItem[] }
72}
73
74declare global {
75 interface Window {
76 postlineSocial?: {
77 fetchProviders: typeof fetchProviders
78 publishCrosspost: typeof publishCrosspost
79 providerLabel: typeof providerLabel
80 }
81 }
82}
83
84window.postlineSocial = {
85 fetchProviders,
86 publishCrosspost,
87 providerLabel,
88}
resources/views/accounts.stxmodified+222-4
Changes to resources/views/accounts.stx
@@ -9,7 +9,7 @@
99 <div>
1010 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p>
1111 <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing drivers</h1>
12 <p class="mt-1.5 mb-0 text-[13px] text-zinc-600">Connect Bluesky with an app password to publish from Postline.</p>
12 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky and LinkedIn to publish and crosspost from Postline.</p>
1313 </div>
1414 </header>
1515
@@ -40,15 +40,39 @@
4040 <p class="my-1.5 text-[13px] text-slate-700" data-bluesky-channel-handle>Not connected</p>
4141 <span class="text-xs text-zinc-500" data-bluesky-channel-detail>Add an app password to publish and sync your timeline.</span>
4242 </div>
43 <span class="whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold border-zinc-200 text-zinc-500" data-bluesky-channel-status>Setup</span>
43 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-bluesky-channel-status>Setup</span>
44 </article>
45 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="linkedin">
46 <div>
47 <h2 class="my-0 font-bold text-[15px]">LinkedIn</h2>
48 <p class="my-1.5 text-[13px] text-slate-700" data-linkedin-channel-handle>Not connected</p>
49 <span class="text-xs text-zinc-500" data-linkedin-channel-detail>Connect with LinkedIn to crosspost member shares.</span>
50 </div>
51 <div class="flex flex-col gap-2 items-end">
52 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-linkedin-channel-status>Setup</span>
53 <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-[#0a66c2] rounded-xl disabled:opacity-50" type="button" data-linkedin-connect>Connect with LinkedIn</button>
54 <button class="hidden text-xs text-zinc-500 underline" type="button" data-linkedin-connect-env>Use token from .env</button>
55 </div>
56 </article>
57 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="instagram">
58 <div>
59 <h2 class="my-0 font-bold text-[15px]">Instagram</h2>
60 <p class="my-1.5 text-[13px] text-slate-700" data-instagram-channel-handle>Not connected</p>
61 <span class="text-xs text-zinc-500" data-instagram-channel-detail>Connect an Instagram Business account to crosspost. Posts require an image.</span>
62 </div>
63 <div class="flex flex-col gap-2 items-end">
64 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-instagram-channel-status>Setup</span>
65 <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-gradient-to-tr from-amber-500 to-purple-600 via-pink-600 rounded-xl disabled:opacity-50" type="button" data-instagram-connect>Connect with Instagram</button>
66 <button class="hidden text-xs text-zinc-500 underline" type="button" data-instagram-connect-env>Use token from .env</button>
67 </div>
4468 </article>
4569 <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80">
4670 <div>
4771 <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2>
4872 <p class="my-1.5 text-[13px] text-zinc-600">Planned</p>
49 <span class="text-xs text-zinc-500">Driver adapter coming after Bluesky is stable.</span>
73 <span class="text-xs text-zinc-500">Driver adapter coming after Bluesky, LinkedIn, and Instagram are stable.</span>
5074 </div>
51 <span class="whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold border-zinc-200 text-zinc-500">Planned</span>
75 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full">Planned</span>
5276 </article>
5377 </div>
5478 </section>
@@ -136,6 +160,200 @@
136160 }
137161 })
138162
163 // --- LinkedIn ---
164 const linkedinHandle = root.querySelector('[data-linkedin-channel-handle]')
165 const linkedinDetail = root.querySelector('[data-linkedin-channel-detail]')
166 const linkedinStatusPill = root.querySelector('[data-linkedin-channel-status]')
167 const linkedinConnect = root.querySelector('[data-linkedin-connect]')
168 const linkedinConnectEnv = root.querySelector('[data-linkedin-connect-env]')
169
170 const renderLinkedIn = (data) => {
171 const connected = Boolean(data?.canPublish)
172 const label = data?.displayName || data?.handle || 'Not connected'
173 if (linkedinHandle) {
174 linkedinHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected')
175 }
176 if (linkedinDetail) {
177 linkedinDetail.textContent = connected
178 ? 'Ready to crosspost member shares from Postline.'
179 : data?.oauthConfigured
180 ? 'Connect with LinkedIn to crosspost member shares.'
181 : data?.configuredFromEnv
182 ? 'Token found in .env — connect to start crossposting.'
183 : 'Add LinkedIn API keys to .env, then connect.'
184 }
185 if (linkedinStatusPill) {
186 linkedinStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup')
187 linkedinStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${
188 connected
189 ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
190 : data?.handle
191 ? 'border-amber-200 bg-amber-50 text-amber-800'
192 : 'border-zinc-200 text-zinc-500'
193 }`
194 }
195 if (linkedinConnect) {
196 linkedinConnect.textContent = connected ? 'Reconnect LinkedIn' : 'Connect with LinkedIn'
197 if (data && !data.oauthConfigured) linkedinConnect.setAttribute('disabled', 'disabled')
198 else linkedinConnect.removeAttribute('disabled')
199 }
200 if (linkedinConnectEnv) linkedinConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv)
201 }
202
203 const refreshLinkedIn = async () => {
204 try {
205 const data = await window.postlineLinkedin?.fetchStatus()
206 renderLinkedIn(data)
207 }
208 catch (error) {
209 renderLinkedIn(null)
210 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'LinkedIn' })
211 }
212 }
213
214 linkedinConnect?.addEventListener('click', () => {
215 notify('Redirecting to LinkedIn…', 'info', { title: 'LinkedIn' })
216 window.location.href = '/api/postline/linkedin/auth'
217 })
218
219 linkedinConnectEnv?.addEventListener('click', async () => {
220 linkedinConnectEnv.setAttribute('disabled', 'disabled')
221 notify('Connecting LinkedIn from .env…', 'info', { title: 'LinkedIn' })
222 try {
223 const response = await fetch('/api/postline/linkedin/connect', { method: 'POST' })
224 const payload = await response.json()
225 if (!response.ok || !payload.ok) throw new Error(payload.error || 'LinkedIn connect failed.')
226 await refreshLinkedIn()
227 notify('LinkedIn connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
228 }
229 catch (error) {
230 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' })
231 }
232 finally {
233 linkedinConnectEnv.removeAttribute('disabled')
234 }
235 })
236
237 // Surface the OAuth callback result, then strip it from the URL.
238 const params = new URLSearchParams(window.location.search)
239 const linkedinResult = params.get('linkedin')
240 if (linkedinResult === 'connected') {
241 notify('LinkedIn connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
242 }
243 else if (linkedinResult === 'error') {
244 notify(params.get('message') || 'LinkedIn connection failed.', 'error', { title: 'LinkedIn' })
245 }
246 if (linkedinResult) {
247 params.delete('linkedin')
248 params.delete('message')
249 const query = params.toString()
250 window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`)
251 }
252
253 const startLinkedIn = () => void refreshLinkedIn()
254 if (document.readyState === 'loading') {
255 document.addEventListener('DOMContentLoaded', startLinkedIn, { once: true })
256 }
257 else {
258 startLinkedIn()
259 }
260
261 // --- Instagram ---
262 const instagramHandle = root.querySelector('[data-instagram-channel-handle]')
263 const instagramDetail = root.querySelector('[data-instagram-channel-detail]')
264 const instagramStatusPill = root.querySelector('[data-instagram-channel-status]')
265 const instagramConnect = root.querySelector('[data-instagram-connect]')
266 const instagramConnectEnv = root.querySelector('[data-instagram-connect-env]')
267
268 const renderInstagram = (data) => {
269 const connected = Boolean(data?.canPublish)
270 const label = data?.handle ? `@${data.handle}` : 'Not connected'
271 if (instagramHandle) {
272 instagramHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected')
273 }
274 if (instagramDetail) {
275 instagramDetail.textContent = connected
276 ? 'Ready to crosspost. Remember Instagram posts need an image.'
277 : data?.oauthConfigured
278 ? 'Connect an Instagram Business account to crosspost. Posts require an image.'
279 : data?.configuredFromEnv
280 ? 'Token found in .env — connect to start crossposting.'
281 : 'Add Instagram Graph API keys to .env, then connect.'
282 }
283 if (instagramStatusPill) {
284 instagramStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup')
285 instagramStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${
286 connected
287 ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
288 : data?.handle
289 ? 'border-amber-200 bg-amber-50 text-amber-800'
290 : 'border-zinc-200 text-zinc-500'
291 }`
292 }
293 if (instagramConnect) {
294 instagramConnect.textContent = connected ? 'Reconnect Instagram' : 'Connect with Instagram'
295 if (data && !data.oauthConfigured) instagramConnect.setAttribute('disabled', 'disabled')
296 else instagramConnect.removeAttribute('disabled')
297 }
298 if (instagramConnectEnv) instagramConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv)
299 }
300
301 const refreshInstagram = async () => {
302 try {
303 const data = await window.postlineInstagram?.fetchStatus()
304 renderInstagram(data)
305 }
306 catch (error) {
307 renderInstagram(null)
308 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Instagram' })
309 }
310 }
311
312 instagramConnect?.addEventListener('click', () => {
313 notify('Redirecting to Instagram…', 'info', { title: 'Instagram' })
314 window.location.href = '/api/postline/instagram/auth'
315 })
316
317 instagramConnectEnv?.addEventListener('click', async () => {
318 instagramConnectEnv.setAttribute('disabled', 'disabled')
319 notify('Connecting Instagram from .env…', 'info', { title: 'Instagram' })
320 try {
321 const response = await fetch('/api/postline/instagram/connect', { method: 'POST' })
322 const payload = await response.json()
323 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Instagram connect failed.')
324 await refreshInstagram()
325 notify('Instagram connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
326 }
327 catch (error) {
328 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' })
329 }
330 finally {
331 instagramConnectEnv.removeAttribute('disabled')
332 }
333 })
334
335 const instagramResult = params.get('instagram')
336 if (instagramResult === 'connected') {
337 notify('Instagram connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
338 }
339 else if (instagramResult === 'error') {
340 notify(params.get('message') || 'Instagram connection failed.', 'error', { title: 'Instagram' })
341 }
342 if (instagramResult) {
343 params.delete('instagram')
344 params.delete('message')
345 const query = params.toString()
346 window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`)
347 }
348
349 const startInstagram = () => void refreshInstagram()
350 if (document.readyState === 'loading') {
351 document.addEventListener('DOMContentLoaded', startInstagram, { once: true })
352 }
353 else {
354 startInstagram()
355 }
356
139357 void refreshStatus()
140358 })()
141359 </script>
resources/views/index.stxmodified+181-60
Changes to resources/views/index.stx
@@ -10,9 +10,9 @@
1010 <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200">
1111 <div class="flex gap-2 items-center">
1212 <strong class="text-[13px]">New post</strong>
13 <a class="inline-flex gap-1.5 items-center px-2.5 min-h-7 text-xs font-bold border rounded-lg transition-colors border-zinc-200 bg-zinc-50 text-zinc-600 hover:bg-white" href="/accounts" data-stx-link data-bluesky-connection data-no-window-drag>
14 <span class="h-2 w-2 rounded-full bg-zinc-300" data-bluesky-connection-dot aria-hidden="true"></span>
15 <span data-bluesky-connection-label>Checking Bluesky…</span>
13 <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>
14 <span class="h-2 w-2 bg-zinc-300 rounded-full" data-bluesky-connection-dot aria-hidden="true"></span>
15 <span data-bluesky-connection-label>Checking accounts…</span>
1616 </a>
1717 </div>
1818 <div class="grid grid-cols-3 p-0.5 bg-zinc-100 border border-zinc-200 rounded-xl" role="group" aria-label="Post mode">
@@ -23,11 +23,13 @@
2323 </header>
2424
2525 <div class="grid p-3">
26 <div class="flex flex-wrap gap-2 mb-2.5">
27 <div class="flex items-center px-2.5 min-h-8 text-xs font-bold text-zinc-700 bg-zinc-50 border border-zinc-200 rounded-xl">Bluesky</div>
28 <div class="flex items-center px-2.5 min-h-8 text-xs text-zinc-500 bg-zinc-50 border border-zinc-200 rounded-xl">Publish now</div>
29 <div class="flex items-center px-2.5 min-h-8 text-xs text-zinc-500 bg-zinc-50 border border-zinc-200 rounded-xl">Public</div>
26 <div class="flex flex-wrap gap-2 mb-2.5" data-provider-toggles role="group" aria-label="Crosspost targets">
27 <span class="flex items-center px-2.5 min-h-8 text-xs text-zinc-400 bg-zinc-50 border border-zinc-200 rounded-xl" data-provider-empty>Loading accounts…</span>
3028 </div>
29 <label class="hidden gap-2 items-center mb-2.5" data-compose-image-row>
30 <span class="h-4 w-4 text-zinc-500 i-hugeicons-image-01" aria-hidden="true"></span>
31 <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>
32 </label>
3133 <div class="grid overflow-y-auto content-start gap-4 pr-1 max-h-72" data-composer-posts>
3234 <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">
3335 <header class="flex gap-3 items-center justify-between">
@@ -39,7 +41,7 @@
3941 <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span>
4042 </button>
4143 </header>
42 <textarea class="min-h-24 w-full leading-relaxed text-[18px] text-neutral-950 bg-transparent border-0 outline-none resize-none placeholder:text-zinc-400" spellcheck="true" aria-label="Post body 1" maxlength="300" placeholder="What do you want to share on Bluesky?" data-bluesky-text></textarea>
44 <textarea class="min-h-24 w-full leading-relaxed text-[18px] text-neutral-950 placeholder:text-zinc-400 bg-transparent border-0 outline-none resize-none" spellcheck="true" aria-label="Post body 1" maxlength="300" placeholder="What do you want to share?" data-bluesky-text></textarea>
4345 <div class="hidden overflow-hidden bg-zinc-50 border border-zinc-200 rounded-2xl" data-link-preview></div>
4446 </article>
4547 </div>
@@ -79,7 +81,9 @@
7981 const publishLabel = root.querySelector('[data-bluesky-publish-label]')
8082 const buttons = Array.from(root.querySelectorAll('[data-bluesky-publish], [data-bluesky-publish-top]'))
8183 const toast = window.postlineToast?.show
82 const blueskyApi = window.postlineBluesky
84 const providerToggles = root.querySelector('[data-provider-toggles]')
85 const composeImageRow = root.querySelector('[data-compose-image-row]')
86 const composeImage = root.querySelector('[data-compose-image]')
8387 const postsRoot = root.querySelector('[data-composer-posts]')
8488 const addPostButton = root.querySelector('[data-add-post]')
8589 const previewButton = root.querySelector('[data-preview-post]')
@@ -97,6 +101,8 @@
97101 let draftSaveTimer
98102
99103 let canPublish = false
104 let providers = []
105 const selected = new Set()
100106
101107 const setStatus = (message, tone = 'neutral', options = {}) => {
102108 if (toast) {
@@ -112,34 +118,112 @@
112118 console.log(`[postline] ${message}`)
113119 }
114120
115 const applyConnection = (status) => {
116 if (!status) return
117 canPublish = Boolean(status.canPublish)
118 const label = blueskyApi?.formatConnectionLabel(status) || 'Bluesky'
119 if (connectionLabel) connectionLabel.textContent = label
120 connection?.classList.toggle('border-emerald-200', canPublish)
121 connection?.classList.toggle('bg-emerald-50', canPublish)
122 connection?.classList.toggle('text-emerald-800', canPublish)
123 connection?.classList.toggle('border-amber-200', !canPublish && Boolean(status.handle))
124 connection?.classList.toggle('bg-amber-50', !canPublish && Boolean(status.handle))
125 connection?.classList.toggle('text-amber-900', !canPublish && Boolean(status.handle))
126 connectionDot?.classList.toggle('bg-emerald-500', canPublish)
127 connectionDot?.classList.toggle('bg-amber-500', !canPublish && Boolean(status.handle))
128 connectionDot?.classList.toggle('bg-zinc-300', !canPublish && !status.handle)
129 buttons.forEach(button => {
121 const providerLabel = provider => window.postlineSocial?.providerLabel(provider) || provider
122 const connectedProviders = () => providers.filter(provider => provider.canPublish)
123 const selectedConnected = () => connectedProviders().filter(provider => selected.has(provider.provider))
124
125 const effectiveLimit = () => {
126 const limits = selectedConnected().map(provider => provider.characterLimit).filter(Boolean)
127 return limits.length ? Math.min(...limits) : 300
128 }
129
130 const applyMaxLength = () => {
131 const limit = effectiveLimit()
132 allPosts().forEach((post) => {
133 textareaFor(post)?.setAttribute('maxlength', String(limit))
134 })
135 }
136
137 const toggleClass = (isSelected, connected) => {
138 const base = 'inline-flex gap-1.5 items-center px-2.5 min-h-8 text-xs font-bold border rounded-xl transition-colors'
139 if (!connected) return `${base} border-zinc-200 bg-zinc-50 text-zinc-400 cursor-not-allowed`
140 return isSelected
141 ? `${base} border-neutral-950 bg-neutral-950 text-white`
142 : `${base} border-zinc-200 bg-zinc-50 text-zinc-600 hover:bg-white`
143 }
144
145 const createProviderToggle = (status) => {
146 const label = providerLabel(status.provider)
147 const isSelected = selected.has(status.provider) && status.canPublish
148 const button = document.createElement('button')
149 button.type = 'button'
150 button.dataset.providerToggle = status.provider
151 button.setAttribute('aria-pressed', isSelected ? 'true' : 'false')
152 button.className = toggleClass(isSelected, status.canPublish)
153 if (!status.canPublish) {
154 button.setAttribute('disabled', 'disabled')
155 button.title = `Connect ${label} on the Accounts page to crosspost here.`
156 }
157 const dot = isSelected ? 'bg-emerald-400' : 'bg-zinc-300'
158 button.innerHTML = `<span class="h-2 w-2 rounded-full ${dot}" aria-hidden="true"></span><span>${label}</span>`
159 return button
160 }
161
162 const renderProviderToggles = () => {
163 if (!providerToggles) return
164 if (providers.length === 0) {
165 providerToggles.innerHTML = '<span class="flex items-center px-2.5 min-h-8 text-xs text-zinc-400 bg-zinc-50 border border-zinc-200 rounded-xl">No accounts yet — connect one on Accounts.</span>'
166 return
167 }
168 providerToggles.replaceChildren(...providers.map(createProviderToggle))
169 }
170
171 const renderConnectionSummary = () => {
172 const connected = connectedProviders()
173 const ready = connected.length > 0
174 if (connectionLabel) {
175 connectionLabel.textContent = ready
176 ? `${connected.map(provider => providerLabel(provider.provider)).join(', ')} ready`
177 : 'Connect an account'
178 }
179 connection?.classList.toggle('border-emerald-200', ready)
180 connection?.classList.toggle('bg-emerald-50', ready)
181 connection?.classList.toggle('text-emerald-800', ready)
182 connectionDot?.classList.toggle('bg-emerald-500', ready)
183 connectionDot?.classList.toggle('bg-zinc-300', !ready)
184 }
185
186 const updatePublishability = () => {
187 canPublish = selectedConnected().length > 0
188 buttons.forEach((button) => {
130189 if (canPublish) button.removeAttribute('disabled')
131190 else button.setAttribute('disabled', 'disabled')
132191 })
192 // The image field is only meaningful for Instagram, which rejects
193 // text-only posts.
194 const needsImage = selected.has('instagram') && connectedProviders().some(provider => provider.provider === 'instagram')
195 composeImageRow?.classList.toggle('hidden', !needsImage)
196 composeImageRow?.classList.toggle('flex', needsImage)
197 applyMaxLength()
198 updateCount()
199 }
200
201 const applyConnection = (list) => {
202 providers = Array.isArray(list) ? list : []
203 const connectedKeys = connectedProviders().map(provider => provider.provider)
204 // Default to every connected provider; otherwise drop any selected
205 // provider that is no longer connected.
206 if (selected.size === 0) {
207 connectedKeys.forEach(key => selected.add(key))
208 }
209 else {
210 ;[...selected].forEach((key) => {
211 if (!connectedKeys.includes(key)) selected.delete(key)
212 })
213 }
214 renderProviderToggles()
215 renderConnectionSummary()
216 updatePublishability()
133217 }
134218
135219 const refreshConnection = async () => {
136220 try {
137 const status = await blueskyApi?.fetchStatus()
138 applyConnection(status)
221 const list = await window.postlineSocial?.fetchProviders()
222 applyConnection(list || [])
139223 }
140224 catch (error) {
141 applyConnection({ canPublish: false, handle: null, configuredFromEnv: false })
142 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Bluesky' })
225 applyConnection([])
226 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Accounts' })
143227 }
144228 }
145229
@@ -232,11 +316,11 @@
232316 const textarea = activeTextarea()
233317 if (!textarea || !count) return
234318 const length = textarea.value.length
235 const limit = 300
319 const limit = effectiveLimit()
236320 count.textContent = `${length} / ${limit} characters - ${allPosts().length} post${allPosts().length === 1 ? '' : 's'}`
237 count.classList.toggle('text-amber-700', length > 260 && length <= limit)
321 count.classList.toggle('text-amber-700', length > limit - 40 && length <= limit)
238322 count.classList.toggle('text-red-700', length >= limit)
239 count.classList.toggle('text-zinc-500', length <= 260)
323 count.classList.toggle('text-zinc-500', length <= limit - 40)
240324 }
241325
242326 const renderLinkPreview = (post, preview) => {
@@ -380,30 +464,29 @@
380464 if (!options.skipSave) saveDraft()
381465 }
382466
383 const publishOne = async (post) => {
467 const publishOne = async (post, targetProviders) => {
468 const social = window.postlineSocial
469 if (!social) throw new Error('Publishing is still loading. Try again in a moment.')
384470 const textarea = textareaFor(post)
385471 if (!textarea) throw new Error('Missing post body.')
386472 const text = textarea.value.trim()
387473 if (!text) throw new Error('Write something before publishing.')
388 const body = new FormData()
389 body.set('text', text)
390 if (post?.dataset.externalUri && post.dataset.externalTitle) {
391 body.set('external_uri', post.dataset.externalUri)
392 body.set('external_title', post.dataset.externalTitle)
393 body.set('external_description', post.dataset.externalDescription || '')
394 }
395 const response = await fetch('/api/postline/bluesky/publish', {
396 method: 'POST',
397 body,
398 })
399 const payload = await response.json()
400 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Bluesky publish failed.')
401 return payload.data?.post?.url
474 const external = (post?.dataset.externalUri && post.dataset.externalTitle)
475 ? {
476 uri: post.dataset.externalUri,
477 title: post.dataset.externalTitle,
478 description: post.dataset.externalDescription || '',
479 }
480 : undefined
481 const imageUrl = composeImage?.value.trim()
482 const image = imageUrl ? { url: imageUrl } : undefined
483 return await social.publishCrosspost({ text, providers: targetProviders, external, image })
402484 }
403485
404486 const publish = async () => {
405 if (!canPublish) {
406 setStatus('Connect Bluesky on the Accounts page before publishing.', 'error', { title: 'Not connected' })
487 const targetProviders = selectedConnected().map(provider => provider.provider)
488 if (targetProviders.length === 0) {
489 setStatus('Select a connected account on the Accounts page before publishing.', 'error', { title: 'Not connected' })
407490 return
408491 }
409492
@@ -417,18 +500,30 @@
417500 return
418501 }
419502
503 const providerNames = targetProviders.map(providerLabel).join(', ')
420504 buttons.forEach(button => button.setAttribute('disabled', 'disabled'))
421505 if (publishLabel) publishLabel.textContent = 'Publishing…'
422506 setStatus(
423 targets.length > 1 ? `Publishing ${targets.length} posts to Bluesky…` : 'Publishing to Bluesky…',
507 targets.length > 1 ? `Publishing ${targets.length} posts to ${providerNames}…` : `Publishing to ${providerNames}…`,
424508 'info',
425509 { title: 'Publishing', id: 'publish' },
426510 )
427511
428512 try {
429513 let lastUrl
514 const failures = []
515 let successCount = 0
430516 for (const [index, post] of targets.entries()) {
431 lastUrl = await publishOne(post)
517 const data = await publishOne(post, targetProviders)
518 for (const result of data.results || []) {
519 if (result.ok) {
520 successCount += 1
521 if (result.url) lastUrl = result.url
522 }
523 else {
524 failures.push(`${providerLabel(result.provider)}: ${result.error || 'failed'}`)
525 }
526 }
432527 if (targets.length > 1) {
433528 textareaFor(post).value = ''
434529 renderLinkPreview(post, null)
@@ -452,13 +547,21 @@
452547 updateCount()
453548 saveDraft()
454549 window.postlineToast?.dismiss?.('publish')
455 setStatus(
456 targets.length > 1
457 ? `Published ${targets.length} posts to Bluesky.`
458 : 'Your post is live on Bluesky.',
459 'success',
460 { title: 'Published', url: lastUrl },
461 )
550 if (failures.length && successCount === 0) {
551 setStatus(`Publish failed — ${failures.join('; ')}`, 'error', { title: 'Publish failed' })
552 }
553 else if (failures.length) {
554 setStatus(`Published, but some targets failed — ${failures.join('; ')}`, 'info', { title: 'Partially published', url: lastUrl })
555 }
556 else {
557 setStatus(
558 targets.length > 1
559 ? `Published ${targets.length} posts to ${providerNames}.`
560 : `Your post is live on ${providerNames}.`,
561 'success',
562 { title: 'Published', url: lastUrl },
563 )
564 }
462565 }
463566 catch (error) {
464567 window.postlineToast?.dismiss?.('publish')
@@ -505,15 +608,26 @@
505608 try {
506609 await refreshConnection()
507610 setStatus(
508 canPublish ? 'Bluesky is ready to publish.' : 'Add your Bluesky app password on Accounts.',
611 canPublish ? 'Accounts are ready to publish.' : 'Connect an account on the Accounts page.',
509612 canPublish ? 'success' : 'error',
510 { title: 'Bluesky' },
613 { title: 'Accounts' },
511614 )
512615 }
513616 finally {
514617 refreshButton.removeAttribute('disabled')
515618 }
516619 })
620 providerToggles?.addEventListener('click', (event) => {
621 const target = event.target
622 if (!(target instanceof HTMLElement)) return
623 const button = target.closest('[data-provider-toggle]')
624 if (!button || button.hasAttribute('disabled')) return
625 const provider = button.dataset.providerToggle
626 if (selected.has(provider)) selected.delete(provider)
627 else selected.add(provider)
628 renderProviderToggles()
629 updatePublishability()
630 })
517631 searchButton?.addEventListener('click', () => {
518632 const text = activeTextarea()?.value.trim()
519633 setStatus(text ? `Searching local drafts for: ${text.slice(0, 80)}` : 'Write a phrase first, then search local drafts.', text ? 'success' : 'error')
@@ -527,7 +641,14 @@
527641 selectPost(allPosts()[savedActiveIndex] || activePost, { skipSave: true })
528642 updateCount()
529643 void updateLinkPreview(activePost)
530 void refreshConnection()
644 // Wait for the social module (a deferred `type=module` script) before the
645 // first provider fetch so window.postlineSocial is guaranteed to exist.
646 if (document.readyState === 'loading') {
647 document.addEventListener('DOMContentLoaded', () => void refreshConnection(), { once: true })
648 }
649 else {
650 void refreshConnection()
651 }
531652 })()
532653 </script>
533654@endsection
resources/views/layouts/postline.stxmodified+3-0
Changes to resources/views/layouts/postline.stx
@@ -113,6 +113,9 @@
113113 <div id="postline-toast-host" class="pointer-events-none fixed bottom-6 right-6 z-[100] flex w-[calc(100%-2rem)] max-w-[22rem] flex-col-reverse gap-2.5 sm:right-6" aria-live="polite" aria-relevant="additions"></div>
114114 <script type="module" src="/assets/scripts/postline-toast.ts"></script>
115115 <script type="module" src="/assets/scripts/postline-bluesky.ts"></script>
116 <script type="module" src="/assets/scripts/postline-linkedin.ts"></script>
117 <script type="module" src="/assets/scripts/postline-instagram.ts"></script>
118 <script type="module" src="/assets/scripts/postline-social.ts"></script>
116119 <script type="module" src="/assets/scripts/postline-analytics.ts"></script>
117120 <script>
118121 ;(() => {