diff --git a/src/lib.rs b/src/lib.rs index 606a6f9c..a629fa3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,6 +117,7 @@ use { video_format::VideoFormat, video_info::VideoInfo, video_type::VideoType, + view::View, }, axum::{ body::Body, @@ -368,6 +369,7 @@ mod video_error; mod video_format; mod video_info; mod video_type; +mod view; #[cfg(test)] mod derive; diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index 637b2097..51662b24 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -2,7 +2,7 @@ use { super::*, axum::{ Router, - extract::{Extension, Path, Request}, + extract::{Extension, Path, Query, Request}, http::{HeaderValue, Uri}, middleware::{self, Next}, response::{IntoResponse, Redirect, Response}, @@ -35,6 +35,11 @@ pub(crate) struct AuthConfig { pub(crate) audience: Option, } +#[derive(Deserialize)] +pub(crate) struct PackagesQuery { + view: Option, +} + pub(crate) struct RedirectConfig { destination: Url, domains: HashSet, diff --git a/src/subcommand/serve/route.rs b/src/subcommand/serve/route.rs index 8082af36..fcf91006 100644 --- a/src/subcommand/serve/route.rs +++ b/src/subcommand/serve/route.rs @@ -210,11 +210,15 @@ pub(crate) async fn package_item( }) } -pub(crate) async fn packages(server: ServerExtension) -> PageResult { +pub(crate) async fn packages( + server: ServerExtension, + Query(query): Query, +) -> PageResult { block_in_place(|| { Ok( PackagesHtml { packages: server.packages()?, + view: query.view.unwrap_or_default(), } .into(), ) diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index 8bb080ad..071be8d2 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -1578,6 +1578,30 @@ fn packages_empty() { .get("/packages") .assert_page(PackagesHtml { packages: Vec::new(), + view: View::List, + }) + .send(); +} + +#[test] +fn packages_grid() { + let server = TestServer::new(); + + let metadata = Metadata { + artwork: Some("foo.png".parse().unwrap()), + ..default() + }; + + let fingerprint = PackageBuilder::new() + .metadata(&metadata) + .file("foo.png", b"bar") + .upload(&server); + + server + .get("/packages?view=grid") + .assert_page(PackagesHtml { + packages: vec![(fingerprint, Some(metadata))], + view: View::Grid, }) .send(); } @@ -1598,10 +1622,22 @@ fn packages_include_creators_and_titles() { .get("/packages") .assert_page(PackagesHtml { packages: vec![(fingerprint, Some(metadata))], + view: View::List, }) .send(); } +#[test] +fn packages_invalid_view() { + TestServer::new() + .get("/packages?view=foo") + .status(StatusCode::BAD_REQUEST) + .assert_body( + "Failed to deserialize query string: view: unknown variant `foo`, expected `grid` or `list`", + ) + .send(); +} + #[test] fn packages_non_empty() { let server = TestServer::new(); @@ -1622,7 +1658,10 @@ fn packages_non_empty() { server .get("/packages") - .assert_page(PackagesHtml { packages }) + .assert_page(PackagesHtml { + packages, + view: View::List, + }) .send(); } diff --git a/src/templates/packages.rs b/src/templates/packages.rs index b958331a..b9b39cfe 100644 --- a/src/templates/packages.rs +++ b/src/templates/packages.rs @@ -3,15 +3,19 @@ use super::*; #[derive(Boilerplate)] pub(crate) struct PackagesHtml { pub(crate) packages: Vec<(Fingerprint, Option)>, + pub(crate) view: View, } impl PackagesHtml { fn packages( &self, - ) -> impl Iterator, Option<&Component>)> { + ) -> impl Iterator, Option<&Component>)> { self.packages.iter().map(|(fingerprint, metadata)| { ( *fingerprint, + metadata + .as_ref() + .is_some_and(|metadata| metadata.artwork.is_some()), metadata .as_ref() .and_then(|metadata| metadata.creator.as_deref()), @@ -24,7 +28,50 @@ impl PackagesHtml { } impl Page for PackagesHtml { + fn stylesheet(&self) -> Option<&'static str> { + Some("/static/packages.css") + } + fn title(&self) -> String { "packages ยท filepack".into() } } + +#[cfg(test)] +mod tests { + use {super::*, pretty_assertions::assert_eq}; + + #[test] + fn grid() { + let fingerprint = test::FINGERPRINT.parse::().unwrap(); + + let metadata = Metadata { + artwork: Some("foo.png".parse().unwrap()), + ..default() + }; + + assert_eq!( + PackagesHtml { + packages: vec![(fingerprint, Some(metadata)), (fingerprint, None)], + view: View::Grid, + } + .to_string(), + unindent(&format!( + " +

Packages

+ + ", + fingerprint = test::FINGERPRINT, + )), + ); + } +} diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 00000000..e2c01f8c --- /dev/null +++ b/src/view.rs @@ -0,0 +1,9 @@ +use super::*; + +#[derive(Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum View { + Grid, + #[default] + List, +} diff --git a/static/index.css b/static/index.css index f27cd09b..3d38dcb8 100644 --- a/static/index.css +++ b/static/index.css @@ -1,4 +1,6 @@ :root { + --border: #222; + --border-hover: #444; --primary: rgb(255 255 255 / 87%); --secondary: rgb(255 255 255 / 60%); } diff --git a/static/package.css b/static/package.css index dd2d12dc..426e0e1f 100644 --- a/static/package.css +++ b/static/package.css @@ -17,13 +17,13 @@ time { a { border-radius: 8px; - border: 1px solid #222; + border: 1px solid var(--border); display: block; overflow: hidden; transition: border-color 150ms; &:hover, &:focus-visible { - border-color: #444; + border-color: var(--border-hover); } } diff --git a/static/packages.css b/static/packages.css new file mode 100644 index 00000000..510331c8 --- /dev/null +++ b/static/packages.css @@ -0,0 +1,27 @@ +.grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + list-style: none; + padding-left: 0; + + a { + aspect-ratio: 1 / 1; + border-radius: 8px; + border: 1px solid var(--border); + display: block; + overflow: hidden; + transition: border-color 150ms; + + &:hover, &:focus-visible { + border-color: var(--border-hover); + } + } + + img { + display: block; + height: 100%; + object-fit: contain; + width: 100%; + } +} diff --git a/templates/packages.html b/templates/packages.html index 6584fa10..d64f0985 100644 --- a/templates/packages.html +++ b/templates/packages.html @@ -1,17 +1,36 @@

Packages

+%% match self.view { +%% View::Grid => { +
    +%% for (fingerprint, artwork, _creator, _title) in self.packages() { +
  • +%% if artwork { + + + +%% } else { + +%% } +
  • +%% } +
+%% } +%% View::List => { +%% } +%% }