View.elm (23452B)
1 module View exposing (view) 2 3 import Browser 4 import Dict exposing (Dict) 5 import Html exposing (Html, a, button, div, h1, h2, img, input, li, p, span, strong, text, ul) 6 import Html.Attributes as Attr exposing (class, disabled, href, placeholder, src, style, target, type_, value) 7 import Html.Events exposing (on, onClick, onInput) 8 import Json.Decode as D 9 import Set exposing (Set) 10 import Time 11 import Types exposing (Listen, Model, Msg(..), Period(..), SimilarState(..), SimilarTrack, Stats, StatsEntry, Tab(..), UserInfo) 12 import Url 13 14 15 16 -- VIEW 17 18 19 view : Model -> Browser.Document Msg 20 view model = 21 { title = "scrobbler" 22 , body = 23 [ div [ class "container" ] 24 [ h1 [ class "site-header", onClick ToggleTabs ] 25 [ img [ src "/cover.webp", Attr.alt "scrobbler" ] [] ] 26 , if model.tabsVisible then 27 div [ class "tabs" ] 28 [ a 29 [ class 30 ("tab-btn" 31 ++ (if model.activeTab == ListensTab then 32 " active" 33 34 else 35 "" 36 ) 37 ) 38 , href 39 (if model.userSlug == "" then 40 "/" 41 42 else 43 "/u/" ++ model.userSlug 44 ) 45 ] 46 [ text "listens" ] 47 , button 48 [ class 49 ("tab-btn" 50 ++ (if model.activeTab == StatsTab then 51 " active" 52 53 else 54 "" 55 ) 56 ) 57 , onClick (SwitchTab StatsTab) 58 ] 59 [ text "stats" ] 60 , button 61 [ class 62 ("tab-btn" 63 ++ (if model.activeTab == AboutTab then 64 " active" 65 66 else 67 "" 68 ) 69 ) 70 , onClick (SwitchTab AboutTab) 71 ] 72 [ text "about" ] 73 ] 74 75 else 76 text "" 77 , case model.activeTab of 78 ListensTab -> 79 div [] 80 [ div [ class "search-bar" ] 81 [ input 82 [ type_ "text" 83 , class "search-input" 84 , placeholder "search by track, artist, album or label" 85 , value model.searchInput 86 , onInput UpdateSearchInput 87 , onEnter SubmitSearch 88 ] 89 [] 90 , button [ class "search-btn", onClick SubmitSearch ] [ text "search" ] 91 , case model.activeSearch of 92 Just _ -> 93 button [ class "filter-clear", onClick ClearSearch ] [ text "✕ clear" ] 94 95 Nothing -> 96 text "" 97 ] 98 , case model.activeFilter of 99 Nothing -> 100 text "" 101 102 Just { field, value } -> 103 div [ class "filter-banner" ] 104 [ span [ class "filter-label" ] 105 [ text (field ++ ": ") 106 , strong [] [ text value ] 107 ] 108 , button [ class "filter-clear", onClick ClearFilter ] 109 [ text "✕ clear" ] 110 ] 111 , renderContent model 112 , div [ class "pagination" ] 113 [ button 114 [ class "page-btn" 115 , disabled (model.offset == 0 || model.loading) 116 , onClick PrevPage 117 ] 118 [ text "Previous" ] 119 , div [ class "page-indicator" ] 120 [ text ("Page " ++ String.fromInt (model.offset // model.limit + 1)) ] 121 , button 122 [ class "page-btn" 123 , disabled (List.length model.listens < model.limit || model.loading) 124 , onClick NextPage 125 ] 126 [ text "Next" ] 127 ] 128 ] 129 130 StatsTab -> 131 div [] 132 [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError 133 , renderStatsView model.expandedSections model.loadedSections model.stats 134 ] 135 136 AboutTab -> 137 renderAboutView model.userSlug model.allUsers 138 ] 139 ] 140 } 141 142 143 renderContent : Model -> Html Msg 144 renderContent model = 145 if model.loading && List.isEmpty model.listens then 146 ul [] [ li [ class "loading" ] [ text "⏳" ] ] 147 148 else 149 case model.error of 150 Just err -> 151 ul [] [ li [ class "error" ] [ text err ] ] 152 153 Nothing -> 154 div [ class "tracks-with-similar" ] 155 (List.indexedMap 156 (\idx listen -> 157 let 158 artist = 159 Maybe.withDefault "" listen.artistName 160 161 trackName = 162 Maybe.withDefault "" listen.trackName 163 164 key = 165 String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ trackName 166 in 167 div [ class "track-with-similar-container" ] 168 [ ul [ class "track-item" ] 169 [ renderListen model.userSlug model.currentTime model.failedCovers model.hoveredCover model.similarStates idx listen ] 170 , renderSimilarPanel model.similarStates key 171 ] 172 ) 173 model.listens 174 ) 175 176 177 renderListen : String -> Maybe Time.Posix -> Set String -> Maybe Int -> Dict String SimilarState -> Int -> Listen -> Html Msg 178 renderListen userSlug currentTime failedCovers hoveredCover similarStates idx listen = 179 let 180 artist = 181 Maybe.withDefault "" listen.artistName 182 183 trackName = 184 Maybe.withDefault "" listen.trackName 185 186 release = 187 Maybe.withDefault "" listen.releaseName 188 189 key = 190 String.fromInt idx ++ "\t" ++ artist ++ "\t" ++ trackName 191 192 mbid = 193 case listen.caaReleaseMbid of 194 Just m -> 195 Just m 196 197 Nothing -> 198 listen.releaseMbid 199 200 coverUrl = 201 "/cover?user=" 202 ++ userSlug 203 ++ "&artist=" 204 ++ Url.percentEncode artist 205 ++ "&release=" 206 ++ Url.percentEncode release 207 ++ (case mbid of 208 Just m -> 209 "&mbid=" ++ m 210 211 Nothing -> 212 "" 213 ) 214 215 isZoomed = 216 hoveredCover == Just idx 217 218 isActive = 219 Dict.member key similarStates 220 in 221 li [ class "success" ] 222 [ div [ class "track-info" ] 223 [ div [ class "track-name" ] 224 [ button [ class "track-link", onClick (FilterBy "track" (Maybe.withDefault "Unknown Track" listen.trackName)) ] [ text (Maybe.withDefault "Unknown Track" listen.trackName) ] ] 225 , div [ class "track-artist" ] [ button [ class "artist-link", onClick (FilterBy "artist" artist) ] [ text artist ] ] 226 , div [ class "track-time" ] 227 [ span [] 228 (button [ class "album-link", onClick (FilterBy "album" release) ] [ text release ] 229 :: (case listen.label of 230 Just l -> 231 [ text " • " 232 , button [ class "label-link", onClick (FilterBy "label" l) ] [ text l ] 233 ] 234 235 Nothing -> 236 [] 237 ) 238 ++ [ text (" • " ++ timeAgo currentTime listen.listenedAt) ] 239 ) 240 ] 241 ] 242 , div [ class "cover-wrapper" ] 243 [ if Set.member coverUrl failedCovers then 244 text "" 245 246 else 247 img 248 [ class 249 ("track-cover" 250 ++ (if isZoomed then 251 " zoomed" 252 253 else 254 "" 255 ) 256 ) 257 , src coverUrl 258 , Attr.alt release 259 , on "error" (D.succeed (ImageError coverUrl)) 260 , onClick 261 (if isZoomed then 262 UnhoverCover 263 264 else 265 HoverCover idx 266 ) 267 ] 268 [] 269 , case listen.genre of 270 Just g -> 271 div [ class "genre-tag" ] [ text g ] 272 273 Nothing -> 274 text "" 275 , if artist /= "" && trackName /= "" then 276 button 277 [ class 278 ("similar-btn" 279 ++ (if isActive then 280 " active" 281 282 else 283 "" 284 ) 285 ) 286 , onClick (FetchSimilar idx artist trackName) 287 ] 288 [ text "similar" ] 289 290 else 291 text "" 292 ] 293 ] 294 295 296 renderSimilarPanel : Dict String SimilarState -> String -> Html Msg 297 renderSimilarPanel similarStates key = 298 case Dict.get key similarStates of 299 Nothing -> 300 text "" 301 302 Just SimilarLoading -> 303 div [ class "similar-panel" ] 304 [ div [ class "similar-loading" ] [ text "⏳" ] ] 305 306 Just (SimilarError err) -> 307 div [ class "similar-panel" ] 308 [ div [ class "similar-error" ] [ text err ] ] 309 310 Just (SimilarLoaded tracks) -> 311 div [ class "similar-panel" ] 312 [ if List.isEmpty tracks then 313 div [ class "similar-empty" ] [ text "no similar songs found" ] 314 315 else 316 div [] (List.map renderSimilarTrack tracks) 317 ] 318 319 320 renderSimilarTrack : SimilarTrack -> Html Msg 321 renderSimilarTrack track = 322 div [ class "similar-track" ] 323 [ div [ class "similar-track-info" ] 324 [ div [ class "similar-track-name" ] [ text track.track ] 325 , div [ class "similar-track-artist" ] [ text track.artist ] 326 ] 327 , case track.score of 328 Just score -> 329 span [ class "similar-score" ] 330 [ text (String.fromInt (round (score * 100)) ++ "%") ] 331 332 Nothing -> 333 text "" 334 , case track.videoUri of 335 Just link -> 336 a [ class "similar-link", href link, target "_blank" ] 337 [ text "▶" ] 338 339 Nothing -> 340 text "" 341 ] 342 343 344 renderStatsView : Set String -> Set String -> Maybe Stats -> Html Msg 345 renderStatsView expandedSections loadedSections mStats = 346 case mStats of 347 Nothing -> 348 div [ class "loading" ] [ text "⏳" ] 349 350 Just stats -> 351 div [] 352 [ renderStatSection expandedSections loadedSections (Just "artist") "top artists" stats.artists 353 , renderStatSection expandedSections loadedSections Nothing "top tracks" stats.tracks 354 , renderStatSection expandedSections loadedSections (Just "genre") "genres" stats.genres 355 , renderStatSection expandedSections loadedSections (Just "label") "labels" stats.labels 356 , renderStatSection expandedSections loadedSections (Just "year") "years" stats.years 357 ] 358 359 360 renderStatSection : Set String -> Set String -> Maybe String -> String -> List StatsEntry -> Html Msg 361 renderStatSection expandedSections loadedSections mField title entries = 362 let 363 sectionKey = 364 Maybe.withDefault title mField 365 366 expanded = 367 Set.member sectionKey expandedSections 368 369 loaded = 370 Set.member sectionKey loadedSections 371 372 visible = 373 if loaded then 374 entries 375 376 else if expanded then 377 List.take 50 entries 378 379 else 380 List.take 10 entries 381 382 maxCount = 383 entries 384 |> List.map .count 385 |> List.maximum 386 |> Maybe.withDefault 1 387 388 n = 389 List.length entries 390 391 footer = 392 if loaded then 393 if n > 10 then 394 button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] 395 396 else 397 text "" 398 399 else if expanded then 400 if n >= 50 then 401 span [] 402 [ button [ class "show-all-btn", onClick (ShowAllSection sectionKey) ] [ text "show all" ] 403 , text " · " 404 , button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] 405 ] 406 407 else 408 button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] 409 410 else if n > 10 then 411 button [ class "show-all-btn", onClick (ExpandSection sectionKey) ] [ text "show more" ] 412 413 else 414 text "" 415 in 416 div [ class "stats-section" ] 417 [ h2 [] [ text title ] 418 , if List.isEmpty entries then 419 div [ class "stats-empty" ] [ text "beyond here lies nothing" ] 420 421 else 422 ul [] (List.map (renderStatEntry maxCount mField) visible) 423 , footer 424 ] 425 426 427 renderStatEntry : Int -> Maybe String -> StatsEntry -> Html Msg 428 renderStatEntry maxCount mField entry = 429 let 430 barPct = 431 entry.count * 100 // maxCount 432 433 rowAttrs = 434 case mField of 435 Just field -> 436 [ class "stat-row clickable", onClick (FilterBy field entry.name) ] 437 438 Nothing -> 439 [ class "stat-row" ] 440 in 441 li rowAttrs 442 [ div [ class "stat-bar", style "width" (String.fromInt barPct ++ "%") ] [] 443 , span [ class "stat-name" ] [ text entry.name ] 444 , span [ class "stat-count" ] [ text (String.fromInt entry.count) ] 445 ] 446 447 448 renderPeriodSelector : Period -> Bool -> String -> Maybe String -> Html Msg 449 renderPeriodSelector current showInput customVal mError = 450 let 451 isCustom = 452 case current of 453 CustomRange _ _ -> 454 True 455 456 _ -> 457 False 458 459 namedBtn target label = 460 button 461 [ class 462 ("period-btn" 463 ++ (if current == target then 464 " active" 465 466 else 467 "" 468 ) 469 ) 470 , onClick (SetStatsPeriod target) 471 ] 472 [ text label ] 473 474 customBtn = 475 button 476 [ class 477 ("period-btn" 478 ++ (if isCustom then 479 " active" 480 481 else 482 "" 483 ) 484 ) 485 , onClick OpenCustomInput 486 ] 487 [ text "custom" ] 488 489 daysBtn n = 490 let 491 label = 492 case n of 493 7 -> 494 "1w" 495 496 14 -> 497 "2w" 498 499 30 -> 500 "1m" 501 502 90 -> 503 "3m" 504 505 180 -> 506 "6m" 507 508 365 -> 509 "1y" 510 511 _ -> 512 String.fromInt n ++ "d" 513 in 514 button 515 [ class 516 ("period-btn" 517 ++ (if current == LastDays n then 518 " active" 519 520 else 521 "" 522 ) 523 ) 524 , onClick (SetStatsPeriod (LastDays n)) 525 ] 526 [ text label ] 527 in 528 div [] 529 [ div [ class "period-selector" ] 530 ([ namedBtn AllTime "all time", customBtn ] 531 ++ List.map daysBtn [ 7, 14, 30, 90, 180, 365 ] 532 ) 533 , if showInput || isCustom then 534 div [] 535 [ div [ class "custom-range" ] 536 [ input 537 [ type_ "text" 538 , class 539 ("custom-range-input" 540 ++ (if mError /= Nothing then 541 " error" 542 543 else 544 "" 545 ) 546 ) 547 , placeholder "2023-01-01 2026-01-01" 548 , value customVal 549 , onInput UpdateCustomInput 550 ] 551 [] 552 , button [ class "period-btn", onClick ApplyCustomPeriod ] 553 [ text "apply" ] 554 ] 555 , case mError of 556 Just err -> 557 div [ class "custom-range-error" ] [ text err ] 558 559 Nothing -> 560 text "" 561 ] 562 563 else 564 text "" 565 ] 566 567 568 onEnter : msg -> Html.Attribute msg 569 onEnter msg = 570 on "keydown" 571 (D.field "key" D.string 572 |> D.andThen 573 (\key -> 574 if key == "Enter" then 575 D.succeed msg 576 577 else 578 D.fail "not enter" 579 ) 580 ) 581 582 583 584 -- HELPERS 585 586 587 timeAgo : Maybe Time.Posix -> Maybe Int -> String 588 timeAgo mNow mTimestamp = 589 case ( mNow, mTimestamp ) of 590 ( Just now, Just ts ) -> 591 let 592 nowSecs = 593 Time.posixToMillis now // 1000 594 595 diff = 596 nowSecs - ts 597 in 598 if diff < 60 then 599 "just now" 600 601 else if diff < 3600 then 602 let 603 mins = 604 diff // 60 605 in 606 String.fromInt mins 607 ++ " minute" 608 ++ (if mins > 1 then 609 "s" 610 611 else 612 "" 613 ) 614 ++ " ago" 615 616 else if diff < 86400 then 617 let 618 hours = 619 diff // 3600 620 in 621 String.fromInt hours 622 ++ " hour" 623 ++ (if hours > 1 then 624 "s" 625 626 else 627 "" 628 ) 629 ++ " ago" 630 631 else 632 let 633 days = 634 diff // 86400 635 in 636 String.fromInt days 637 ++ " day" 638 ++ (if days > 1 then 639 "s" 640 641 else 642 "" 643 ) 644 ++ " ago" 645 646 _ -> 647 "unknown time" 648 649 650 renderAboutView : String -> List UserInfo -> Html Msg 651 renderAboutView currentSlug allUsers = 652 let 653 otherUsers = 654 List.filter (\u -> u.slug /= currentSlug) allUsers 655 656 userLink { slug, name } = 657 let 658 url = 659 if slug == "" then 660 "/" 661 662 else 663 "/u/" ++ slug 664 in 665 li [] [ a [ href url ] [ text name ] ] 666 667 extLink url label = 668 a [ href url, target "_blank", class "about-link" ] [ text label ] 669 in 670 div [] 671 [ p [ class "about-lead" ] 672 [ text "corpus is a self-hosted proxy that syncs scrobbles from " 673 , extLink "https://listenbrainz.org" "ListenBrainz" 674 , text " and " 675 , extLink "https://www.last.fm" "Last.fm." 676 ] 677 , div [ class "stats-section" ] 678 [ h2 [] [ text "features" ] 679 , ul [ class "about-list" ] 680 [ li [] [ text "searchable listen history with pagination" ] 681 , li [] [ text "stats by artist, track, label, year, and genre" ] 682 , li [] [ text "filter listens by label, artist, genre, or year" ] 683 , li [] [ text "cover art from Cover Art Archive, Last.fm, and Discogs" ] 684 , li [] [ text "similar track discovery via cosine.club" ] 685 , li [] [ text "metadata enrichment via MusicBrainz, Last.fm, and Discogs" ] 686 ] 687 ] 688 , div [ class "stats-section" ] 689 [ h2 [] [ text "links" ] 690 , p [ class "about-meta" ] 691 [ div [] [ extLink "https://instagram.com/counterpoint303" "counterpoint" ] 692 , div [] [ extLink "https://sr.ht/~mtmn/corpus" "sourcehut" ] 693 , div [] [ extLink "https://mtmn.name" "mtmn.name" ] 694 ] 695 ] 696 , if List.isEmpty otherUsers then 697 text "" 698 699 else 700 div [ class "stats-section" ] 701 [ h2 [] [ text "friends" ] 702 , ul [ class "about-users" ] 703 (List.map userLink otherUsers) 704 ] 705 ]