A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 12 kB View raw
1import { useEffect, useState } from 'react'; 2import type { Agent } from '@atproto/api'; 3import { AuthProvider } from '../lib/auth/AuthProvider'; 4import { useAuth } from '../lib/auth/useAuth'; 5import LoginForm from '../lib/auth/LoginForm'; 6import PublicationForm from './PublicationForm'; 7import { 8 listAllPublications, 9 deletePublication, 10 type Publication, 11 type ForeignPublication, 12} from '../lib/publish/publications'; 13import { 14 listPublicationArticles, 15 unpublish, 16 type MyArticle, 17} from '../lib/publish/publisher'; 18import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 19import AppBar from './AppBar'; 20import ProviderLogo from './ProviderLogo'; 21import { editLinkFor } from '../lib/editor/edit-link'; 22 23type View = 24 | { kind: 'list' } 25 | { kind: 'create' } 26 | { kind: 'manage'; pub: Publication }; 27 28/** The publication dashboard (SP10, step D). Authed, client-only — no `@wordpress/*`. */ 29function DashboardGate() { 30 const { status, agent, handle, did, pdsUrl, error } = useAuth(); 31 const [ data, setData ] = useState< 32 { owned: Publication[]; foreign: ForeignPublication[] } | null 33 >( null ); 34 const [ view, setView ] = useState< View >( { kind: 'list' } ); 35 36 useEffect( () => { 37 if ( ! agent || ! did ) { 38 return; 39 } 40 let cancelled = false; 41 listAllPublications( agent, did ) 42 .then( ( result ) => ! cancelled && setData( result ) ) 43 .catch( () => ! cancelled && setData( { owned: [], foreign: [] } ) ); 44 return () => { 45 cancelled = true; 46 }; 47 }, [ agent, did ] ); 48 49 if ( status === 'loading' ) { 50 return ( 51 <> 52 <AppBar current="dashboard" /> 53 <p className="dash__loading">Connecting to your identity</p> 54 </> 55 ); 56 } 57 58 if ( status !== 'signed-in' || ! agent || ! did ) { 59 return ( 60 <> 61 <AppBar current="dashboard" /> 62 <div className="dash__login"> 63 <LoginForm /> 64 { status === 'error' && error && ( 65 <p className="dash__error" role="alert"> 66 Couldn't start the auth client: { error } 67 </p> 68 ) } 69 </div> 70 </> 71 ); 72 } 73 74 const writerHandle = handle ?? did; 75 76 const reload = () => { 77 listAllPublications( agent, did ) 78 .then( setData ) 79 .catch( () => setData( { owned: [], foreign: [] } ) ); 80 }; 81 82 return ( 83 <> 84 <AppBar current="dashboard" /> 85 <div className="dash"> 86 <header className="dash__bar"> 87 <button 88 type="button" 89 className="dash__crumb" 90 onClick={ () => setView( { kind: 'list' } ) } 91 disabled={ view.kind === 'list' } 92 > 93 Your publications 94 </button> 95 </header> 96 97 { view.kind === 'create' && ( 98 <PublicationForm 99 agent={ agent } 100 did={ did } 101 pdsUrl={ pdsUrl } 102 handle={ writerHandle } 103 onSaved={ () => { 104 reload(); 105 setView( { kind: 'list' } ); 106 } } 107 onCancel={ () => setView( { kind: 'list' } ) } 108 /> 109 ) } 110 111 { view.kind === 'manage' && ( 112 <PublicationManager 113 agent={ agent } 114 did={ did } 115 pdsUrl={ pdsUrl } 116 handle={ writerHandle } 117 pub={ view.pub } 118 onChanged={ ( pub ) => { 119 reload(); 120 setView( { kind: 'manage', pub } ); 121 } } 122 onDeleted={ () => { 123 reload(); 124 setView( { kind: 'list' } ); 125 } } 126 onBack={ () => setView( { kind: 'list' } ) } 127 /> 128 ) } 129 130 { view.kind === 'list' && ( 131 <PublicationList 132 publications={ data ? data.owned : null } 133 foreign={ data?.foreign ?? [] } 134 did={ did } 135 handle={ writerHandle } 136 pdsUrl={ pdsUrl } 137 onNew={ () => setView( { kind: 'create' } ) } 138 onManage={ ( pub ) => setView( { kind: 'manage', pub } ) } 139 /> 140 ) } 141 </div> 142 </> 143 ); 144} 145 146/** A publication's 48px logo, or a letter-fallback square when it has no icon. Shared by the owned and foreign lists. */ 147function PublicationLogo( { 148 icon, 149 name, 150 pdsUrl, 151 did, 152}: { 153 icon?: BlobRefJson; 154 name: string; 155 pdsUrl: string | null; 156 did: string; 157} ) { 158 const logoUrl = icon && pdsUrl ? buildGetBlobUrl( pdsUrl, did, icon.ref.$link ) : null; 159 return logoUrl ? ( 160 <img className="dash__publogo" src={ logoUrl } alt="" width={ 48 } height={ 48 } /> 161 ) : ( 162 <span className="dash__publogo dash__publogo--fallback" aria-hidden="true"> 163 { name.charAt( 0 ).toUpperCase() } 164 </span> 165 ); 166} 167 168function PublicationList( { 169 publications, 170 foreign, 171 did, 172 handle, 173 pdsUrl, 174 onNew, 175 onManage, 176}: { 177 publications: Publication[] | null; 178 foreign: ForeignPublication[]; 179 did: string; 180 handle: string; 181 pdsUrl: string | null; 182 onNew: () => void; 183 onManage: ( pub: Publication ) => void; 184} ) { 185 if ( publications === null ) { 186 return <p className="dash__loading">Loading your publications</p>; 187 } 188 return ( 189 <section className="dash__section"> 190 <div className="dash__section-head"> 191 <h1 className="dash__h1">Your publications</h1> 192 </div> 193 194 { publications.length === 0 ? ( 195 <div className="dash__empty-state"> 196 <p>You don't have any publications yet. Create one to start publishing.</p> 197 <button type="button" className="dash__new" onClick={ onNew }> 198 + New publication 199 </button> 200 </div> 201 ) : ( 202 <> 203 <ul className="dash__pubs"> 204 { publications.map( ( pub ) => { 205 return ( 206 <li className="dash__pub" key={ pub.uri }> 207 <PublicationLogo 208 icon={ pub.icon } 209 name={ pub.name } 210 pdsUrl={ pdsUrl } 211 did={ did } 212 /> 213 <span className="dash__pubtext"> 214 <span className="dash__pubname">{ pub.name }</span> 215 <span className="dash__pubslug">/{ pub.slug }</span> 216 </span> 217 <span className="dash__pubactions"> 218 <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }> 219 View 220 </a> 221 <button type="button" onClick={ () => onManage( pub ) }> 222 Manage 223 </button> 224 </span> 225 </li> 226 ); 227 } ) } 228 </ul> 229 <div className="dash__add"> 230 <button type="button" className="dash__new-quiet" onClick={ onNew }> 231 + New publication 232 </button> 233 </div> 234 </> 235 ) } 236 237 { foreign.length > 0 && ( 238 <div className="dash__foreign"> 239 <h2 className="dash__h2">From other apps</h2> 240 <p className="dash__foreign-note"> 241 These publications live in your repository but are managed by another app. 242 </p> 243 <ul className="dash__pubs"> 244 { foreign.map( ( pub ) => { 245 return ( 246 <li className="dash__pub" key={ pub.uri }> 247 <PublicationLogo 248 icon={ pub.icon } 249 name={ pub.name } 250 pdsUrl={ pdsUrl } 251 did={ did } 252 /> 253 <span className="dash__pubtext"> 254 <span className="dash__pubname">{ pub.name }</span> 255 <span className="dash__pubhost"> 256 <ProviderLogo 257 provider={ pub.provider } 258 className="dash__pubhost-logo" 259 /> 260 { pub.hostname } 261 </span> 262 </span> 263 <span className="dash__pubactions"> 264 <a 265 className="dash__link" 266 href={ pub.url } 267 target="_blank" 268 rel="noopener noreferrer" 269 > 270 Visit ↗ 271 </a> 272 </span> 273 </li> 274 ); 275 } ) } 276 </ul> 277 </div> 278 ) } 279 </section> 280 ); 281} 282 283type Tab = 'posts' | 'settings' | 'delete'; 284 285function PublicationManager( { 286 agent, 287 did, 288 pdsUrl, 289 handle, 290 pub, 291 onChanged, 292 onDeleted, 293 onBack, 294}: { 295 agent: Agent; 296 did: string; 297 pdsUrl: string | null; 298 handle: string; 299 pub: Publication; 300 onChanged: ( pub: Publication ) => void; 301 onDeleted: () => void; 302 onBack: () => void; 303} ) { 304 const [ tab, setTab ] = useState< Tab >( 'posts' ); 305 const [ articles, setArticles ] = useState< MyArticle[] | null >( null ); 306 const [ busy, setBusy ] = useState< string | null >( null ); 307 const [ deleting, setDeleting ] = useState( false ); 308 const [ error, setError ] = useState< string | null >( null ); 309 310 useEffect( () => { 311 let cancelled = false; 312 listPublicationArticles( agent, did, { uri: pub.uri, slug: pub.slug } ) 313 .then( ( list ) => ! cancelled && setArticles( list ) ) 314 .catch( () => ! cancelled && setArticles( [] ) ); 315 return () => { 316 cancelled = true; 317 }; 318 }, [ agent, did, pub.uri, pub.slug ] ); 319 320 async function onUnpublish( article: MyArticle ) { 321 const ok = window.confirm( 322 `Unpublish “${ article.title }”?\n\nThis deletes the article AND its Bluesky post.` 323 ); 324 if ( ! ok ) { 325 return; 326 } 327 setBusy( article.rkey ); 328 setError( null ); 329 try { 330 await unpublish( agent, did, { rkey: article.rkey, bskyPostRef: article.bskyPostRef } ); 331 setArticles( ( prev ) => prev?.filter( ( a ) => a.rkey !== article.rkey ) ?? null ); 332 } catch ( err ) { 333 setError( err instanceof Error ? err.message : String( err ) ); 334 } finally { 335 setBusy( null ); 336 } 337 } 338 339 async function onDelete() { 340 const count = articles?.length ?? 0; 341 const ok = window.confirm( 342 `Delete the publication “${ pub.name }”?\n\n` + 343 `This permanently removes the publication and its ${ count } article${ 344 count === 1 ? '' : 's' 345 } — and each article's companion Bluesky post. This cannot be undone.` 346 ); 347 if ( ! ok ) { 348 return; 349 } 350 setDeleting( true ); 351 setError( null ); 352 try { 353 await deletePublication( agent, did, { uri: pub.uri, rkey: pub.rkey } ); 354 onDeleted(); 355 } catch ( err ) { 356 setError( 357 `${ err instanceof Error ? err.message : String( err ) } some articles or ` + 358 `posts may already have been removed. Reopen this publication and try again.` 359 ); 360 setDeleting( false ); 361 } 362 } 363 364 return ( 365 <section className="dash__section"> 366 <div className="dash__section-head"> 367 <h1 className="dash__h1">{ pub.name }</h1> 368 <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }> 369 View public page 370 </a> 371 </div> 372 373 <nav className="dash__tabs"> 374 <button 375 type="button" 376 className={ tab === 'posts' ? 'is-active' : '' } 377 onClick={ () => setTab( 'posts' ) } 378 > 379 Posts 380 </button> 381 <button 382 type="button" 383 className={ tab === 'settings' ? 'is-active' : '' } 384 onClick={ () => setTab( 'settings' ) } 385 > 386 Settings 387 </button> 388 <button 389 type="button" 390 className={ tab === 'delete' ? 'is-active' : '' } 391 onClick={ () => setTab( 'delete' ) } 392 > 393 Delete 394 </button> 395 </nav> 396 397 { error && ( 398 <p className="dash__error" role="alert"> 399 { error } 400 </p> 401 ) } 402 403 { tab === 'posts' && ( 404 <div className="dash__posts"> 405 { articles === null ? ( 406 <p className="dash__loading">Loading posts…</p> 407 ) : articles.length === 0 ? ( 408 <p className="dash__empty"> 409 No posts yet. <a href="/editor">Open the studio</a> to write one. 410 </p> 411 ) : ( 412 <ul className="dash__postlist"> 413 { articles.map( ( article ) => ( 414 <li className="dash__post" key={ article.rkey }> 415 <a 416 className="dash__postlink" 417 href={ `/@${ handle }/${ pub.slug }/${ article.rkey }` } 418 > 419 { article.title } 420 </a> 421 { article.publishedAt && ( 422 <span className="dash__postdate"> 423 { article.publishedAt.slice( 0, 10 ) } 424 </span> 425 ) } 426 <a 427 className="dash__edit" 428 href={ editLinkFor( article.rkey ) } 429 aria-label={ `Edit ${ article.title }` } 430 > 431 Edit 432 </a> 433 <button 434 type="button" 435 disabled={ busy === article.rkey } 436 onClick={ () => void onUnpublish( article ) } 437 > 438 { busy === article.rkey ? 'Unpublishing…' : 'Unpublish' } 439 </button> 440 </li> 441 ) ) } 442 </ul> 443 ) } 444 </div> 445 ) } 446 447 { tab === 'settings' && ( 448 <PublicationForm 449 agent={ agent } 450 did={ did } 451 pdsUrl={ pdsUrl } 452 handle={ handle } 453 existing={ pub } 454 onSaved={ onChanged } 455 onCancel={ onBack } 456 /> 457 ) } 458 459 { tab === 'delete' && ( 460 <div className="dash__danger"> 461 <h2>Delete this publication</h2> 462 <p> 463 This permanently removes <strong>{ pub.name }</strong>, all of its articles, and 464 each article's companion Bluesky post. This cannot be undone. 465 </p> 466 <button 467 type="button" 468 className="dash__delete" 469 onClick={ () => void onDelete() } 470 disabled={ deleting } 471 > 472 { deleting ? 'Deleting…' : 'Delete publication' } 473 </button> 474 </div> 475 ) } 476 </section> 477 ); 478} 479 480export default function Dashboard() { 481 return ( 482 <AuthProvider> 483 <DashboardGate /> 484 </AuthProvider> 485 ); 486}