From 9d84a115432141f734714f5ad5b45197487bfc7f Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sat, 21 Feb 2026 14:16:37 -0500 Subject: [PATCH] drag and drop enhancements, fixed playlist creation using old db entries, added icons-only sidebar when small width --- backend/database/database.go | 29 ++ .../components/playlist-view/playlist-view.ts | 266 +++++++++++++++++- .../src/components/queue-panel/queue-panel.ts | 85 +++--- .../src/components/sidebar/app-sidebar.ts | 81 ++++-- 4 files changed, 392 insertions(+), 69 deletions(-) diff --git a/backend/database/database.go b/backend/database/database.go index 98b3f1e..6ce9a33 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -52,6 +52,17 @@ func NewDB(logger *slog.Logger) (*DB, error) { db.SetMaxOpenConns(1) // SQLite only supports one writer at a time + // Enable foreign key enforcement — SQLite disables it by + // default, which means ON DELETE CASCADE will not work without + // this pragma. + if _, err := db.ExecContext( + dbCtx, "PRAGMA foreign_keys = ON", + ); err != nil { + return nil, fmt.Errorf( + "could not enable foreign keys: %w", err, + ) + } + // Execute SQL files from the embedded schemas directory logger.Debug("reading sql schema files from embedded directory") @@ -86,6 +97,24 @@ func NewDB(logger *slog.Logger) (*DB, error) { } } + // Remove orphaned playlist_tracks left behind by past deletes + // that ran without foreign key enforcement. + orphanResult, err := db.ExecContext( + dbCtx, + "DELETE FROM playlist_tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)", + ) + if err != nil { + logger.Warn( + "could not clean orphaned playlist tracks", + "err", err, + ) + } else if n, _ := orphanResult.RowsAffected(); n > 0 { + logger.Info( + "Cleaned orphaned playlist tracks", + "deleted", n, + ) + } + // Get generated queries queries := sqlcgen.New(db) diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 079566d..5021419 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import { CreatePlaylist, + CreatePlaylistWithTracks, AddTracksToPlaylist, RemoveTracksFromPlaylist, DeletePlaylist, @@ -166,6 +167,18 @@ export class PlaylistView /** Index of the playlist currently hovered during a drag. */ @state() private dragOverPlaylistIndex = -1; + /** True when dragging over empty space in the playlist list. */ + @state() private dragOverEmptyZone = false; + + /** True when dragging over the "New Playlist" button. */ + @state() private dragOverNewButton = false; + + /** + * File paths from a drop that landed outside any playlist. + * When non-empty the create form is in "create-and-add" mode. + */ + private pendingDropPaths: string[] = []; + private dragImageEl: HTMLElement | null = null; @query('#context-menu') @@ -352,11 +365,19 @@ export class PlaylistView font-family: inherit; } - .new-playlist-button:hover { + .new-playlist-button:hover, + .new-playlist-button.drag-over { border-color: var(--yj-accent, #ffd43b); color: var(--yj-accent, #ffd43b); } + .new-playlist-button.drag-over { + background-color: var( + --yj-accent-bg-strong, + rgba(255, 212, 59, 0.15) + ); + } + .create-form { display: flex; align-items: center; @@ -422,6 +443,8 @@ export class PlaylistView padding: 0; margin: 0; list-style: none; + display: flex; + flex-direction: column; } .playlist-item { @@ -592,6 +615,58 @@ export class PlaylistView margin: 4px 0; } + .drop-zone-icon { + display: none; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 12px; + background: var( + --yj-accent-bg-strong, + rgba(255, 212, 59, 0.18) + ); + color: var(--yj-accent, #ffd43b); + font-size: 28px; + pointer-events: none; + } + + .empty-state.drag-over { + background-color: var( + --yj-accent-bg-strong, + rgba(255, 212, 59, 0.15) + ); + outline: 2px dashed + var(--yj-accent, #ffd43b); + outline-offset: -4px; + } + + .empty-state.drag-over .drop-zone-icon { + display: flex; + } + + .drop-zone { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + } + + .drop-zone.drag-over { + background-color: var( + --yj-accent-bg-strong, + rgba(255, 212, 59, 0.15) + ); + outline: 2px dashed + var(--yj-accent, #ffd43b); + outline-offset: -4px; + } + + .drop-zone.drag-over .drop-zone-icon { + display: flex; + } + #context-menu { z-index: 200; } @@ -1095,6 +1170,16 @@ export class PlaylistView if (this.dragOverPlaylistIndex !== index) { this.dragOverPlaylistIndex = index; } + + // A specific playlist is targeted — hide the + // "new playlist" drop zone highlights. + if (this.dragOverEmptyZone) { + this.dragOverEmptyZone = false; + } + + if (this.dragOverNewButton) { + this.dragOverNewButton = false; + } }; private onPlaylistDragLeave = ( @@ -1122,6 +1207,7 @@ export class PlaylistView index: number, ) => { e.preventDefault(); + e.stopPropagation(); this.dragOverPlaylistIndex = -1; const payload = getDragPayload(e); @@ -1161,6 +1247,116 @@ export class PlaylistView } }; + // ================================================================= + // Drop target (empty space → create new playlist) + // ================================================================= + + private onEmptyZoneDragOver = (e: DragEvent) => { + if (!hasTrackPayload(e)) return; + + e.preventDefault(); + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + + // Only show the "new playlist" drop zone when + // not hovering a specific playlist item. + if ( + this.dragOverPlaylistIndex === -1 && + !this.dragOverEmptyZone + ) { + this.dragOverEmptyZone = true; + } + + if (this.dragOverNewButton) { + this.dragOverNewButton = false; + } + }; + + private onEmptyZoneDragLeave = (e: DragEvent) => { + const related = + e.relatedTarget as Node | null; + + if (!related || !this.contains(related)) { + this.dragOverEmptyZone = false; + } + }; + + private onEmptyZoneDrop = (e: DragEvent) => { + e.preventDefault(); + this.dragOverEmptyZone = false; + + const payload = getDragPayload(e); + + if ( + !payload || + payload.filePaths.length === 0 + ) { + return; + } + + this.pendingDropPaths = payload.filePaths; + this.creating = true; + this.newPlaylistName = ''; + + void this.updateComplete.then(() => { + const input = + this.shadowRoot?.querySelector( + '.create-form input', + ); + + input?.focus(); + }); + }; + + // ================================================================= + // Drop target ("New Playlist" button) + // ================================================================= + + private onNewButtonDragOver = (e: DragEvent) => { + if (!hasTrackPayload(e)) return; + + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + + if (!this.dragOverNewButton) { + this.dragOverNewButton = true; + } + + // Hide the empty-zone highlight while + // hovering the button. + if (this.dragOverEmptyZone) { + this.dragOverEmptyZone = false; + } + }; + + private onNewButtonDragLeave = ( + e: DragEvent, + ) => { + const related = + e.relatedTarget as Node | null; + const btn = + this.shadowRoot?.querySelector( + '.new-playlist-button', + ); + + if (btn && !btn.contains(related)) { + this.dragOverNewButton = false; + } + }; + + private onNewButtonDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.dragOverNewButton = false; + this.onEmptyZoneDrop(e); + }; + private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; @@ -1430,16 +1626,28 @@ export class PlaylistView private handleCancelCreate = () => { this.creating = false; this.newPlaylistName = ''; + this.pendingDropPaths = []; }; private handleCreatePlaylist = async () => { const name = this.newPlaylistName.trim(); if (!name) return; + const paths = this.pendingDropPaths; + try { - await CreatePlaylist(name); + if (paths.length > 0) { + await CreatePlaylistWithTracks( + name, + paths, + ); + } else { + await CreatePlaylist(name); + } + this.creating = false; this.newPlaylistName = ''; + this.pendingDropPaths = []; await this.refreshPlaylists(); } catch (err) { console.error( @@ -1491,9 +1699,15 @@ export class PlaylistView Import