From 69729536499719b6d7caf6914b084f554b78d8b3 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sat, 21 Mar 2026 13:25:21 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20smart=20playlist=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20layout,=20defaults,=20free-form=20input,=20case-ins?= =?UTF-8?q?ensitive=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Details view: - Skip evaluation on new playlists (was returning all 25k tracks) - Go straight to editor on auto-edit instead of awaiting loadTracks Editor: - Default sort to Random instead of None, remove None option - Hide sort direction button when sort is Random - Fixed-width field (160px) and operator (140px) columns, value fills remaining space - Preview fills remaining vertical space (flex layout) instead of fixed 200px max-height - Preview scrolls independently while rules/options stay pinned Combobox: - Accept free-form text on blur and Enter — no longer requires selection from dropdown - Enables typing values like 'indie' that may not be exact DB entries Backend: - Case-insensitive text matching: COLLATE NOCASE on is/is_not/is_any_of operators - Applies to both regular fields and genre subqueries - LIKE operators were already case-insensitive (SQLite default) --- backend/smartplaylist/smartplaylist.go | 12 ++++---- backend/smartplaylist/smartplaylist_test.go | 24 ++++++++-------- frontend/src/components/combobox/combobox.ts | 25 +++++++++++++++-- .../smart-playlist-details.ts | 13 +++++++-- .../smart-playlist-editor.ts | 28 ++++++++++++------- 5 files changed, 69 insertions(+), 33 deletions(-) diff --git a/backend/smartplaylist/smartplaylist.go b/backend/smartplaylist/smartplaylist.go index 0b78294..b3246c3 100644 --- a/backend/smartplaylist/smartplaylist.go +++ b/backend/smartplaylist/smartplaylist.go @@ -194,13 +194,13 @@ func buildGenreSubquery(rule Rule) (string, []any, error) { switch rule.Operator { case "is": - return subquery + "g.name = ?)", []any{rule.Value}, nil + return subquery + "g.name = ? COLLATE NOCASE)", []any{rule.Value}, nil case "is_not": return `af.id NOT IN ( SELECT rg_sub.recording_id FROM recording_genres rg_sub JOIN genres g ON rg_sub.genre_id = g.id - WHERE g.name = ?)`, []any{rule.Value}, nil + WHERE g.name = ? COLLATE NOCASE)`, []any{rule.Value}, nil case "is_any_of": var values []string @@ -225,7 +225,7 @@ func buildGenreSubquery(rule Rule) (string, []any, error) { condArgs := make([]any, len(values)) for i, v := range values { - placeholders[i] = "?" + placeholders[i] = "? COLLATE NOCASE" condArgs[i] = v } @@ -255,7 +255,7 @@ func buildCondition( return col + " = ?", []any{v}, nil } - return col + " = ?", []any{rule.Value}, nil + return col + " = ? COLLATE NOCASE", []any{rule.Value}, nil case "is_not": if isNumeric { @@ -267,7 +267,7 @@ func buildCondition( return col + " != ?", []any{v}, nil } - return col + " != ?", []any{rule.Value}, nil + return col + " != ? COLLATE NOCASE", []any{rule.Value}, nil case "contains": return col + " LIKE ?", @@ -308,7 +308,7 @@ func buildCondition( condArgs := make([]any, len(values)) for i, v := range values { - placeholders[i] = "?" + placeholders[i] = "? COLLATE NOCASE" condArgs[i] = v } diff --git a/backend/smartplaylist/smartplaylist_test.go b/backend/smartplaylist/smartplaylist_test.go index b805335..308cd45 100644 --- a/backend/smartplaylist/smartplaylist_test.go +++ b/backend/smartplaylist/smartplaylist_test.go @@ -264,8 +264,8 @@ func TestBuildWhereClause_TextIs(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if clause != "artist_name = ?" { - t.Errorf("clause = %q, want %q", clause, "artist_name = ?") + if clause != "artist_name = ? COLLATE NOCASE" { + t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE") } if len(args) != 1 || args[0] != "Queen" { @@ -283,9 +283,9 @@ func TestBuildWhereClause_TextIsNot(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if clause != "artist_name != ?" { + if clause != "artist_name != ? COLLATE NOCASE" { t.Errorf("clause = %q, want %q", - clause, "artist_name != ?") + clause, "artist_name != ? COLLATE NOCASE") } if len(args) != 1 || args[0] != "Queen" { @@ -386,9 +386,9 @@ func TestBuildWhereClause_TextIsAnyOf(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if clause != "artist_name IN (?, ?)" { + if clause != "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)" { t.Errorf("clause = %q, want %q", - clause, "artist_name IN (?, ?)") + clause, "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") } if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" { @@ -541,8 +541,8 @@ func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) { clause) } - if !strings.Contains(clause, "g.name = ?") { - t.Errorf("genre 'is' should have g.name = ?: %q", clause) + if !strings.Contains(clause, "g.name = ? COLLATE NOCASE") { + t.Errorf("genre 'is' should have g.name = ? COLLATE NOCASE: %q", clause) } if len(args) != 1 || args[0] != "Rock" { @@ -596,9 +596,9 @@ func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) { ) } - if !strings.Contains(clause, "g.name IN (?, ?)") { + if !strings.Contains(clause, "g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") { t.Errorf( - "genre 'is_any_of' should have g.name IN (?, ?): %q", + "genre 'is_any_of' should have g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE): %q", clause, ) } @@ -647,9 +647,9 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if clause != "artist_name = ? AND year > ?" { + if clause != "artist_name = ? COLLATE NOCASE AND year > ?" { t.Errorf("clause = %q, want %q", - clause, "artist_name = ? AND year > ?") + clause, "artist_name = ? COLLATE NOCASE AND year > ?") } if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) { diff --git a/frontend/src/components/combobox/combobox.ts b/frontend/src/components/combobox/combobox.ts index 36c4716..fd39f45 100644 --- a/frontend/src/components/combobox/combobox.ts +++ b/frontend/src/components/combobox/combobox.ts @@ -174,8 +174,24 @@ export class YjCombobox extends LitElement { // fire first. requestAnimationFrame(() => { this.open = false; - // Restore display text to the confirmed value. - this.filterText = this.value; + + // Commit free-form text: if the user typed something that + // isn't in the option list, accept it as the value anyway. + const typed = this.filterText.trim(); + if (typed && typed !== this.value) { + this.value = typed; + this.filterText = typed; + this.dispatchEvent( + new CustomEvent('combobox-change', { + bubbles: true, + composed: true, + detail: { value: typed }, + }), + ); + } else { + // Restore display text to the confirmed value. + this.filterText = this.value; + } }); } @@ -211,6 +227,11 @@ export class YjCombobox extends LitElement { ) { e.preventDefault(); this.selectOption(opts[this.highlightedIndex]!); + } else if (this.filterText.trim()) { + // Commit free-form text on Enter even without a + // highlighted option. + e.preventDefault(); + this.selectOption(this.filterText.trim()); } break; diff --git a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts index 640c1cb..5bf5e1c 100644 --- a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts +++ b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts @@ -261,8 +261,11 @@ export class SmartPlaylistDetails extends LitElement { .editor-container { flex: 1; - overflow: auto; + overflow: hidden; padding: 0 20px 20px; + display: flex; + flex-direction: column; + min-height: 0; } `]; @@ -270,13 +273,17 @@ export class SmartPlaylistDetails extends LitElement { // Lifecycle // ================================================================= - override async connectedCallback() { + override connectedCallback() { super.connectedCallback(); - await this.loadTracks(); if (this.autoEdit) { + // Skip evaluation for new playlists — go straight to editor. this.autoEdit = false; + this.loading = false; + this.tracks = []; this.handleEditRules(); + } else { + void this.loadTracks(); } this.playlistDeletedCleanup = EventsOn( diff --git a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts index b4886c6..d1c61ff 100644 --- a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts +++ b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts @@ -160,7 +160,7 @@ export class SmartPlaylistEditor extends LitElement { @state() private ruleRows: RuleRow[] = [emptyRule()]; @state() private limit = 0; - @state() private sortField = ''; + @state() private sortField = 'random'; @state() private sortDir = ''; @state() private previewTracks: library.Track[] = []; @state() private previewLoading = false; @@ -174,7 +174,10 @@ export class SmartPlaylistEditor extends LitElement { designTokens, css` :host { - display: block; + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; } /* ── Rule rows ────────────────────────── */ @@ -184,17 +187,18 @@ export class SmartPlaylistEditor extends LitElement { flex-direction: column; gap: 6px; padding: 12px 0 8px; + flex-shrink: 0; } .rule-row { display: grid; - grid-template-columns: 1fr 140px 1fr 28px; + grid-template-columns: 160px 140px 1fr 28px; gap: 6px; align-items: start; } .rule-row.between-row { - grid-template-columns: 1fr 140px 1fr 1fr 28px; + grid-template-columns: 160px 140px 1fr 1fr 28px; } /* ── Form controls ────────────────────── */ @@ -289,6 +293,7 @@ export class SmartPlaylistEditor extends LitElement { var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); margin-top: 4px; flex-wrap: wrap; + flex-shrink: 0; } .option-group { @@ -337,6 +342,11 @@ export class SmartPlaylistEditor extends LitElement { var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); margin-top: 8px; padding-top: 10px; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; } .preview-header { @@ -372,10 +382,11 @@ export class SmartPlaylistEditor extends LitElement { } .preview-list { - max-height: 200px; + flex: 1; overflow-y: auto; display: flex; flex-direction: column; + min-height: 0; } .preview-track { @@ -467,7 +478,7 @@ export class SmartPlaylistEditor extends LitElement { this.ruleRows = rows.length > 0 ? rows : [emptyRule()]; this.limit = parsed.limit ?? 0; - this.sortField = parsed.sort_field ?? ''; + this.sortField = parsed.sort_field || 'random'; this.sortDir = parsed.sort_dir ?? ''; } catch { this.ruleRows = [emptyRule()]; @@ -819,9 +830,6 @@ export class SmartPlaylistEditor extends LitElement { (e.target as HTMLSelectElement).value, )} > - ${SORT_FIELDS.map( (f) => html`