Implement grid expanding

This commit is contained in:
Mikko Ahlroth 2023-11-13 19:44:32 +02:00
parent 93683dbb9f
commit 16214369f5
12 changed files with 326 additions and 108 deletions

View file

@ -13,13 +13,13 @@ gleam = ">= 0.32.0"
[dependencies]
gleam_stdlib = "~> 0.31"
lustre = "~> 3.0"
gleam_json = "~> 0.6"
gleam_http = "~> 3.5"
gleam_javascript = "~> 0.6"
gleam_fetch = "~> 0.2"
plinth = "~> 0.1"
varasto = "~> 1.0"
lustre = "~> 3.0"
[dev-dependencies]
gleeunit = "~> 0.10"

View file

@ -2,16 +2,16 @@
# You typically do not need to edit this file
packages = [
{ name = "gleam_fetch", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_http", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "F64E93C754D948B2D37ABC4ADD5482FE0FAED4B99C79E66012DDE96BEDC40544" },
{ name = "gleam_fetch", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "F64E93C754D948B2D37ABC4ADD5482FE0FAED4B99C79E66012DDE96BEDC40544" },
{ name = "gleam_http", version = "3.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0B09AAE8EB547C4F2F2D3F8917A0A4D2EF75DFF0232389332BAFE19DBBFDB92B" },
{ name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" },
{ name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" },
{ name = "gleam_stdlib", version = "0.32.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "07D64C26D014CF570F8ACADCE602761EA2E74C842D26F2FD49B0D61973D9966F" },
{ name = "gleeunit", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "1397E5C4AC4108769EE979939AC39BF7870659C5AFB714630DEEEE16B8272AD5" },
{ name = "lustre", version = "3.0.10", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "DBA4A223D93BDDD43B9D8EEB52AE130A6995C8078559F32DB58BE89899307FED" },
{ name = "lustre", version = "3.0.11", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "4B46341D9A3426023CFA21A6CFF4ED0171652A01B8905D1878099308DA2E92D2" },
{ name = "plinth", version = "0.1.4", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "43307A0271B8AC9B646B3C39F80D22C41DB4022CDAD67ABFAA555BB3F1321E2E" },
{ name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
{ name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
{ name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["plinth", "gleam_stdlib", "gleam_json"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
]
[requirements]

10
src/elekf/utils/set.gleam Normal file
View file

@ -0,0 +1,10 @@
import gleam/set.{type Set}
/// Toggle an item in a set. In other words, insert the item if it didn't
/// already exist, and remove it if it did.
pub fn toggle(set: Set(a), item: a) -> Set(a) {
case set.contains(set, item) {
True -> set.delete(set, item)
False -> set.insert(set, item)
}
}

View file

@ -15,6 +15,7 @@ import lustre/event
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/library/artist.{type Artist}
import elekf/library/album.{type Album}
import elekf/web/events/start_play
import elekf/web/events/show_artist
import elekf/web/components/search
@ -26,9 +27,13 @@ pub type DataGetter(a) =
fn(Library) -> List(LibraryItem(a))
/// A view that renders a single item from the library.
///
/// It should return a list of elements, which can be used for expanding items:
/// the first element for the item itself and subsequent elements for the
/// expanded contents.
pub type ItemView(a) =
fn(Model(a), List(LibraryItem(a)), Int, LibraryItem(a)) ->
element.Element(Msg)
List(element.Element(Msg))
/// A filter that gets the item and the current search string and must return
/// `True` if the item should be shown and `False` if not.
@ -64,6 +69,7 @@ pub type Msg {
StartPlay(List(LibraryItem(Track)), Int)
ShowArtist(LibraryItem(Artist))
Search(search.Msg)
AlbumExpandToggled(LibraryItem(Album))
}
pub type Model(a) {
@ -159,6 +165,9 @@ pub fn update(model, msg) {
let search_model = search.update(model.search, search_msg)
#(Model(..model, search: search_model), effect.none())
}
// Base case, this should be handled in an implementing view
AlbumExpandToggled(_album) -> #(model, effect.none())
}
}
@ -188,7 +197,8 @@ pub fn library_view(
[h3([attribute.class("library-item-title")], [text("Shuffle all")])],
),
],
list.index_map(items, fn(i, item) { item_view(model, items, i, item) }),
list.index_map(items, fn(i, item) { item_view(model, items, i, item) })
|> list.flatten(),
),
)
}

View file

@ -3,61 +3,118 @@
import gleam/list
import gleam/option
import gleam/int
import lustre/element/html.{div, h3, img, p}
import lustre/element/html.{div, h3, p}
import lustre/element.{text}
import lustre/attribute
import lustre/event
import ibroadcast/artwork
import elekf/library
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/track.{type Track}
import elekf/library/album_utils
import elekf/web/components/library_view.{type Model, StartPlay}
import elekf/web/components/library_view.{AlbumExpandToggled}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/track_item
import elekf/web/components/library_views/thumbnail
import elekf/web/common.{type Settings}
const base_classes = "library-item album-item"
const showing_tracks_class = "library-item-expanded"
pub fn view(
model: Model(Album),
_items: List(LibraryItem(Album)),
_index: Int,
library: Library,
settings: option.Option(Settings),
item: LibraryItem(Album),
show_tracks: Bool,
) {
let #(album_id, album) = item
let tracks = album_utils.get_tracks(model.library, album)
let tracks = album_utils.get_tracks(library, album)
let artist_name = case album.artist_id {
0 -> "Unknown artist"
id -> library.assert_artist(model.library, id).name
id -> library.assert_artist(library, id).name
}
let assert Ok(first_track) = list.first(tracks)
div(
let class = case show_tracks {
True -> base_classes <> " " <> showing_tracks_class
False -> base_classes
}
let expanded = case show_tracks {
True -> "true"
False -> "false"
}
list.append(
[
attribute.id("album-list-" <> int.to_string(album_id)),
attribute.class("library-item album-item"),
event.on_click(StartPlay(tracks, 0)),
attribute.attribute("role", "button"),
],
[
case model.settings, { first_track.1 }.artwork_id {
option.Some(s), id if id != 0 ->
img([
attribute.class("artist-image"),
attribute.alt("artist.name"),
attribute.src(artwork.url(
s.artwork_server,
int.to_string(id),
artwork.S300,
)),
attribute.attribute("loading", "lazy"),
attribute.width(300),
])
_, _ ->
div([attribute.class("artist-image-placeholder")], [text(album.name)])
},
h3([attribute.class("album-title")], [text(album.name)]),
p([attribute.class("album-artist")], [text(artist_name)]),
p(
[attribute.class("album-tracks")],
[text(int.to_string(list.length(album.tracks)) <> " tracks")],
div(
[
attribute.id("album-list-" <> int.to_string(album_id)),
attribute.class(class),
],
[
div(
[
attribute.class("album-item-expander"),
event.on_click(AlbumExpandToggled(item)),
event.on_keydown(fn(_) { AlbumExpandToggled(item) }),
attribute.attribute("role", "button"),
attribute.attribute("aria-expanded", expanded),
attribute.attribute("tabindex", "0"),
],
[
case settings, { first_track.1 }.artwork_id {
option.Some(s), id if id != 0 ->
thumbnail.view(s, int.to_string(id))
_, _ ->
div(
[
attribute.class(
"artist-image-placeholder album-item-image",
),
],
[text(album.name)],
)
},
h3([attribute.class("album-item-title")], [text(album.name)]),
p([attribute.class("album-item-artist")], [text(artist_name)]),
p(
[attribute.class("album-item-tracks-meta")],
[text(int.to_string(list.length(album.tracks)) <> " tracks")],
),
],
),
],
),
],
case show_tracks {
True -> [view_tracks(library, tracks)]
False -> []
},
)
}
pub fn view_tracks(library: Library, tracks: List(LibraryItem(Track))) {
div(
[
attribute.class(
"library-item-expanded-contents album-item-expanded-tracks",
),
],
list.flatten([
[h3([attribute.class("library-item-title")], [text("Shuffle all")])],
list.index_map(
tracks,
fn(index, track_item) {
track_item.view(
library,
tracks,
track_item,
index,
"album-tracks-list",
)
},
)
|> list.flatten(),
]),
)
}

View file

@ -3,26 +3,48 @@
import gleam/string
import gleam/list
import gleam/map
import gleam/set
import gleam/option
import gleam/dynamic
import gleam/result
import lustre
import lustre/effect
import lustre/attribute
import lustre/element
import elekf/utils/set as set_utils
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/album_utils
import elekf/web/components/library_view
import elekf/web/components/library_view.{AlbumExpandToggled}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/album_item
import elekf/web/common
const component_name = "albums-view"
type Model {
Model(library_view: library_view.Model(Album), expanded_albums: set.Set(Int))
}
type Msg {
LibraryViewMsg(library_view.Msg)
}
/// Register the albums view as a custom element.
pub fn register() {
library_view.register(
lustre.component(
component_name,
data_getter,
album_item.view,
shuffler,
search_filter,
init,
update,
generate_view(search_filter),
library_view.generic_attributes()
|> map.map_values(fn(_key, decoder) {
fn(data: dynamic.Dynamic) {
data
|> decoder()
|> result.map(LibraryViewMsg)
}
}),
)
}
@ -35,6 +57,34 @@ pub fn render(
library_view.render(component_name, library, settings, extra_attrs)
}
fn init() {
let #(lib_m, lib_e) =
library_view.init(library.empty(), data_getter, shuffler)
#(
Model(library_view: lib_m, expanded_albums: set.new()),
effect.map(lib_e, LibraryViewMsg),
)
}
fn update(model: Model, msg) {
case msg {
LibraryViewMsg(AlbumExpandToggled(album)) -> {
#(
Model(
..model,
expanded_albums: set_utils.toggle(model.expanded_albums, album.0),
),
effect.none(),
)
}
LibraryViewMsg(lib_msg) -> {
let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg)
#(Model(..model, library_view: lib_m), effect.map(lib_e, LibraryViewMsg))
}
}
}
fn data_getter(library: Library) {
map.to_list(library.albums)
}
@ -49,3 +99,23 @@ fn shuffler(library, items: List(LibraryItem(Album))) {
fn search_filter(item: Album, search_text: String) {
string.contains(item.name_lower, search_text)
}
fn generate_view(search_filter: library_view.SearchFilter(Album)) {
fn(model: Model) {
library_view.library_view(
model.library_view,
fn(_library_model, _items, _index, item) { view(model, item) },
search_filter,
)
|> element.map(LibraryViewMsg)
}
}
fn view(model: Model, item: LibraryItem(Album)) {
album_item.view(
model.library_view.library,
model.library_view.settings,
item,
set.contains(model.expanded_albums, item.0),
)
}

View file

@ -5,15 +5,15 @@ import gleam/int
import gleam/list
import gleam/map
import gleam/option
import lustre/element/html.{div, h3, img, p}
import lustre/element/html.{div, h3, p}
import lustre/element.{text}
import lustre/attribute
import lustre/event
import ibroadcast/artwork
import elekf/library.{type Library}
import elekf/library/artist.{type Artist}
import elekf/web/components/library_view.{type Model, ShowArtist}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/thumbnail
import elekf/web/common
const component_name = "artists-view"
@ -69,37 +69,32 @@ fn item_view(
item: LibraryItem(Artist),
) {
let #(artist_id, artist) = item
div(
[
attribute.id("artist-list-" <> int.to_string(artist_id)),
attribute.class("library-item artist-item"),
attribute.type_("button"),
event.on_click(ShowArtist(item)),
attribute.attribute("role", "button"),
],
[
case model.settings, artist.artwork_id {
option.Some(s), option.Some(id) ->
img([
attribute.class("artist-image"),
attribute.alt("artist.name"),
attribute.src(artwork.url(s.artwork_server, id, artwork.S300)),
attribute.attribute("loading", "lazy"),
attribute.width(300),
])
_, _ ->
div(
[attribute.class("artist-image-placeholder")],
[text(artist.name)],
)
},
h3([attribute.class("artist-title")], [text(artist.name)]),
p(
[attribute.class("artist-tracks")],
[text(int.to_string(list.length(artist.tracks)) <> " tracks")],
),
],
)
[
div(
[
attribute.id("artist-list-" <> int.to_string(artist_id)),
attribute.class("library-item artist-item"),
attribute.type_("button"),
event.on_click(ShowArtist(item)),
attribute.attribute("role", "button"),
],
[
case model.settings, artist.artwork_id {
option.Some(s), option.Some(id) -> thumbnail.view(s, id)
_, _ ->
div(
[attribute.class("artist-image-placeholder")],
[text(artist.name)],
)
},
h3([attribute.class("artist-title")], [text(artist.name)]),
p(
[attribute.class("artist-tracks")],
[text(int.to_string(list.length(artist.tracks)) <> " tracks")],
),
],
),
]
}
fn get_tracks(library: Library, artist: Artist) {

View file

@ -3,6 +3,7 @@
import gleam/string
import gleam/list
import gleam/map
import gleam/set
import gleam/option
import gleam/dynamic
import gleam/result
@ -11,11 +12,12 @@ import lustre/element/html.{div, header}
import lustre/element.{text}
import lustre/attribute
import lustre/effect
import elekf/utils/set as set_utils
import elekf/library.{type Library}
import elekf/library/album.{type Album}
import elekf/library/album_utils
import elekf/library/artist.{type Artist}
import elekf/web/components/library_view
import elekf/web/components/library_view.{AlbumExpandToggled}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/album_item
import elekf/web/common
@ -26,6 +28,7 @@ type Model {
Model(
library_view: library_view.Model(Album),
artist: option.Option(LibraryItem(Artist)),
expanded_albums: set.Set(Int),
)
}
@ -82,7 +85,7 @@ fn init() {
)
#(
Model(artist: option.None, library_view: lib_m),
Model(artist: option.None, expanded_albums: set.new(), library_view: lib_m),
effect.map(lib_e, LibraryViewMsg),
)
}
@ -92,6 +95,7 @@ fn update(model: Model, msg) {
ArtistUpdated(option.Some(artist)) -> #(
Model(
artist: option.Some(artist),
expanded_albums: set.new(),
library_view: library_view.Model(
..model.library_view,
data: data_getter(model.library_view.library, artist),
@ -102,10 +106,18 @@ fn update(model: Model, msg) {
ArtistUpdated(option.None) -> #(
Model(
artist: option.None,
expanded_albums: set.new(),
library_view: library_view.Model(..model.library_view, data: []),
),
effect.none(),
)
LibraryViewMsg(AlbumExpandToggled(album)) -> #(
Model(
..model,
expanded_albums: set_utils.toggle(model.expanded_albums, album.0),
),
effect.none(),
)
LibraryViewMsg(lib_msg) -> {
let #(lib_m, lib_e) = library_view.update(model.library_view, lib_msg)
#(Model(..model, library_view: lib_m), effect.map(lib_e, LibraryViewMsg))
@ -161,7 +173,7 @@ fn view(
header([attribute.class("library-header")], [text({ artist.1 }.name)]),
library_view.library_view(
model.library_view,
album_item.view,
fn(_library_model, _items, _index, item) { album_view(model, item) },
search_filter,
)
|> element.map(LibraryViewMsg),
@ -169,6 +181,15 @@ fn view(
)
}
fn album_view(model: Model, item: LibraryItem(Album)) {
album_item.view(
model.library_view.library,
model.library_view.settings,
item,
set.contains(model.expanded_albums, item.0),
)
}
fn artist_decode(data: dynamic.Dynamic) {
let artist: option.Option(LibraryItem(Artist)) = dynamic.unsafe_coerce(data)
Ok(ArtistUpdated(artist))

View file

@ -0,0 +1,20 @@
//// A thumbnail element (artist, album...)
import lustre/element/html.{div, img}
import lustre/attribute
import ibroadcast/artwork
import elekf/web/common.{type Settings}
pub fn view(s: Settings, id: String) {
div(
[attribute.class("library-item-thumbnail")],
[
img([
attribute.alt(""),
attribute.src(artwork.url(s.artwork_server, id, artwork.S300)),
attribute.attribute("loading", "lazy"),
attribute.width(300),
]),
],
)
}

View file

@ -0,0 +1,37 @@
import gleam/int
import lustre/attribute
import lustre/event
import lustre/element.{text}
import lustre/element/html.{div, h3, p}
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_view.{StartPlay}
pub fn view(
library: Library,
items: List(LibraryItem(Track)),
item: LibraryItem(Track),
index: Int,
id_prefix: String,
) {
let #(track_id, track) = item
let album = library.assert_album(library, track.album_id)
let artist = library.assert_artist(library, track.artist_id)
[
div(
[
attribute.id(id_prefix <> "-" <> int.to_string(track_id)),
attribute.class("library-item track-item"),
attribute.type_("button"),
event.on_click(StartPlay(items, index)),
attribute.attribute("role", "button"),
],
[
h3([attribute.class("track-title")], [text(track.title)]),
p([attribute.class("track-artist")], [text(artist.name)]),
p([attribute.class("track-album")], [text(album.name)]),
],
),
]
}

View file

@ -1,18 +1,15 @@
//// A library view to all of the tracks in the library.
import gleam/string
import gleam/int
import gleam/list
import gleam/map
import gleam/option
import lustre/element/html.{div, h3, p}
import lustre/element.{text}
import lustre/attribute
import lustre/event
import elekf/library.{type Library}
import elekf/library/track.{type Track}
import elekf/web/components/library_view.{type Model, StartPlay}
import elekf/web/components/library_view.{type Model}
import elekf/web/components/library_item.{type LibraryItem}
import elekf/web/components/library_views/track_item
import elekf/web/common
const component_name = "tracks-view"
@ -55,21 +52,5 @@ fn item_view(
index: Int,
item: LibraryItem(Track),
) {
let #(track_id, track) = item
let album = library.assert_album(model.library, track.album_id)
let artist = library.assert_artist(model.library, track.artist_id)
div(
[
attribute.id("track-list-" <> int.to_string(track_id)),
attribute.class("library-item track-item"),
attribute.type_("button"),
event.on_click(StartPlay(items, index)),
attribute.attribute("role", "button"),
],
[
h3([attribute.class("track-title")], [text(track.title)]),
p([attribute.class("track-artist")], [text(artist.name)]),
p([attribute.class("track-album")], [text(album.name)]),
],
)
track_item.view(model.library, items, item, index, "track-list")
}

View file

@ -66,12 +66,29 @@ main {
}
#artists-view .library-list,
#albums-view .library-list {
#albums-view .library-list,
#single-artist-view .library-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-auto-flow: dense;
gap: 10px;
}
.library-list-shuffle-all {
grid-column: 1 / -1;
}
.library-item-thumbnail {
aspect-ratio: 1 / 1;
}
.library-item-expanded {
}
.library-item-expanded-contents {
grid-column: 1 / -1;
}
.library-item img {
}