Files
music_library/lib/music_library_web/live/stats_live/index.html.heex
T
Claudio Ortolina 268aff9918 Add top albums to the stats page
Authored with Claude.

> I'd like to have a new section in the Stats page. In this section I
want to see the top albums I listened to in the past 30,
  90, 365 days. Use the data in `scrobbled_tracks` to determine which
albums need to be displayed.

> This all looks great as a starter. I'd like to make some
modifications.

  1. Invert the position of the artist name and the album title (so that
it's consistent with the Scrobble Activity)
  2. Remove the border and shadow from around each album
  3. Rename the "Last 365 days" section to "Last Year"
2025-06-14 09:38:44 +03:00

448 lines
16 KiB
Plaintext

<div>
<h1 class="mt-5 text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Records")}
</h1>
<%!-- TODO: replace with OSS version --%>
<dl class="mt-5 grid grid-cols-2 gap-5 sm:grid-cols-5">
<.album_preview
record={@latest_record}
title={gettext("Latest purchase")}
class="col-span-2 sm:col-span-3"
/>
<.counter
title={gettext("Total collection")}
count={@collection_count}
path={~p"/collection"}
/>
<.counter title={gettext("Total wishlist")} count={@wishlist_count} path={~p"/wishlist"} />
</dl>
</div>
<div>
<h1 class="mt-5 text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Formats")}
</h1>
<%!-- TODO: replace with OSS version --%>
<dl class={[
"mt-5 grid divide-zinc-200 dark:divide-zinc-900 overflow-hidden rounded-md bg-white dark:bg-zinc-800 shadow-sm divide-x-2",
stats_class(@collection_count_by_format)
]}>
<div :for={{format, count} <- @collection_count_by_format} class="px-2 py-5 sm:px-4">
<dt class="text-sm font-medium text-zinc-500 dark:text-zinc-400 text-center max-sm:text-xs break-keep">
{format_label(format)}
</dt>
<dd class="mt-1 text-center">
<.link
class="text-xl lg:text-2xl font-semibold hover:text-zinc-500 dark:text-zinc-300 dark:hover:text-zinc-200"
navigate={~p"/collection?query=format:#{format}"}
>
{count}
</.link>
</dd>
</div>
</dl>
</div>
<div>
<h1 class="mt-5 text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Types")}
</h1>
<%!-- TODO: replace with OSS version --%>
<dl class={[
"mt-5 grid divide-zinc-200 dark:divide-zinc-900 overflow-hidden rounded-md bg-white dark:bg-zinc-800 shadow-sm divide-x-2",
stats_class(@collection_count_by_type)
]}>
<div :for={{type, count} <- @collection_count_by_type} class="px-2 py-5 sm:px-4">
<dt class="text-sm font-medium text-zinc-500 dark:text-zinc-400 text-center max-sm:text-xs break-keep">
{type_label(type)}
</dt>
<dd class="mt-1 text-center">
<.link
class="text-xl lg:text-2xl font-semibold hover:text-zinc-500 dark:text-zinc-300 dark:hover:text-zinc-200"
navigate={~p"/collection?query=type:#{type}"}
>
{count}
</.link>
</dd>
</div>
</dl>
</div>
<div class="mt-5 grid grid-cols-1 lg:grid-cols-2 gap-5">
<div>
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Top %{n} Artists", %{n: length(@records_by_artist)})}
</h1>
<div class="mt-5 bg-white dark:bg-zinc-800 rounded-md shadow-sm">
<.vertical_bar_chart
data={@records_by_artist}
width={600}
height={35 * length(@records_by_artist)}
color_class="fill-red-500"
label_fn={fn datum -> datum.name end}
value_fn={fn datum -> datum.count end}
label_click={
fn datum ->
JS.navigate(~p"/artists/#{datum.id}")
end
}
class="w-full"
/>
</div>
</div>
<div>
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Top %{n} Genres", %{n: length(@records_by_genre)})}
</h1>
<div class="mt-5 bg-white dark:bg-zinc-800 rounded-md shadow-sm">
<.vertical_bar_chart
data={@records_by_genre}
width={600}
height={35 * length(@records_by_genre)}
color_class="fill-zinc-500"
label_fn={fn {genre, _count} -> genre end}
value_fn={fn {_genre, count} -> count end}
label_click={
fn {genre, _count} -> JS.navigate(~p"/collection?#{%{query: ~s(genre:"#{genre}")}}") end
}
class="w-full"
/>
</div>
</div>
</div>
<div>
<h1 class="mt-5 text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Top Albums")}
</h1>
<div class="mt-5 grid grid-cols-1 lg:grid-cols-3 gap-5">
<div>
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-200 mb-3">
{gettext("Last 30 days")}
</h2>
<div class="space-y-2">
<div :for={album <- @top_albums.last_30_days} class="flex items-center space-x-3 p-2">
<img
class="w-12 h-12 rounded-md object-cover"
src={album.cover_url}
alt={album.album_title}
/>
<div class="flex-1 min-w-0">
<p class="text-xs text-zinc-600 dark:text-zinc-400 truncate">
{album.artist_name}
</p>
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-300 truncate">
{album.album_title}
</p>
</div>
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-300">
{album.play_count}
</div>
</div>
</div>
</div>
<div>
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-200 mb-3">
{gettext("Last 90 days")}
</h2>
<div class="space-y-2">
<div :for={album <- @top_albums.last_90_days} class="flex items-center space-x-3 p-2">
<img
class="w-12 h-12 rounded-md object-cover"
src={album.cover_url}
alt={album.album_title}
/>
<div class="flex-1 min-w-0">
<p class="text-xs text-zinc-600 dark:text-zinc-400 truncate">
{album.artist_name}
</p>
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-300 truncate">
{album.album_title}
</p>
</div>
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-300">
{album.play_count}
</div>
</div>
</div>
</div>
<div>
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-200 mb-3">
{gettext("Last Year")}
</h2>
<div class="space-y-2">
<div :for={album <- @top_albums.last_365_days} class="flex items-center space-x-3 p-2">
<img
class="w-12 h-12 rounded-md object-cover"
src={album.cover_url}
alt={album.album_title}
/>
<div class="flex-1 min-w-0">
<p class="text-xs text-zinc-600 dark:text-zinc-400 truncate">
{album.artist_name}
</p>
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-300 truncate">
{album.album_title}
</p>
</div>
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-300">
{album.play_count}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flow-root">
<div class="mt-5 flex justify-between items-center">
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
{gettext("Scrobble activity")}
</h1>
<button
type="button"
class="phx-click-loading:animate-spin text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-300"
phx-click={JS.push("refresh_lastfm_feed")}
>
<span class="sr-only">{gettext("Refresh LastFm Feed")}</span>
<.icon name="hero-arrow-path" class="-mt-1 h-5 w-5" aria-hidden="true" data-slot="icon" />
</button>
</div>
<%!-- TODO: replace with OSS version --%>
<span class="mt-5 isolate inline-flex rounded-md shadow-xs">
<.button
phx-click={JS.push("set_scrobble_activity_mode", value: %{"mode" => :albums})}
class={[
"rounded-l-md rounded-r-none",
@scrobble_activity_mode == :albums && "!bg-zinc-100 dark:!bg-zinc-700"
]}
>
{gettext("Albums")}
</.button>
<.button
phx-click={JS.push("set_scrobble_activity_mode", value: %{"mode" => :tracks})}
class={[
"-ml-px rounded-r-md rounded-l-none",
@scrobble_activity_mode == :tracks && "!bg-zinc-100 dark:!bg-zinc-700"
]}
>
{gettext("Tracks")}
</.button>
</span>
<ul
:if={@scrobble_activity_mode == :albums}
id="scrobble-activity-albums"
role="list"
class="mt-5 rounded-md px-4 py-5 shadow-sm"
phx-update="stream"
>
<li :for={{id, album} <- @streams.recent_albums} id={id} class="group">
<div class="relative pb-8 group-last:pb-0">
<span
class="group-last:hidden absolute left-6 top-6 -ml-px h-full w-0.5 bg-zinc-200"
aria-hidden="true"
>
</span>
<div class="relative flex space-x-3 items-center justify-between">
<div class="flex min-w-0 justify-between space-x-4 items-center">
<img
class="h-12 w-12 rounded-md shadow-sm"
src={album.cover_url}
alt={album.metadata.title}
/>
<div>
<p
:if={album.artist.musicbrainz_id not in @artist_ids}
class="font-semibold text-sm block text-zinc-500 dark:text-zinc-400"
>
{album.artist.name}
</p>
<.link
:if={album.artist.musicbrainz_id in @artist_ids}
class="font-semibold text-sm block text-zinc-700 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300"
navigate={~p"/artists/#{album.artist.musicbrainz_id}"}
>
{album.artist.name}
</.link>
<p class="font-semibold text-sm md:text-base text-zinc-700 dark:text-zinc-300">
{album.metadata.title}
</p>
<time
datetime={format_scrobbled_at_uts(album.scrobbled_at_uts)}
class="whitespace-nowrap text-right text-xs sm:text-sm text-zinc-500 dark:text-zinc-400"
>
{album.scrobbled_at_label}
</time>
</div>
</div>
<div class="flex gap-1 flex-col text-right">
<.badge :if={album.metadata.musicbrainz_id == ""}>
{gettext("No MB ID")}
</.badge>
<.link
:if={
record_id = tracked_record?(@collected_releases, album.metadata.musicbrainz_id)
}
navigate={~p"/collection/#{record_id}"}
>
<.badge color="green">
{gettext("Collected")}
</.badge>
</.link>
<.link
:if={
record_id = tracked_record?(@wishlisted_releases, album.metadata.musicbrainz_id)
}
navigate={~p"/wishlist/#{record_id}"}
>
<.badge color="yellow">
{gettext("Wishlisted")}
</.badge>
</.link>
</div>
<.actions_menu
:if={
album.metadata.musicbrainz_id !== "" and
!tracked_record?(
@collected_releases ++ @wishlisted_releases,
album.metadata.musicbrainz_id
)
}
id={album.scrobbled_at_uts}
background_container_target="#records > li"
>
<:button>
<span class="sr-only">{gettext("Choose which format to import")}</span>
<.icon name="hero-star" class="-mt-1 h-5 w-5" aria-hidden="true" data-slot="icon" />
</:button>
<:links>
<.link
:for={format <- Records.Record.formats()}
class="block px-3 py-1 text-sm leading-6 text-zinc-900 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:text-zinc-300 dark:hover:bg-zinc-700"
role="menuitem"
tabindex="0"
id={"actions-#{album.scrobbled_at_uts}-#{format}-import"}
phx-click={
JS.push("import",
value: %{id: album.metadata.musicbrainz_id, format: format},
page_loading: true
)
}
>
{format_label(format)}
</.link>
</:links>
</.actions_menu>
</div>
</div>
</li>
</ul>
<ul
:if={@scrobble_activity_mode == :tracks}
id="scrobble-activity-tracks"
role="list"
class="mt-5 rounded-md px-4 py-5 shadow-sm"
phx-update="stream"
>
<li :for={{id, track} <- @streams.recent_tracks} id={id} class="group">
<div class="relative pb-8 group-last:pb-0">
<span
class="group-last:hidden absolute left-6 top-6 -ml-px h-full w-0.5 bg-zinc-200"
aria-hidden="true"
>
</span>
<div class="relative flex space-x-3 items-center justify-between">
<div class="flex min-w-0 justify-between space-x-4 items-center">
<img class="h-12 w-12 rounded-md shadow-sm" src={track.cover_url} alt={track.title} />
<div>
<p
:if={track.artist.musicbrainz_id not in @artist_ids}
class="font-semibold text-sm block text-zinc-500 dark:text-zinc-400"
>
{track.artist.name}
</p>
<.link
:if={track.artist.musicbrainz_id in @artist_ids}
class="font-semibold text-sm block text-zinc-700 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300"
navigate={~p"/artists/#{track.artist.musicbrainz_id}"}
>
{track.artist.name}
</.link>
<p class="font-semibold text-sm md:text-base text-zinc-700 dark:text-zinc-300">
{track.title}
</p>
<p class="font-semibold text-sm text-zinc-500 dark:text-zinc-400">
{track.album.title}
</p>
<time
datetime={format_scrobbled_at_uts(track.scrobbled_at_uts)}
class="whitespace-nowrap text-right text-xs sm:text-sm text-zinc-500 dark:text-zinc-400"
>
{track.scrobbled_at_label}
</time>
</div>
</div>
<div class="flex gap-1 flex-col text-right">
<.badge :if={track.album.musicbrainz_id == ""}>
{gettext("No MB ID")}
</.badge>
<.link
:if={record_id = tracked_record?(@collected_releases, track.album.musicbrainz_id)}
navigate={~p"/collection/#{record_id}"}
>
<.badge color="green">{gettext("Collected")}</.badge>
</.link>
<.link
:if={record_id = tracked_record?(@wishlisted_releases, track.album.musicbrainz_id)}
navigate={~p"/wishlist/#{record_id}"}
>
<.badge color="yellow">{gettext("Wishlisted")}</.badge>
</.link>
</div>
<.actions_menu
:if={
track.album.musicbrainz_id !== "" and
!tracked_record?(
@collected_releases ++ @wishlisted_releases,
track.album.musicbrainz_id
)
}
id={track.scrobbled_at_uts}
background_container_target="#records > li"
>
<:button>
<span class="sr-only">{gettext("Choose which format to import")}</span>
<.icon name="hero-star" class="-mt-1 h-5 w-5" aria-hidden="true" data-slot="icon" />
</:button>
<:links>
<.link
:for={format <- Records.Record.formats()}
class="block px-3 py-1 text-sm leading-6 text-zinc-900 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:text-zinc-300 dark:hover:bg-zinc-700"
role="menuitem"
tabindex="0"
id={"actions-#{track.scrobbled_at_uts}-#{format}-import"}
phx-click={
JS.push("import",
value: %{id: track.album.musicbrainz_id, format: format},
page_loading: true
)
}
>
{format_label(format)}
</.link>
</:links>
</.actions_menu>
</div>
</div>
</li>
</ul>
</div>