From ffc5d9639cf7c916a4f846590ae0d67cf13afe27 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 12 Mar 2026 19:50:59 -0400 Subject: [PATCH] feat(12-02): replace config-page library section with full library management UI - Add library list with name, path, track count per library - Add Library button opens folder picker, auto-creates library - Inline rename with Enter/Escape via overflow menu - Removal confirmation dialog with real impact counts (tracks, playlists, queue) - Toast notification with removal summary after library removal - Add GetAllLibrariesWithTrackCounts + Info type to backend Library struct - Add Wails binding stubs for AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact - Add Info, RemovalImpact, RemovalSummary types to models.ts - Remove old single-directory library config UI (GetLibraryDirectory/SetLibraryDirectory) --- backend/library/query.go | 39 ++ .../src/components/config-page/config-page.ts | 509 ++++++++++++++++-- frontend/wailsjs/go/library/Library.d.ts | 10 + frontend/wailsjs/go/library/Library.js | 20 + frontend/wailsjs/go/models.ts | 56 ++ 5 files changed, 575 insertions(+), 59 deletions(-) diff --git a/backend/library/query.go b/backend/library/query.go index b40155c..3def8b8 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -443,3 +443,42 @@ func (l *Library) GetAllGenresWithCounts() ( return genres, nil } + +// Info contains library metadata enriched with track count +// for the frontend settings UI. +type Info struct { + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + TrackCount int64 `json:"trackCount"` +} + +// GetAllLibrariesWithTrackCounts returns all libraries with their +// audio file counts. Typically 1-5 libraries so the loop is trivial. +func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) { + libs, err := l.db.Queries.GetAllLibraries(l.ctx) + if err != nil { + return nil, fmt.Errorf("could not get libraries: %w", err) + } + + result := make([]Info, 0, len(libs)) + + for _, lib := range libs { + count, countErr := l.db.Queries.CountAudioFilesByLibrary(l.ctx, lib.ID) + if countErr != nil { + l.logger.Error("could not count tracks for library", + "libraryID", lib.ID, "error", countErr) + + count = 0 + } + + result = append(result, Info{ + ID: lib.ID, + Name: lib.Name, + Path: lib.Path, + TrackCount: count, + }) + } + + return result, nil +} diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index ad2025a..7c1d4fa 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -8,16 +8,21 @@ import { CancelCurrentScan, CancelAllScans, ScanAllLibraries, + ScanLibrary, PauseScan, ResumeScan, + AddLibrary, + RenameLibrary, + RemoveLibrary, + GetRemovalImpact, + GetAllLibrariesWithTrackCounts, } from '@go/library/Library'; import { - GetLibraryDirectory, - SetLibraryDirectory, GetScanConcurrency, SetScanConcurrency, } from '@go/config/Config'; import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; +import type { library } from '@go/models'; import { ThemeController } from '@store/controllers/theme-controller'; import { TrackListController } from '@store/controllers/tracklist-controller'; import { FavoritesController } from '@store/controllers/favorites-controller'; @@ -337,8 +342,15 @@ export class ConfigPage extends LitElement { @state() private playlists: playlist.Summary[] = []; // --- Library state --- - @state() private libraryDirectory = ''; - @state() private selectedDirectory = ''; + @state() private libraries: library.Info[] = []; + @state() private editingLibraryId: number | null = null; + @state() private editingName = ''; + @state() private removingLibraryId: number | null = null; + @state() private removalImpact: library.RemovalImpact | null = null; + @state() private isRemoving = false; + @state() private toastMessage = ''; + @state() private toastVisible = false; + @state() private activeMenuId: number | null = null; @state() private scanning = false; @state() private statusMessage = ''; @state() private scanProgress: ScanProgress | null = null; @@ -357,6 +369,7 @@ export class ConfigPage extends LitElement { existingAction: string; } | null = null; + private toastTimer?: ReturnType; private cancelScanStarted?: () => void; private cancelScanProgress?: () => void; private cancelScanComplete?: () => void; @@ -365,6 +378,9 @@ export class ConfigPage extends LitElement { private cancelScanCancelled?: () => void; private cancelScanQueued?: () => void; private cancelScanQueueDrained?: () => void; + private cancelLibraryAdded?: () => void; + private cancelLibraryRenamed?: () => void; + private cancelLibraryRemoved?: () => void; static override styles = css` :host { @@ -812,6 +828,182 @@ export class ConfigPage extends LitElement { color: var(--yj-accent, #ffd43b); } + /* Library management */ + .library-list { + display: flex; + flex-direction: column; + gap: 0; + } + + .library-row { + display: flex; + align-items: center; + gap: 0.75em; + padding: 0.6em 0.5em; + border-bottom: 1px solid var(--yj-border-subtle, #333); + } + + .library-row:last-child { + border-bottom: none; + } + + .library-row:hover { + background: var(--yj-bg-elevated, #343a40); + border-radius: 4px; + } + + .library-name { + flex: 1; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .library-name:hover { + color: var(--yj-accent, #ffd43b); + } + + .library-path { + color: var(--yj-text-tertiary, #868e96); + font-size: var(--yj-text-sm, 13px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; + } + + .library-count { + color: var(--yj-text-tertiary, #868e96); + font-size: var(--yj-text-sm, 13px); + white-space: nowrap; + flex-shrink: 0; + } + + .edit-input { + flex: 1; + padding: 0.3em 0.5em; + background: var(--yj-bg-elevated, #343a40); + border: 1px solid var(--yj-accent, #ffd43b); + border-radius: 4px; + color: var(--yj-text-primary, #fff); + font-size: 0.9em; + font-family: inherit; + outline: none; + } + + .overflow-wrapper { + position: relative; + flex-shrink: 0; + } + + .overflow-btn { + cursor: pointer; + border: none; + background: transparent; + color: var(--yj-text-tertiary, #868e96); + font-size: 1.1em; + padding: 0.2em 0.4em; + letter-spacing: 2px; + border-radius: 4px; + } + + .overflow-btn:hover { + background: var(--yj-bg-overlay, #495057); + color: var(--yj-text-primary, #fff); + } + + .overflow-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--yj-bg-surface, #2a2a2a); + border: 1px solid var(--yj-border, #444); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 100; + min-width: 120px; + padding: 4px 0; + } + + .overflow-item { + padding: 0.5em 1em; + font-size: var(--yj-text-sm, 13px); + cursor: pointer; + white-space: nowrap; + } + + .overflow-item:hover { + background: var(--yj-bg-elevated, #343a40); + } + + .overflow-item--danger { + color: var(--yj-error, #e03131); + } + + .overflow-item--danger:hover { + background: color-mix( + in srgb, + var(--yj-error, #e03131) 10%, + var(--yj-bg-elevated, #343a40) + ); + } + + .add-library-btn { + margin-top: 0.75em; + margin-bottom: 1em; + } + + .toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: var(--yj-bg-surface, #2a2a2a); + color: var(--yj-text-primary, #fff); + padding: 0.75em 1.5em; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + font-size: var(--yj-text-sm, 13px); + z-index: 2000; + border: 1px solid var(--yj-border, #444); + animation: toast-in 0.2s ease-out; + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translateX(-50%) + translateY(10px); + } + to { + opacity: 1; + transform: translateX(-50%) + translateY(0); + } + } + + .spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; + vertical-align: middle; + margin-right: 4px; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + /* Keyboard shortcuts section */ .shortcut-category { margin-bottom: 16px; @@ -880,7 +1072,7 @@ export class ConfigPage extends LitElement { override connectedCallback(): void { super.connectedCallback(); - this.loadLibraryConfig(); + void this.loadLibraries(); void this.loadPlaylists(); this.scrollMode = localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover'; @@ -917,6 +1109,20 @@ export class ConfigPage extends LitElement { Events.LibraryScanQueueDrained, this.handleScanQueueDrained, ); + this.cancelLibraryAdded = EventsOn( + Events.LibraryAdded, + () => void this.loadLibraries(), + ); + this.cancelLibraryRenamed = EventsOn( + Events.LibraryRenamed, + () => void this.loadLibraries(), + ); + this.cancelLibraryRemoved = EventsOn( + Events.LibraryRemoved, + () => void this.loadLibraries(), + ); + + document.addEventListener('click', this.handleDocumentClick); } override disconnectedCallback(): void { @@ -929,21 +1135,27 @@ export class ConfigPage extends LitElement { this.cancelScanCancelled?.(); this.cancelScanQueued?.(); this.cancelScanQueueDrained?.(); + this.cancelLibraryAdded?.(); + this.cancelLibraryRenamed?.(); + this.cancelLibraryRemoved?.(); + + document.removeEventListener('click', this.handleDocumentClick); + + if (this.toastTimer) clearTimeout(this.toastTimer); } - private async loadLibraryConfig(): Promise { + private async loadLibraries(): Promise { try { - const [dir, mode] = await Promise.all([ - GetLibraryDirectory(), + const [libs, mode] = await Promise.all([ + GetAllLibrariesWithTrackCounts(), GetScanConcurrency(), ]); - this.libraryDirectory = dir; - this.selectedDirectory = dir; + this.libraries = libs ?? []; this.concurrencyMode = mode; } catch (err) { console.error( - 'Failed to load library config:', + 'Failed to load libraries:', err, ); } @@ -1072,37 +1284,121 @@ export class ConfigPage extends LitElement { this.cancelMetrics = null; }; - private handleDirectoryBrowse = async (): Promise => { + private handleAddLibrary = async (): Promise => { try { const dir = await DirectoryPicker(); if (dir) { - this.selectedDirectory = dir; + await AddLibrary(dir); } } catch (err) { console.error( - 'Directory picker failed:', + 'Failed to add library:', err, ); } }; - private handleSaveDirectory = async (): Promise => { - if (!this.selectedDirectory) return; + private handleStartRename = (id: number, name: string): void => { + this.editingLibraryId = id; + this.editingName = name; + this.activeMenuId = null; + }; - try { - await SetLibraryDirectory( - this.selectedDirectory, - ); - this.libraryDirectory = - this.selectedDirectory; - this.statusMessage = - 'Library directory saved. A scan will start automatically if the directory changed.'; - } catch (err) { - this.statusMessage = `Failed to save directory: ${err}`; + private handleRenameKeyDown = async (e: KeyboardEvent): Promise => { + if (e.key === 'Enter') { + e.preventDefault(); + + if (this.editingLibraryId !== null && this.editingName.trim()) { + try { + await RenameLibrary(this.editingLibraryId, this.editingName.trim()); + } catch (err) { + console.error('Failed to rename library:', err); + } + } + + this.editingLibraryId = null; + this.editingName = ''; + } else if (e.key === 'Escape') { + this.editingLibraryId = null; + this.editingName = ''; } }; + private handleRenameInput = (e: InputEvent): void => { + this.editingName = (e.target as HTMLInputElement).value; + }; + + private handleRescanLibrary = (id: number): void => { + this.activeMenuId = null; + void ScanLibrary(id); + }; + + private handleRemoveClick = async (id: number): Promise => { + this.activeMenuId = null; + + try { + const impact = await GetRemovalImpact(id); + + this.removalImpact = impact; + this.removingLibraryId = id; + } catch (err) { + console.error('Failed to get removal impact:', err); + } + }; + + private handleConfirmRemove = async (): Promise => { + if (this.removingLibraryId === null) return; + + const id = this.removingLibraryId; + const lib = this.libraries.find((l) => l.id === id); + const libName = lib?.name ?? 'Library'; + + this.isRemoving = true; + + try { + const summary = await RemoveLibrary(id); + + this.removingLibraryId = null; + this.removalImpact = null; + this.isRemoving = false; + this.showToast( + `Removed '${libName}' (${summary?.tracksDeleted ?? 0} tracks deleted)`, + ); + } catch (err) { + this.isRemoving = false; + console.error('Failed to remove library:', err); + } + }; + + private handleCancelRemove = (): void => { + this.removingLibraryId = null; + this.removalImpact = null; + this.isRemoving = false; + }; + + private toggleOverflowMenu = (id: number, e: Event): void => { + e.stopPropagation(); + this.activeMenuId = this.activeMenuId === id ? null : id; + }; + + private handleDocumentClick = (): void => { + if (this.activeMenuId !== null) { + this.activeMenuId = null; + } + }; + + private showToast(message: string): void { + this.toastMessage = message; + this.toastVisible = true; + + if (this.toastTimer) clearTimeout(this.toastTimer); + + this.toastTimer = setTimeout(() => { + this.toastVisible = false; + }, 4000); + } + private handleConcurrencyChange = ( e: CustomEvent, ): void => { @@ -1456,13 +1752,6 @@ export class ConfigPage extends LitElement { // COMPUTED // =================================================================== - private get directoryChanged(): boolean { - return ( - this.selectedDirectory !== - this.libraryDirectory - ); - } - private get hasRescanPhases(): boolean { if (!this.metrics) return false; @@ -1901,36 +2190,88 @@ export class ConfigPage extends LitElement { // --- Library section --- private renderLibrarySection() { + const removingLib = this.libraries.find( + (l) => l.id === this.removingLibraryId, + ); + return html` - +
+ ${this.libraries.map( + (lib) => html` +
+ ${this.editingLibraryId === lib.id + ? html` + e.stopPropagation()} + /> + ` + : html` + this.handleStartRename(lib.id, lib.name)} + > + ${lib.name} + + `} + ${lib.path} + + ${lib.trackCount} tracks + +
+ + ${this.activeMenuId === lib.id + ? html` +
e.stopPropagation()} + > +
this.handleStartRename(lib.id, lib.name)} + > + Rename +
+
this.handleRescanLibrary(lib.id)} + > + Rescan +
+
void this.handleRemoveClick(lib.id)} + > + Remove +
+
+ ` + : nothing} +
+
+ `, + )} +
- ${this.directoryChanged - ? html` -
- -
- ` - : nothing} + ` : nothing} + + ${this.removingLibraryId !== null && this.removalImpact + ? html` +
+
e.stopPropagation()} + > +
+ Remove Library +
+
+ Remove '${removingLib?.name}'? + This will delete + ${this.removalImpact.trackCount} + tracks, affect + ${this.removalImpact.playlistsAffected} + playlists, and remove + ${this.removalImpact.queueItemCount} + queue items. +
+
+ + +
+
+
+ ` + : nothing}
+ + ${this.toastVisible + ? html`
${this.toastMessage}
` + : nothing} `; } diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 15ea18b..f546b8e 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -3,6 +3,8 @@ import {library} from '../models'; import {context} from '../models'; +export function AddLibrary(arg1:string):Promise; + export function CancelAllScans():Promise; export function CancelCurrentScan():Promise; @@ -13,6 +15,8 @@ export function FullRescan():Promise; export function GetAlbumTracks(arg1:number):Promise>; +export function GetAllLibrariesWithTrackCounts():Promise>; + export function GetAlbumsByArtist(arg1:number):Promise>; export function GetAllAlbums():Promise>; @@ -23,6 +27,8 @@ export function GetAllGenresWithCounts():Promise>; export function GetAllTracks():Promise>; +export function GetRemovalImpact(arg1:number):Promise; + export function GetScanQueueLength():Promise; export function GetTracksByGenre(arg1:string):Promise>; @@ -33,6 +39,10 @@ export function IsScanPaused():Promise; export function PauseScan():Promise; +export function RemoveLibrary(arg1:number):Promise; + +export function RenameLibrary(arg1:number, arg2:string):Promise; + export function QueuedLibraryNames():Promise>; export function ResumeScan():Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index a0d718e..8b2e819 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function AddLibrary(arg1) { + return window['go']['library']['Library']['AddLibrary'](arg1); +} + export function CancelAllScans() { return window['go']['library']['Library']['CancelAllScans'](); } @@ -22,6 +26,10 @@ export function GetAlbumTracks(arg1) { return window['go']['library']['Library']['GetAlbumTracks'](arg1); } +export function GetAllLibrariesWithTrackCounts() { + return window['go']['library']['Library']['GetAllLibrariesWithTrackCounts'](); +} + export function GetAlbumsByArtist(arg1) { return window['go']['library']['Library']['GetAlbumsByArtist'](arg1); } @@ -42,6 +50,10 @@ export function GetAllTracks() { return window['go']['library']['Library']['GetAllTracks'](); } +export function GetRemovalImpact(arg1) { + return window['go']['library']['Library']['GetRemovalImpact'](arg1); +} + export function GetScanQueueLength() { return window['go']['library']['Library']['GetScanQueueLength'](); } @@ -62,6 +74,14 @@ export function PauseScan() { return window['go']['library']['Library']['PauseScan'](); } +export function RemoveLibrary(arg1) { + return window['go']['library']['Library']['RemoveLibrary'](arg1); +} + +export function RenameLibrary(arg1, arg2) { + return window['go']['library']['Library']['RenameLibrary'](arg1, arg2); +} + export function QueuedLibraryNames() { return window['go']['library']['Library']['QueuedLibraryNames'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 33aa647..94dbcdc 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -40,6 +40,62 @@ export namespace library { this.Name = source["Name"]; } } + export class Info { + id: number; + name: string; + path: string; + trackCount: number; + + static createFrom(source: any = {}) { + return new Info(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.path = source["path"]; + this.trackCount = source["trackCount"]; + } + } + export class RemovalImpact { + trackCount: number; + playlistsAffected: number; + queueItemCount: number; + + static createFrom(source: any = {}) { + return new RemovalImpact(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.trackCount = source["trackCount"]; + this.playlistsAffected = source["playlistsAffected"]; + this.queueItemCount = source["queueItemCount"]; + } + } + export class RemovalSummary { + tracksDeleted: number; + artistsRemoved: number; + albumsRemoved: number; + genresRemoved: number; + playlistsAffected: number; + queueItemsRemoved: number; + + static createFrom(source: any = {}) { + return new RemovalSummary(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.tracksDeleted = source["tracksDeleted"]; + this.artistsRemoved = source["artistsRemoved"]; + this.albumsRemoved = source["albumsRemoved"]; + this.genresRemoved = source["genresRemoved"]; + this.playlistsAffected = source["playlistsAffected"]; + this.queueItemsRemoved = source["queueItemsRemoved"]; + } + } export class GenreWithCount { Name: string; TrackCount: number;