Compare commits

...

4 Commits

Author SHA1 Message Date
david.alvarez beea9a0dcf Fix feed order (DESC) and cover art in RSS
- Change feed SQL ORDER BY to DESC (newest releases first)
- Remove broken img tag from RSS description (JS onerror not supported in RSS readers)
- Replace with link to Cover Art Archive
2026-07-02 16:44:51 +02:00
david.alvarez 2deb335953 Fix MB API deserialization: primary-type and secondary-types are strings not objects 2026-07-02 16:29:48 +02:00
david.alvarez e7f9b6838e Fix search dropdown, async artist add, OOB swaps
- Fix search results dropdown: absolute position relative to search wrapper
- Make release group fetching async via Vert.x executeBlocking (avoid blocking add)
- Add OOB swap for artist list so +Add works from any page
- Simplify search-results template styling
- Add Response type for combined OOB responses
2026-07-02 16:11:38 +02:00
david.alvarez dcdd04df8c Fix UI: navbar layout, search form, theme switcher, content centering
- Wrap search in HTMX form for reliable parameter serialization
- Replace daisyUI data-choose-theme with custom JS (localStorage persistence)
- Restructure navbar: logo+tabs left, search center, theme+settings right
- Center releases page with max-w-4xl constrained layout
- Center artist page content
- Add loading spinner centering
2026-07-02 16:02:42 +02:00
10 changed files with 82 additions and 55 deletions
@@ -1,30 +1,25 @@
package com.discdrop.client.dto; package com.discdrop.client.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List; import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record ReleaseGroupBrowseResponse( public record ReleaseGroupBrowseResponse(
List<ReleaseGroup> releaseGroups, @JsonProperty("release-groups") List<ReleaseGroup> releaseGroups,
int releaseGroupCount @JsonProperty("release-group-count") int releaseGroupCount
) { ) {
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record ReleaseGroup( public record ReleaseGroup(
String id, String id,
String title, String title,
String firstReleaseDate, @JsonProperty("first-release-date") String firstReleaseDate,
PrimaryType primaryType, @JsonProperty("primary-type") String primaryType,
List<SecondaryType> secondaryTypes, @JsonProperty("secondary-types") List<String> secondaryTypes,
List<ArtistCredit> artistCredit @JsonProperty("artist-credit") List<ArtistCredit> artistCredit
) { ) {
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record PrimaryType(String id, String name) {} public record ArtistCredit(@JsonProperty("artist") Artist artist) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record SecondaryType(String id, String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ArtistCredit(Artist artist) {
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Artist(String id, String name) {} public record Artist(String id, String name) {}
} }
@@ -221,7 +221,7 @@ public class DatabaseService {
var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList()); var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList());
sql += " AND primary_type IN (" + typePlaceholders + ")"; sql += " AND primary_type IN (" + typePlaceholders + ")";
} }
sql += " ORDER BY first_release_date ASC"; sql += " ORDER BY first_release_date DESC";
try (Connection conn = getConnection(); try (Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) { PreparedStatement ps = conn.prepareStatement(sql)) {
@@ -43,15 +43,14 @@ public class ReleaseGroupService {
total = Math.min(page.releaseGroupCount(), MAX_RELEASE_GROUPS); total = Math.min(page.releaseGroupCount(), MAX_RELEASE_GROUPS);
for (var rg : page.releaseGroups()) { for (var rg : page.releaseGroups()) {
String primaryType = rg.primaryType() != null ? rg.primaryType().name() : "Other"; String primaryType = rg.primaryType() != null ? rg.primaryType() : "Other";
if (!monitoredTypes.contains(primaryType)) continue; if (!monitoredTypes.contains(primaryType)) continue;
String secondaryTypes = "[]"; String secondaryTypes = "[]";
if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) { if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) {
try { try {
secondaryTypes = json.writeValueAsString( secondaryTypes = json.writeValueAsString(rg.secondaryTypes());
rg.secondaryTypes().stream().map(s -> s.name()).toList());
} catch (Exception e) { } catch (Exception e) {
secondaryTypes = "[]"; secondaryTypes = "[]";
} }
@@ -79,8 +79,6 @@ public class RSSFeedBuilder {
private String buildDescription(ReleaseGroup rg) { private String buildDescription(ReleaseGroup rg) {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.append("<img src=\"https://coverartarchive.org/release-group/")
.append(rg.mbid()).append("/front\" alt=\"Cover\" onerror=\"this.style.display='none'\">");
sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>") sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>")
.append(" <i>").append(escHtml(rg.title())).append("</i><br>"); .append(" <i>").append(escHtml(rg.title())).append("</i><br>");
sb.append("Type: ").append(escHtml(rg.primaryType())); sb.append("Type: ").append(escHtml(rg.primaryType()));
@@ -88,6 +86,8 @@ public class RSSFeedBuilder {
sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes())); sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes()));
} }
sb.append("<br>First Release Date: ").append(rg.firstReleaseDate()); sb.append("<br>First Release Date: ").append(rg.firstReleaseDate());
sb.append("<br><a href=\"https://coverartarchive.org/release-group/")
.append(rg.mbid()).append("/front\">Cover Art</a>");
sb.append("</p>"); sb.append("</p>");
return sb.toString(); return sb.toString();
} }
@@ -12,6 +12,7 @@ import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -55,12 +56,14 @@ public class ArtistController {
@POST @POST
@Path("/{mbid}/track") @Path("/{mbid}/track")
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
public TemplateInstance trackArtist(@PathParam("mbid") String mbid) { public Response trackArtist(@PathParam("mbid") String mbid) {
var artist = artistService.addArtist(mbid); var artist = artistService.addArtist(mbid);
var types = getMonitoredTypes(artist); var types = getMonitoredTypes(artist);
releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), types); releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), types);
var allArtists = artistService.findAll(); var allArtists = artistService.findAll();
return artistListTemplate.data("artists", allArtists); var listHtml = artistListTemplate.data("artists", allArtists).render();
var emptySearch = searchResultsTemplate.data("artists", List.of()).render();
return Response.ok(emptySearch + listHtml).build();
} }
@DELETE @DELETE
+2 -2
View File
@@ -1,9 +1,9 @@
{#include layouts/base} {#include layouts/base}
{#page-content} {#page-content}
<div class="container mx-auto p-4"> <div class="w-full max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-4">🎤 Artists</h2> <h2 class="text-2xl font-bold mb-4">🎤 Artists</h2>
<div id="artist-list" hx-get="/api/artists/list" hx-trigger="load"> <div id="artist-list" hx-get="/api/artists/list" hx-trigger="load">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg block mx-auto"></span>
</div> </div>
</div> </div>
{/page-content} {/page-content}
@@ -1,3 +1,4 @@
<div id="artist-list" hx-swap-oob="true">
{#if artists.isEmpty()} {#if artists.isEmpty()}
<div class="card bg-base-100 shadow-xl p-8 text-center"> <div class="card bg-base-100 shadow-xl p-8 text-center">
<p class="text-lg text-base-content/60">No artists tracked yet</p> <p class="text-lg text-base-content/60">No artists tracked yet</p>
@@ -30,35 +31,40 @@
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" <input type="checkbox" class="checkbox checkbox-xs checkbox-primary"
{#if artist.albumMonitored}checked{/if} {#if artist.albumMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=album" hx-patch="/api/artists/{artist.mbid}/monitor?type=album"
hx-target="#artist-list" hx-swap="innerHTML"> hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs">Album</span> <span class="label-text text-xs">Album</span>
</label> </label>
<label class="label cursor-pointer gap-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.singleMonitored}checked{/if} {#if artist.singleMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=single" hx-patch="/api/artists/{artist.mbid}/monitor?type=single"
hx-target="#artist-list" hx-swap="innerHTML"> hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs">Single</span> <span class="label-text text-xs">Single</span>
</label> </label>
<label class="label cursor-pointer gap-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.epMonitored}checked{/if} {#if artist.epMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=ep" hx-patch="/api/artists/{artist.mbid}/monitor?type=ep"
hx-target="#artist-list" hx-swap="innerHTML"> hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs">EP</span> <span class="label-text text-xs">EP</span>
</label> </label>
<label class="label cursor-pointer gap-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.broadcastMonitored}checked{/if} {#if artist.broadcastMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast" hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast"
hx-target="#artist-list" hx-swap="innerHTML"> hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs">Broadcast</span> <span class="label-text text-xs">Broadcast</span>
</label> </label>
<label class="label cursor-pointer gap-1"> <label class="label cursor-pointer gap-1">
<input type="checkbox" class="checkbox checkbox-xs" <input type="checkbox" class="checkbox checkbox-xs"
{#if artist.otherMonitored}checked{/if} {#if artist.otherMonitored}checked{/if}
hx-patch="/api/artists/{artist.mbid}/monitor?type=other" hx-patch="/api/artists/{artist.mbid}/monitor?type=other"
hx-target="#artist-list" hx-swap="innerHTML"> hx-target="#artist-list"
hx-swap="innerHTML">
<span class="label-text text-xs">Other</span> <span class="label-text text-xs">Other</span>
</label> </label>
</div> </div>
@@ -78,3 +84,4 @@
</table> </table>
</div> </div>
{/if} {/if}
</div>
@@ -1,4 +1,4 @@
<div class="card bg-base-100 shadow-xl mt-1 max-h-64 overflow-y-auto"> <div class="bg-base-100 shadow-xl border border-base-300 rounded-box mt-1 max-h-64 overflow-y-auto">
{#if artists.isEmpty()} {#if artists.isEmpty()}
<div class="p-3 text-sm text-base-content/60">No artists found</div> <div class="p-3 text-sm text-base-content/60">No artists found</div>
{#else} {#else}
@@ -18,9 +18,9 @@
{#else} {#else}
<button class="btn btn-primary btn-xs" <button class="btn btn-primary btn-xs"
hx-post="/api/artists/{artist.mbid}/track" hx-post="/api/artists/{artist.mbid}/track"
hx-target="#artist-list" hx-target="#search-results"
hx-swap="innerHTML" hx-swap="innerHTML"
onclick="this.closest('#search-results').innerHTML=''; document.querySelector('[name=q]').value=''"> onclick="document.getElementById('search-input').value=''">
+ Add + Add
</button> </button>
{/if} {/if}
+43 -20
View File
@@ -7,40 +7,63 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style> <style>
.search-dropdown { #search-wrapper {
position: relative;
}
#search-results {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 0; left: 0;
right: 0; right: 0;
z-index: 50; z-index: 50;
width: 100%;
} }
</style> </style>
<script>
document.addEventListener('DOMContentLoaded', function () {
var saved = localStorage.getItem('daisyui-theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
});
function setTheme(sel) {
var theme = sel.value;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('daisyui-theme', theme);
}
</script>
</head> </head>
<body class="min-h-screen bg-base-200"> <body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-lg px-4"> <div class="navbar bg-base-100 shadow-lg px-4">
<div class="flex-1 gap-2"> <div class="navbar-start">
<a href="/" class="text-xl font-bold">🎵 DiscDrop</a> <div class="flex items-center gap-2">
<div class="tabs tabs-box ml-4 hidden sm:flex"> <a href="/" class="text-xl font-bold">🎵 DiscDrop</a>
<a href="/" class="tab {#if activeTab == 'releases'}tab-active{/if}">Releases</a> <div class="tabs tabs-box ml-2 hidden sm:flex">
<a href="/artists" class="tab {#if activeTab == 'artists'}tab-active{/if}">Artists</a> <a href="/" class="tab {#if activeTab == 'releases'}tab-active{/if}">Releases</a>
<a href="/artists" class="tab {#if activeTab == 'artists'}tab-active{/if}">Artists</a>
</div>
</div> </div>
</div> </div>
<div class="flex-none gap-2 relative"> <div class="navbar-center">
<div class="form-control"> <div id="search-wrapper" class="w-48 md:w-64">
<input type="text" placeholder="Search artists..." <form hx-get="/api/artists/search"
class="input input-bordered input-sm w-48 md:w-64" hx-target="#search-results"
name="q" hx-swap="innerHTML"
hx-get="/api/artists/search" hx-trigger="keyup changed delay:300ms from:#search-input">
hx-trigger="keyup changed delay:300ms" <input id="search-input"
hx-target="#search-results" type="text"
hx-swap="innerHTML" placeholder="Search artists..."
autocomplete="off"> class="input input-bordered input-sm w-full"
name="q"
autocomplete="off">
</form>
<div id="search-results"></div>
</div> </div>
<div id="search-results" class="search-dropdown"></div> </div>
<select class="select select-bordered select-sm w-full max-w-[8rem]" data-choose-theme> <div class="navbar-end gap-2">
<option value="dark">Dark</option> <select class="select select-bordered select-sm w-full max-w-[8rem]"
onchange="setTheme(this)">
<option value="dark" selected>Dark</option>
<option value="light">Light</option> <option value="light">Light</option>
<option value="synthwave">Synthwave</option> <option value="synthwave">Synthwave</option>
<option value="retro">Retro</option> <option value="retro">Retro</option>
@@ -60,7 +83,7 @@
</main> </main>
<dialog id="settingsModal" class="modal"> <dialog id="settingsModal" class="modal">
<div class="modal-box" id="settings-modal-content" <div class="modal-box max-w-md" id="settings-modal-content"
hx-get="/api/settings/form" hx-get="/api/settings/form"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
+4 -4
View File
@@ -1,11 +1,11 @@
{#include layouts/base} {#include layouts/base}
{#page-content} {#page-content}
<div class="container mx-auto p-4"> <div class="w-full max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-4">📡 Releases</h2> <h2 class="text-2xl font-bold mb-4 text-center">📡 Releases</h2>
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load"> <div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg block mx-auto"></span>
</div> </div>
<div class="mt-4 flex gap-4"> <div class="mt-6 flex justify-center gap-4">
<a href="/feed/rss.xml" class="btn btn-primary">📥 RSS Feed</a> <a href="/feed/rss.xml" class="btn btn-primary">📥 RSS Feed</a>
<a href="/feed/opml" class="btn btn-ghost">📋 OPML</a> <a href="/feed/opml" class="btn btn-ghost">📋 OPML</a>
</div> </div>