fix: smart playlist UX polish — layout, defaults, free-form input, case-insensitive matching

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)
This commit is contained in:
2026-03-21 13:25:21 -04:00
parent 477b7ff6a2
commit 6972953649
5 changed files with 69 additions and 33 deletions
+6 -6
View File
@@ -194,13 +194,13 @@ func buildGenreSubquery(rule Rule) (string, []any, error) {
switch rule.Operator { switch rule.Operator {
case "is": case "is":
return subquery + "g.name = ?)", []any{rule.Value}, nil return subquery + "g.name = ? COLLATE NOCASE)", []any{rule.Value}, nil
case "is_not": case "is_not":
return `af.id NOT IN ( return `af.id NOT IN (
SELECT rg_sub.recording_id FROM recording_genres rg_sub SELECT rg_sub.recording_id FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id 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": case "is_any_of":
var values []string var values []string
@@ -225,7 +225,7 @@ func buildGenreSubquery(rule Rule) (string, []any, error) {
condArgs := make([]any, len(values)) condArgs := make([]any, len(values))
for i, v := range values { for i, v := range values {
placeholders[i] = "?" placeholders[i] = "? COLLATE NOCASE"
condArgs[i] = v condArgs[i] = v
} }
@@ -255,7 +255,7 @@ func buildCondition(
return col + " = ?", []any{v}, nil return col + " = ?", []any{v}, nil
} }
return col + " = ?", []any{rule.Value}, nil return col + " = ? COLLATE NOCASE", []any{rule.Value}, nil
case "is_not": case "is_not":
if isNumeric { if isNumeric {
@@ -267,7 +267,7 @@ func buildCondition(
return col + " != ?", []any{v}, nil return col + " != ?", []any{v}, nil
} }
return col + " != ?", []any{rule.Value}, nil return col + " != ? COLLATE NOCASE", []any{rule.Value}, nil
case "contains": case "contains":
return col + " LIKE ?", return col + " LIKE ?",
@@ -308,7 +308,7 @@ func buildCondition(
condArgs := make([]any, len(values)) condArgs := make([]any, len(values))
for i, v := range values { for i, v := range values {
placeholders[i] = "?" placeholders[i] = "? COLLATE NOCASE"
condArgs[i] = v condArgs[i] = v
} }
+12 -12
View File
@@ -264,8 +264,8 @@ func TestBuildWhereClause_TextIs(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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 = ?") t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE")
} }
if len(args) != 1 || args[0] != "Queen" { if len(args) != 1 || args[0] != "Queen" {
@@ -283,9 +283,9 @@ func TestBuildWhereClause_TextIsNot(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if clause != "artist_name != ?" { if clause != "artist_name != ? COLLATE NOCASE" {
t.Errorf("clause = %q, want %q", t.Errorf("clause = %q, want %q",
clause, "artist_name != ?") clause, "artist_name != ? COLLATE NOCASE")
} }
if len(args) != 1 || args[0] != "Queen" { if len(args) != 1 || args[0] != "Queen" {
@@ -386,9 +386,9 @@ func TestBuildWhereClause_TextIsAnyOf(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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", 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" { if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" {
@@ -541,8 +541,8 @@ func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) {
clause) clause)
} }
if !strings.Contains(clause, "g.name = ?") { if !strings.Contains(clause, "g.name = ? COLLATE NOCASE") {
t.Errorf("genre 'is' should have g.name = ?: %q", clause) t.Errorf("genre 'is' should have g.name = ? COLLATE NOCASE: %q", clause)
} }
if len(args) != 1 || args[0] != "Rock" { 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( 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, clause,
) )
} }
@@ -647,9 +647,9 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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", 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) { if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {
+23 -2
View File
@@ -174,8 +174,24 @@ export class YjCombobox extends LitElement {
// fire first. // fire first.
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.open = false; 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(); e.preventDefault();
this.selectOption(opts[this.highlightedIndex]!); 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; break;
@@ -261,8 +261,11 @@ export class SmartPlaylistDetails extends LitElement {
.editor-container { .editor-container {
flex: 1; flex: 1;
overflow: auto; overflow: hidden;
padding: 0 20px 20px; padding: 0 20px 20px;
display: flex;
flex-direction: column;
min-height: 0;
} }
`]; `];
@@ -270,13 +273,17 @@ export class SmartPlaylistDetails extends LitElement {
// Lifecycle // Lifecycle
// ================================================================= // =================================================================
override async connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await this.loadTracks();
if (this.autoEdit) { if (this.autoEdit) {
// Skip evaluation for new playlists — go straight to editor.
this.autoEdit = false; this.autoEdit = false;
this.loading = false;
this.tracks = [];
this.handleEditRules(); this.handleEditRules();
} else {
void this.loadTracks();
} }
this.playlistDeletedCleanup = EventsOn( this.playlistDeletedCleanup = EventsOn(
@@ -160,7 +160,7 @@ export class SmartPlaylistEditor extends LitElement {
@state() private ruleRows: RuleRow[] = [emptyRule()]; @state() private ruleRows: RuleRow[] = [emptyRule()];
@state() private limit = 0; @state() private limit = 0;
@state() private sortField = ''; @state() private sortField = 'random';
@state() private sortDir = ''; @state() private sortDir = '';
@state() private previewTracks: library.Track[] = []; @state() private previewTracks: library.Track[] = [];
@state() private previewLoading = false; @state() private previewLoading = false;
@@ -174,7 +174,10 @@ export class SmartPlaylistEditor extends LitElement {
designTokens, designTokens,
css` css`
:host { :host {
display: block; display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
} }
/* ── Rule rows ────────────────────────── */ /* ── Rule rows ────────────────────────── */
@@ -184,17 +187,18 @@ export class SmartPlaylistEditor extends LitElement {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
padding: 12px 0 8px; padding: 12px 0 8px;
flex-shrink: 0;
} }
.rule-row { .rule-row {
display: grid; display: grid;
grid-template-columns: 1fr 140px 1fr 28px; grid-template-columns: 160px 140px 1fr 28px;
gap: 6px; gap: 6px;
align-items: start; align-items: start;
} }
.rule-row.between-row { .rule-row.between-row {
grid-template-columns: 1fr 140px 1fr 1fr 28px; grid-template-columns: 160px 140px 1fr 1fr 28px;
} }
/* ── Form controls ────────────────────── */ /* ── Form controls ────────────────────── */
@@ -289,6 +293,7 @@ export class SmartPlaylistEditor extends LitElement {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
margin-top: 4px; margin-top: 4px;
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0;
} }
.option-group { .option-group {
@@ -337,6 +342,11 @@ export class SmartPlaylistEditor extends LitElement {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
margin-top: 8px; margin-top: 8px;
padding-top: 10px; padding-top: 10px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
} }
.preview-header { .preview-header {
@@ -372,10 +382,11 @@ export class SmartPlaylistEditor extends LitElement {
} }
.preview-list { .preview-list {
max-height: 200px; flex: 1;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.preview-track { .preview-track {
@@ -467,7 +478,7 @@ export class SmartPlaylistEditor extends LitElement {
this.ruleRows = rows.length > 0 ? rows : [emptyRule()]; this.ruleRows = rows.length > 0 ? rows : [emptyRule()];
this.limit = parsed.limit ?? 0; this.limit = parsed.limit ?? 0;
this.sortField = parsed.sort_field ?? ''; this.sortField = parsed.sort_field || 'random';
this.sortDir = parsed.sort_dir ?? ''; this.sortDir = parsed.sort_dir ?? '';
} catch { } catch {
this.ruleRows = [emptyRule()]; this.ruleRows = [emptyRule()];
@@ -819,9 +830,6 @@ export class SmartPlaylistEditor extends LitElement {
(e.target as HTMLSelectElement).value, (e.target as HTMLSelectElement).value,
)} )}
> >
<option value="" ?selected=${!this.sortField}>
None
</option>
${SORT_FIELDS.map( ${SORT_FIELDS.map(
(f) => html` (f) => html`
<option <option
@@ -833,7 +841,7 @@ export class SmartPlaylistEditor extends LitElement {
`, `,
)} )}
</select> </select>
${this.sortField ${this.sortField && this.sortField !== 'random'
? html` ? html`
<button <button
class="sort-dir-btn" class="sort-dir-btn"