diff --git a/assets/static/css/theme.css b/assets/static/css/theme.css index c0c53fe..3f334f5 100644 --- a/assets/static/css/theme.css +++ b/assets/static/css/theme.css @@ -249,6 +249,38 @@ body { } .term-empty-cmd { font-size: 0.8rem; color: oklch(var(--bc) / 0.45); } +/* --- how-it-works note + form helpers (admin) -------------- */ +.term-note { + margin-bottom: 1.5rem; + padding: 0.9rem 1.1rem; + background: oklch(var(--b2)); + border: 1px solid oklch(var(--b3)); + border-left: 3px solid oklch(var(--a)); +} +.term-note-title { margin-bottom: 0.55rem; font-size: 0.8rem; color: oklch(var(--a)); } +.term-step { display: flex; gap: 0.55rem; font-size: 0.88rem; } +.term-step + .term-step { margin-top: 0.3rem; } +.term-step-n { flex: none; color: oklch(var(--p)); } +.term-note-foot { margin-top: 0.6rem; font-size: 0.8rem; color: oklch(var(--bc) / 0.6); } +.term-help { margin-top: 0.2rem; font-size: 0.76rem; color: oklch(var(--bc) / 0.55); } +.term-picklist { + border: 1px solid oklch(var(--b3)); + background: oklch(var(--b1)); + max-height: 18rem; + overflow-y: auto; +} +.term-pick { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.5rem 0.7rem; + border-top: 1px solid oklch(var(--b3)); + cursor: pointer; +} +.term-pick:first-child { border-top: 0; } +.term-pick:hover { background: oklch(var(--b2)); } +.term-formdiv { margin: 1.25rem 0; border-top: 1px dashed oklch(var(--b3)); } + /* --- terminal session block (mockup-code substitute) ------- */ .term-screen { background: oklch(var(--b1)); diff --git a/assets/views/admin/audio/albums.html b/assets/views/admin/audio/albums.html index be8fc85..52924a1 100644 --- a/assets/views/admin/audio/albums.html +++ b/assets/views/admin/audio/albums.html @@ -1,66 +1,88 @@ {% extends "admin/base.html" %} -{% block title %}Audio Albums{% endblock title %} +{% block title %}Albums{% endblock title %} +{% block crumb %}audio/albums{% endblock crumb %} {% block content %} -
-
-
-

Audio Albums

-

Create albums and upload audio tracks.

-
- +
+
+

+ root@universal-web:/admin/audio# + ls albums/ +

+

albums

+

// step 2 — group songs into a release with a cover.

+ +
-
-
- {% if albums | length > 0 %} -
- - - - - - - - - - - {% for row in albums %} - - - - - - - {% endfor %} - -
AlbumStatusTracksActions
{{ row.album.title }} - {% if row.album.published %} - Published - {% else %} - Draft - {% endif %} - {{ row.track_count }} -
- Tracks - View -
-
+
+

# before you make an album

+
+ [1] + upload your songs first — an album is built from songs that already exist. +
+
+ [2] + create the album here, then tick the songs that belong to it (or upload more into it later). +
+
+ +
+
+ + ~/audio/albums/ + {{ albums | length }} albums +
+
+ {% if albums | length > 0 %} +
+ + + + + + + + + + + {% for row in albums %} + + + + + + + {% endfor %} + +
albumstatussongsactions
{{ row.album.title }} + {% if row.album.published %} + published + {% else %} + draft + {% endif %} + {{ row.track_count }} + +
+
+ {% else %} +
+

no albums yet

+

$ create an album to group your songs into a release

+ - {% else %} -
-

No albums yet.

-

Create an album before uploading tracks.

-
- New album -
-
- {% endif %} -
+
+ {% endif %}
{% endblock content %} diff --git a/assets/views/admin/audio/new_album.html b/assets/views/admin/audio/new_album.html index 0cff7e7..9d7d9ed 100644 --- a/assets/views/admin/audio/new_album.html +++ b/assets/views/admin/audio/new_album.html @@ -1,77 +1,100 @@ {% extends "admin/base.html" %} -{% block title %}New Audio Album{% endblock title %} +{% block title %}New album{% endblock title %} +{% block crumb %}audio/new-album{% endblock crumb %} {% block content %} -
-
-
-

New Audio Album

-

Create a container for uploaded tracks.

-
- Back to albums +
+
+

+ root@universal-web:/admin/audio# + mkdir albums/new +

+

new album

+

// fill in the details, then tick the songs to include.

+ +
-
-
-
-
- - +
+
+ + ~/audio/albums/new +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

optional — png, jpg, webp or gif; uploaded right here, shown on the album page.

+
+ +
+ + +
+ +
+ +
+ + {% if available_tracks | length > 0 %} +
+ {% for song in available_tracks %} + + {% endfor %}
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {% if available_tracks | length > 0 %} -
- {% for song in available_tracks %} - - {% endfor %} +

only songs that aren’t in an album yet are shown. you can add or remove songs after creating the album.

+ {% else %} +
+
+ + no free songs to add — + upload a song first, + or create the album empty and add songs later. +
- {% else %} -
- No ungrouped songs available. -
- {% endif %}
+ {% endif %} +
- + -
- - Cancel -
- -
+
+ + cancel +
+
{% endblock content %} diff --git a/assets/views/admin/audio/songs.html b/assets/views/admin/audio/songs.html index 458130d..6c27afa 100644 --- a/assets/views/admin/audio/songs.html +++ b/assets/views/admin/audio/songs.html @@ -1,85 +1,106 @@ {% extends "admin/base.html" %} {% block title %}Songs{% endblock title %} +{% block crumb %}audio/songs{% endblock crumb %} {% block content %} -
-
-
-

Songs

-

Publish songs directly; albums only group them together.

-
- +
+
+

+ root@universal-web:/admin/audio# + ls songs/ +

+

songs

+

// step 1 — every audio file you upload becomes a song.

+ +
-
-
- {% if tracks | length > 0 %} -
- - - - - - - - - - - - {% for track in tracks %} - - - - - - - - {% endfor %} - -
SongGroupFileStatusActions
{{ track.title }} - {% if track.album_id %} - Album - {% else %} - Ungrouped - {% endif %} - {{ track.audio_file_id }} - {% if track.published %} - Published - {% else %} - Draft - {% endif %} - -
- Play - {% if track.published %} -
- -
- {% else %} -
- -
- {% endif %} -
- -
-
-
+
+

# how audio works

+
+ [1] + upload a song — pick an audio file here; it becomes a song you can publish. +
+
+ [2] + make an album (optional) — group songs together with a cover and a track order. +
+

a song can be published on its own (a single) or as part of an album — your choice.

+
+ +
+
+ + ~/audio/songs/ + {{ tracks | length }} songs +
+
+ {% if tracks | length > 0 %} +
+ + + + + + + + + + + {% for track in tracks %} + + + + + + + {% endfor %} + +
songwherestatusactions
{{ track.title }} + {% if track.album_id %} + in an album + {% else %} + single + {% endif %} + + {% if track.published %} + published + {% else %} + draft + {% endif %} + +
+ play + {% if track.published %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ +
+
+
+
+ {% else %} +
+

no songs yet

+

$ upload your first audio file to get started

+ - {% else %} -
-

No songs yet.

-

Upload a song, then group it into an album when needed.

- -
- {% endif %} -
+
+ {% endif %}
{% endblock content %} diff --git a/assets/views/admin/audio/tracks.html b/assets/views/admin/audio/tracks.html index d826335..44dd88a 100644 --- a/assets/views/admin/audio/tracks.html +++ b/assets/views/admin/audio/tracks.html @@ -1,104 +1,130 @@ {% extends "admin/base.html" %} -{% block title %}{{ album.title }} Tracks{% endblock title %} +{% block title %}{{ album.title }} — tracks{% endblock title %} +{% block crumb %}audio/{{ album.slug }}{% endblock crumb %} {% block content %} -
-
-
-

{{ album.title }}

-

Uploaded tracks for this album.

-
- +
+
+

+ root@universal-web:/admin/audio/{{ album.slug }}# + ls +

+

{{ album.title }}

+

+ // album · {{ tracks | length }} song(s) · + {% if album.published %}published{% else %}draft{% endif %} +

+ +
-
-
- {% if available_tracks | length > 0 %} -
-
- - -
- -
- {% endif %} +
+

# two ways to add a song to this album

+
+ [a] + upload a new file straight into the album — use the button above. +
+
+ [b] + pick an existing song that isn’t in any album yet — use the form below. +
+
- {% if tracks | length > 0 %} -
- - - - - - - - - - - - {% for track in tracks %} - - - - - - - - {% endfor %} - -
TrackFileStatusFeaturedActions
- {% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }} - {{ track.audio_file_id }} - {% if track.published %} - Published - {% else %} - Draft - {% endif %} - - {% if track.featured %} - Yes - {% else %} - No - {% endif %} - -
- Play - {% if track.published %} -
- -
- {% else %} -
- -
- {% endif %} -
- -
-
- -
-
-
+
+
+ + ~/audio/{{ album.slug }}/tracklist + {{ tracks | length }} songs +
+
+ {% if available_tracks | length > 0 %} +
+
+ + +

these are songs you’ve uploaded that aren’t in an album yet.

- {% else %} -
-

No tracks yet.

-

Upload the first audio file for this album.

- + + +
+ {% endif %} + + {% if tracks | length > 0 %} +
+ + + + + + + + + + + + {% for track in tracks %} + + + + + + + + {% endfor %} + +
#songstatusfeaturedactions
{% if track.track_number %}{{ track.track_number }}{% else %}—{% endif %}{{ track.title }} + {% if track.published %} + published + {% else %} + draft + {% endif %} + + {% if track.featured %} + featured + {% else %} + + {% endif %} + +
+ play + {% if track.published %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ +
+
+ +
+
+
+
+ {% else %} +
+

this album has no songs yet

+

$ upload a file into the album, or add an existing song above

+ - {% endif %} -
+
+ {% endif %}
{% endblock content %} diff --git a/assets/views/admin/audio/upload_track.html b/assets/views/admin/audio/upload_track.html index 9264d69..205c252 100644 --- a/assets/views/admin/audio/upload_track.html +++ b/assets/views/admin/audio/upload_track.html @@ -1,67 +1,83 @@ {% extends "admin/base.html" %} -{% block title %}Upload Track{% endblock title %} +{% block title %}Upload song{% endblock title %} +{% block crumb %}audio/upload{% endblock crumb %} {% block content %} -
-
-
-

Upload Track

- {% if album %} -

{{ album.title }}

- {% else %} -

Ungrouped song

- {% endif %} -
+
+
+

+ root@universal-web:/admin/audio# + {% if album %}cp song.mp3 {{ album.slug }}/{% else %}cp song.mp3 songs/{% endif %} +

+

upload a song

{% if album %} - Back to tracks +

// goes straight into the album “{{ album.title }}”.

{% else %} - Back to songs +

// uploads as a standalone song (a single) — you can add it to an album later.

{% endif %}
+
+ {% if album %} + [ cancel ] + {% else %} + [ cancel ] + {% endif %} +
+
+ +
+
+ + {% if album %}~/audio/{{ album.slug }}/upload{% else %}~/audio/songs/upload{% endif %} +
+
+ {% if album %} +
+ {% else %} + + {% endif %} +
+ + +

required — mp3, wav, ogg, flac, aac, m4a or webm.

+
+ +
+ + +

optional — leave blank to use the audio file’s name.

+
-
-
{% if album %} - - {% else %} - +
+ + +

optional — this song’s position in the album’s track list.

+
{% endif %} -
- - -
-
- - -
+ -
- - -
+ - - - - -
- - {% if album %} - Cancel - {% else %} - Cancel - {% endif %} -
- -
+
+ + {% if album %} + cancel + {% else %} + cancel + {% endif %} +
+
{% endblock content %} diff --git a/assets/views/admin/index.html b/assets/views/admin/index.html index 67ab785..fa3a20e 100644 --- a/assets/views/admin/index.html +++ b/assets/views/admin/index.html @@ -68,7 +68,7 @@

audio

-

create albums and upload tracks.

+

upload songs, then group them into albums.

diff --git a/src/controllers/media.rs b/src/controllers/media.rs index dd9afba..5029af8 100644 --- a/src/controllers/media.rs +++ b/src/controllers/media.rs @@ -36,15 +36,16 @@ const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024; pub const AUDIO_STORAGE_DIR: &str = "audio"; pub const IMAGE_STORAGE_DIR: &str = "images"; -#[derive(Debug, Deserialize)] -struct AlbumForm { - title: String, - description: Option, - cover_image_id: Option, +/// Album-create form, parsed manually from `multipart/form-data` so the page +/// can both upload a cover image and submit any number of `track_ids` +/// checkboxes (a urlencoded `Form` can't deserialize repeated keys into a Vec). +struct ParsedAlbumForm { + title: Option, artist: Option, release_date: Option, - published: Option, - #[serde(default)] + description: Option, + published: bool, + cover: Option>, track_ids: Vec, } @@ -104,10 +105,6 @@ fn normalize_empty(value: Option) -> Option { }) } -fn is_checked(value: &Option) -> bool { - value.as_deref().is_some_and(|value| value == "on" || value == "true" || value == "1") -} - fn safe_filename(filename: &str) -> Result<&str> { if filename.is_empty() || filename.contains('/') @@ -275,6 +272,73 @@ async fn read_track_upload( Ok((data, title, track_number, featured, published)) } +/// Parse the new-album `multipart/form-data` body: text fields, an optional +/// cover image file, and zero or more `track_ids` checkbox values. +async fn read_album_form(mut multipart: Multipart) -> Result { + let mut form = ParsedAlbumForm { + title: None, + artist: None, + release_date: None, + description: None, + published: false, + cover: None, + track_ids: Vec::new(), + }; + + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? + { + let name = field.name().unwrap_or("").to_string(); + if name == "cover" { + let mut data = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? + { + data.extend_from_slice(&chunk); + if data.len() > IMAGE_MAX_BYTES { + return Err(Error::BadRequest(format!( + "cover image is larger than {} MB", + IMAGE_MAX_BYTES / 1024 / 1024 + ))); + } + } + // An unselected file input still sends an empty `cover` part. + if !data.is_empty() { + form.cover = Some(data); + } + } else { + let value = field + .text() + .await + .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; + match name.as_str() { + "title" => form.title = normalize_empty(Some(value)), + "artist" => form.artist = normalize_empty(Some(value)), + "release_date" => form.release_date = normalize_empty(Some(value)), + "description" => form.description = normalize_empty(Some(value)), + "published" => { + form.published = value == "on" || value == "true" || value == "1"; + } + "track_ids" => { + let trimmed = value.trim(); + if !trimmed.is_empty() { + let id = Uuid::parse_str(trimmed) + .map_err(|_| Error::BadRequest("invalid song selection".to_string()))?; + form.track_ids.push(id); + } + } + _ => {} + } + } + } + + Ok(form) +} + async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result { let base = slugify(title); let mut slug = base.clone(); @@ -557,34 +621,50 @@ async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine, S async fn admin_album_create( auth: auth::JWT, State(ctx): State, - Form(params): Form, + multipart: Multipart, ) -> Result { let admin_user = admin::current_admin(auth, &ctx).await?; - let published = is_checked(¶ms.published); - let release_date = normalize_empty(params.release_date) + let form = read_album_form(multipart).await?; + + let title = form + .title + .ok_or_else(|| Error::BadRequest("album title is required".to_string()))?; + let release_date = form + .release_date .and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()); + // Store the uploaded cover (if any) and keep its filename as cover_image_id. + let cover_image_id = match form.cover { + Some(data) => { + let extension = detect_image_extension(&data)?; + Some(store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?) + } + None => None, + }; + let album = audio_albums::ActiveModel { id: Set(Uuid::new_v4()), - title: Set(params.title.clone()), - slug: Set(unique_album_slug(&ctx, ¶ms.title).await?), - description: Set(normalize_empty(params.description)), - cover_image_id: Set(normalize_empty(params.cover_image_id)), - artist: Set(normalize_empty(params.artist)), + title: Set(title.clone()), + slug: Set(unique_album_slug(&ctx, &title).await?), + description: Set(form.description), + cover_image_id: Set(cover_image_id), + artist: Set(form.artist), release_date: Set(release_date), - published: Set(published), + published: Set(form.published), uploader_id: Set(admin_user.id), view_count: Set(0), - published_at: Set(published.then(|| Utc::now().into())), + published_at: Set(form.published.then(|| Utc::now().into())), ..Default::default() } .insert(&ctx.db) .await?; - for track_id in params.track_ids { + for track_id in form.track_ids { let track = track_by_id(&ctx, track_id).await?; if track.album_id.is_some() { - return Err(Error::BadRequest("selected song already belongs to an album".to_string())); + return Err(Error::BadRequest( + "selected song already belongs to an album".to_string(), + )); } let mut active = track.into_active_model(); @@ -922,7 +1002,7 @@ pub fn routes() -> Routes { .add("/admin/images/upload", post(admin_image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024))) .add("/admin/audio/albums", get(admin_albums)) .add("/admin/audio/albums/create", get(admin_album_new)) - .add("/admin/audio/albums/create", post(admin_album_create)) + .add("/admin/audio/albums/create", post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024))) .add("/admin/audio/tracks", get(admin_tracks)) .add("/admin/audio/tracks/upload", get(admin_song_upload_form)) .add("/admin/audio/tracks/upload-file", post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))