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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user