Expand 77 hidden lines 78 78 pub ( crate ) access: auth:: Access , 79 79 80 80 pub ( crate ) viewer_id: Option < String > , 81 + 82 + pub ( crate ) bookmarked: bool , 81 83 82 84 pub ( crate ) fork_parent: Option < ( String , String ) > , 83 85 } Expand 69 hidden lines 153 155 Some ( pid) => resolve_fork_parent ( state, pid) . await , 154 156 None => None , 155 157 } ; 158 + let bookmarked = match viewer { 159 + Some ( u) => state 160 + . store 161 + . is_bookmarked ( & u. id, & repo. id) 162 + . await 163 + . unwrap_or ( false ) , 164 + None => false , 165 + } ; 156 166 Ok ( Some ( RepoCtx { 157 167 owner, 158 168 repo, 159 169 access, 160 170 viewer_id: viewer. map ( |u| u. id. clone ( ) ) , 171 + bookmarked, 161 172 fork_parent, 162 173 } ) ) 163 174 } Expand 16 hidden lines 180 191 181 192 # [ serde ( default ) ] 182 193 pub q: Option < String > , 194 + 195 + # [ serde ( default ) ] 196 + pub tab: Option < String > , 183 197 } 184 198 185 199 Expand 117 hidden lines 303 317 let Some ( owner) = state. store. user_by_username ( & owner_name) . await ? else { 304 318 return Err ( AppError :: NotFound ) ; 305 319 } ; 306 - let repos = visible_repos ( & state, & owner, viewer. as_ref ( ) ) . await ?; 307 - let groups = state. store. groups_by_owner ( & owner. id) . await ?; 308 - 320 + let tab = match lq. tab. as_deref ( ) { 321 + Some ( "groups" ) => ProfileTab :: Groups , 322 + Some ( "bookmarks" ) => ProfileTab :: Bookmarks , 323 + _ => ProfileTab :: Repos , 324 + } ; 309 325 let q = lq. q. unwrap_or_default ( ) ; 310 326 let needle = q. trim ( ) . to_lowercase ( ) ; 311 - 312 - 313 - 314 - let top_groups: Vec < _ > = groups. iter ( ) . filter ( |g| !g. path. contains ( '/' ) ) . collect ( ) ; 315 - let entries: Vec < RepoEntry > = repos 316 - . iter ( ) 317 - . filter ( |r| { 318 - let matches = needle. is_empty ( ) || r. path. to_lowercase ( ) . contains ( & needle) ; 319 - matches && ( !needle. is_empty ( ) || !r. path. contains ( '/' ) ) 320 - } ) 321 - . map ( |r| RepoEntry :: owned ( & owner. username, r) ) 322 - . collect ( ) ; 327 + let base = format ! ( "/{}" , owner. username) ; 323 328 324 - let ( jar, chrome) = build_chrome ( & state, jar, viewer. clone ( ) , uri. path ( ) . to_string ( ) ) ; 325 - let body = html ! { 326 - div class="profile-layout" { 327 - aside class="profile-sidebar" { ( profile_sidebar ( & owner) ) } 328 - div class="profile-main" { 329 + let content = match tab { 330 + ProfileTab :: Repos => { 331 + let repos = visible_repos ( & state, & owner, viewer. as_ref ( ) ) . await ?; 332 + 333 + let entries: Vec < RepoEntry > = repos 334 + . iter ( ) 335 + . filter ( |r| { 336 + let matches = needle. is_empty ( ) || r. path. to_lowercase ( ) . contains ( & needle) ; 337 + matches && ( !needle. is_empty ( ) || !r. path. contains ( '/' ) ) 338 + } ) 339 + . map ( |r| RepoEntry :: owned ( & owner. username, r) ) 340 + . collect ( ) ; 341 + html ! { 329 342 form class="search-row" method="get" { 330 343 input type ="search" name="q" value=( q) 331 344 placeholder="Search repositories…" aria-label="Search repositories" ; 332 345 button class="btn btn-primary" type ="submit" { "Search" } 333 346 } 334 - @if !top_groups. is_empty ( ) { 335 - section class="listing" { 336 - h2 { "Groups" } 347 + ( repo_card ( "" , "No repositories." , & entries) ) 348 + } 349 + } 350 + ProfileTab :: Groups => { 351 + let groups = state. store. groups_by_owner ( & owner. id) . await ?; 352 + let top_groups: Vec < _ > = groups. iter ( ) . filter ( |g| !g. path. contains ( '/' ) ) . collect ( ) ; 353 + html ! { 354 + section class="listing" { 355 + @if top_groups. is_empty ( ) { 356 + div class="card" { p class="muted" { "No groups." } } 357 + } @else { 337 358 ul class="repo-list card" { 338 359 @for group in & top_groups { 339 360 li { Expand 7 hidden lines 347 368 } 348 369 } 349 370 } 350 - ( repo_card ( "Repositories" , "No repositories." , & entries) ) 371 + } 372 + } 373 + ProfileTab :: Bookmarks => { 374 + let bookmarked = state. store. bookmarked_repos ( & owner. id) . await ?; 375 + let visible = filter_readable ( & state, bookmarked, viewer. as_ref ( ) ) . await ?; 376 + let entries: Vec < RepoEntry > = visible 377 + . iter ( ) 378 + . map ( |r| { 379 + 380 + RepoEntry :: global ( & r. owner_id, r) 381 + } ) 382 + . collect ( ) ; 383 + 384 + let entries = resolve_bookmark_labels ( & state, & visible, entries) . await ; 385 + html ! { ( repo_card ( "" , "No bookmarks yet." , & entries) ) } 386 + } 387 + } ; 388 + 389 + let ( jar, chrome) = build_chrome ( & state, jar, viewer. clone ( ) , uri. path ( ) . to_string ( ) ) ; 390 + let ptab = |t : ProfileTab , key : & str , label : & str | { 391 + html ! { 392 + a href=( format ! ( "{base}?tab={key}" ) ) aria-current=[ ( tab == t) . then_some ( "page" ) ] { ( label) } 393 + } 394 + } ; 395 + let body = html ! { 396 + div class="profile-layout" { 397 + aside class="profile-sidebar" { ( profile_sidebar ( & owner) ) } 398 + div class="profile-main" { 399 + nav class="tabs profile-tabs" { 400 + ( ptab ( ProfileTab :: Repos , "repos" , "Repositories" ) ) 401 + ( ptab ( ProfileTab :: Groups , "groups" , "Groups" ) ) 402 + ( ptab ( ProfileTab :: Bookmarks , "bookmarks" , "Bookmarks" ) ) 403 + } 404 + ( content) 351 405 } 352 406 } 353 407 } ; 354 408 Ok ( ( jar, page ( & chrome, & owner. username, body) ) . into_response ( ) ) 355 409 } 356 410 411 + 412 + # [ derive ( Debug , Clone , Copy , PartialEq , Eq ) ] 413 + enum ProfileTab { 414 + Repos , 415 + Groups , 416 + Bookmarks , 417 + } 418 + 419 + 420 + async fn filter_readable ( 421 + state : & AppState , 422 + repos : Vec < Repo > , 423 + viewer : Option < & User > , 424 + ) -> AppResult < Vec < Repo > > { 425 + let viewer_id = viewer. map ( |v| auth:: Viewer { 426 + id: v. id. clone ( ) , 427 + is_admin: v. is_admin, 428 + } ) ; 429 + let mut out = Vec :: new ( ) ; 430 + for repo in repos { 431 + let collab = match viewer { 432 + Some ( v) => state 433 + . store 434 + . collaborator_permission ( & repo. id, & v. id) 435 + . await ? 436 + . and_then ( |p| auth:: Permission :: parse ( & p) ) , 437 + None => None , 438 + } ; 439 + let access = auth:: access ( viewer_id. as_ref ( ) , & repo, collab, state. allow_anonymous ( ) ) ; 440 + if access >= auth:: Access :: Read { 441 + out. push ( repo) ; 442 + } 443 + } 444 + Ok ( out) 445 + } 446 + 447 + 448 + async fn resolve_bookmark_labels ( 449 + state : & AppState , 450 + repos : & [ Repo ] , 451 + mut entries : Vec < RepoEntry > , 452 + ) -> Vec < RepoEntry > { 453 + let mut names: std:: collections:: HashMap < String , String > = std:: collections:: HashMap :: new ( ) ; 454 + for ( repo, entry) in repos. iter ( ) . zip ( entries. iter_mut ( ) ) { 455 + let owner = if let Some ( n) = names. get ( & repo. owner_id) { 456 + n. clone ( ) 457 + } else { 458 + let n = state 459 + . store 460 + . user_by_id ( & repo. owner_id) 461 + . await 462 + . ok ( ) 463 + . flatten ( ) 464 + . map_or_else ( || repo. owner_id. clone ( ) , |u| u. username) ; 465 + names. insert ( repo. owner_id. clone ( ) , n. clone ( ) ) ; 466 + n 467 + } ; 468 + entry. href = format ! ( "/{owner}/{}" , repo. path) ; 469 + entry. label = format ! ( "{owner}/{}" , repo. path) ; 470 + } 471 + entries 472 + } 473 + 357 474 358 475 pub async fn dispatch ( 359 476 State ( state) : State < AppState > , Expand 1258 hidden lines 1618 1735 Ok ( repo) 1619 1736 } 1620 1737 1738 + 1739 + 1740 + 1741 + pub async fn bookmark_toggle ( 1742 + State ( state) : State < AppState > , 1743 + MaybeUser ( viewer) : MaybeUser , 1744 + Path ( id) : Path < String > , 1745 + ) -> AppResult < Response > { 1746 + let Some ( user) = viewer. clone ( ) else { 1747 + return Ok ( Redirect :: to ( "/login" ) . into_response ( ) ) ; 1748 + } ; 1749 + let repo = require_readable_repo ( & state, viewer. as_ref ( ) , & id) . await ?; 1750 + if state. store. is_bookmarked ( & user. id, & repo. id) . await ? { 1751 + state. store. remove_bookmark ( & user. id, & repo. id) . await ?; 1752 + } else { 1753 + state. store. add_bookmark ( & user. id, & repo. id) . await ?; 1754 + } 1755 + let owner = state 1756 + . store 1757 + . user_by_id ( & repo. owner_id) 1758 + . await ? 1759 + . map_or_else ( || repo. owner_id. clone ( ) , |u| u. username) ; 1760 + Ok ( Redirect :: to ( & format ! ( "/{owner}/{}" , repo. path) ) . into_response ( ) ) 1761 + } 1762 + 1621 1763 1622 1764 pub async fn fork_confirm ( 1623 1765 State ( state) : State < AppState > , Expand 1233 hidden lines 2857 2999 } 2858 3000 div class="repo-actions" { 2859 3001 @if ctx. viewer_id. is_some ( ) { 3002 + a class=( if ctx. bookmarked { "btn btn-active" } else { "btn" } ) 3003 + href=( format ! ( "/repo-bookmark/{}" , ctx. repo. id) ) { 3004 + ( icon ( Icon :: Bookmark ) ) 3005 + span { ( if ctx. bookmarked { "Bookmarked" } else { "Bookmark" } ) } 3006 + } 2860 3007 a class="btn" href=( format ! ( "/repo-fork/{}" , ctx. repo. id) ) { 2861 3008 ( icon ( Icon :: GitFork ) ) span { "Fork" } 2862 3009 }