corpus

self-hosted listenbrainz and last.fm proxy
Log | Files | Refs | README | LICENSE

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         ]