⋯ 9 lines Show the 9 lines above this hunk @@ -10,9 +10,9 @@10 10 < header class = "flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200" > 11 11 < div class = "flex gap-2 items-center" > 12 12 < 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 > 16 16 < / a > 17 17 < / div > 18 18 < div class = "grid grid-cols-3 p-0.5 bg-zinc-100 border border-zinc-200 rounded-xl" role = "group" aria - label = "Post mode" > ⋯ 4 lines Show the 4 lines above this hunk @@ -23,11 +23,13 @@23 23 < / header > 24 24 25 25 < 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 > 30 28 < / 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 > 31 33 < div class = "grid overflow-y-auto content-start gap-4 pr-1 max-h-72" data - composer - posts > 32 34 < 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" > 33 35 < header class = "flex gap-3 items-center justify-between" > ⋯ 5 lines Show the 5 lines above this hunk @@ -39,7 +41,7 @@39 41 < span class = "h-3.5 w-3.5 i-hugeicons-cancel-01" aria - hidden = "true" > < / span > 40 42 < / button > 41 43 < / 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 > 43 45 < div class = "hidden overflow-hidden bg-zinc-50 border border-zinc-200 rounded-2xl" data - link - preview > < / div > 44 46 < / article > 45 47 < / div > ⋯ 33 lines Show the 33 lines above this hunk @@ -79,7 +81,9 @@79 81 const publishLabel = root . querySelector ( '[data-bluesky-publish-label]' ) 80 82 const buttons = Array . from ( root . querySelectorAll ( '[data-bluesky-publish], [data-bluesky-publish-top]' ) ) 81 83 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]' ) 83 87 const postsRoot = root . querySelector ( '[data-composer-posts]' ) 84 88 const addPostButton = root . querySelector ( '[data-add-post]' ) 85 89 const previewButton = root . querySelector ( '[data-preview-post]' ) ⋯ 11 lines Show the 11 lines above this hunk @@ -97,6 +101,8 @@97 101 let draftSaveTimer 98 102 99 103 let canPublish = false 104 + let providers = [ ] 105 + const selected = new Set ( ) 100 106 101 107 const setStatus = ( message , tone = 'neutral' , options = { } ) = > { 102 108 if ( toast ) { ⋯ 9 lines Show the 9 lines above this hunk @@ -112,34 +118,112 @@112 118 console . log ( `[postline] ${message}` ) 113 119 } 114 120 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 ) = > { 130 189 if ( canPublish ) button . removeAttribute ( 'disabled' ) 131 190 else button . setAttribute ( 'disabled' , 'disabled' ) 132 191 } ) 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 ( ) 133 217 } 134 218 135 219 const refreshConnection = async ( ) = > { 136 220 try { 137 - const status = await blueskyApi ? . fetchStatus ( ) 138 - applyConnection ( status ) 221 + const list = await window . postlineSocial ? . fetchProviders ( ) 222 + applyConnection ( list | | [ ] ) 139 223 } 140 224 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 ' } ) 143 227 } 144 228 } 145 229 ⋯ 86 lines Show the 86 lines above this hunk @@ -232,11 +316,11 @@232 316 const textarea = activeTextarea ( ) 233 317 if ( ! textarea | | ! count ) return 234 318 const length = textarea . value . length 235 - const limit = 300 319 + const limit = effectiveLimit ( ) 236 320 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 ) 238 322 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 ) 240 324 } 241 325 242 326 const renderLinkPreview = ( post , preview ) = > { ⋯ 137 lines Show the 137 lines above this hunk @@ -380,30 +464,29 @@380 464 if ( ! options . skipSave ) saveDraft ( ) 381 465 } 382 466 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.' ) 384 470 const textarea = textareaFor ( post ) 385 471 if ( ! textarea ) throw new Error ( 'Missing post body.' ) 386 472 const text = textarea . value . trim ( ) 387 473 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 } ) 402 484 } 403 485 404 486 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' } ) 407 490 return 408 491 } 409 492 ⋯ 7 lines Show the 7 lines above this hunk @@ -417,18 +500,30 @@417 500 return 418 501 } 419 502 503 + const providerNames = targetProviders . map ( providerLabel ) . join ( ', ' ) 420 504 buttons . forEach ( button = > button . setAttribute ( 'disabled' , 'disabled' ) ) 421 505 if ( publishLabel ) publishLabel . textContent = 'Publishing…' 422 506 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} … ` , 424 508 'info' , 425 509 { title : 'Publishing' , id : 'publish' } , 426 510 ) 427 511 428 512 try { 429 513 let lastUrl 514 + const failures = [ ] 515 + let successCount = 0 430 516 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 + } 432 527 if ( targets . length > 1 ) { 433 528 textareaFor ( post ) . value = '' 434 529 renderLinkPreview ( post , null ) ⋯ 17 lines Show the 17 lines above this hunk @@ -452,13 +547,21 @@452 547 updateCount ( ) 453 548 saveDraft ( ) 454 549 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 + } 462 565 } 463 566 catch ( error ) { 464 567 window . postlineToast ? . dismiss ? . ( 'publish' ) ⋯ 40 lines Show the 40 lines above this hunk @@ -505,15 +608,26 @@505 608 try { 506 609 await refreshConnection ( ) 507 610 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 .' , 509 612 canPublish ? 'success' : 'error' , 510 - { title : ' Bluesky ' } , 613 + { title : ' Accounts ' } , 511 614 ) 512 615 } 513 616 finally { 514 617 refreshButton . removeAttribute ( 'disabled' ) 515 618 } 516 619 } ) 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 + } ) 517 631 searchButton ? . addEventListener ( 'click' , ( ) = > { 518 632 const text = activeTextarea ( ) ? . value . trim ( ) 519 633 setStatus ( text ? `Searching local drafts for: ${text.slice(0, 80)}` : 'Write a phrase first, then search local drafts.' , text ? 'success' : 'error' ) ⋯ 7 lines Show the 7 lines above this hunk @@ -527,7 +641,14 @@527 641 selectPost ( allPosts ( ) [ savedActiveIndex ] | | activePost , { skipSave : true } ) 528 642 updateCount ( ) 529 643 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 + } 531 652 } ) ( ) 532 653 < / script > 533 654 @ endsection