drag and drop enhancements, fixed playlist creation using old db entries, added icons-only sidebar when small width

This commit is contained in:
2026-02-21 14:16:37 -05:00
parent 8ad952e750
commit 9d84a11543
4 changed files with 392 additions and 69 deletions
+29
View File
@@ -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)
@@ -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<HTMLInputElement>(
'.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
</button>
<button
class="new-playlist-button"
class="new-playlist-button ${this.dragOverNewButton ? 'drag-over' : ''}"
@click=${this
.handleNewPlaylistClick}
@dragover=${this
.onNewButtonDragOver}
@dragleave=${this
.onNewButtonDragLeave}
@drop=${this
.onNewButtonDrop}
>
<wa-icon
name="plus"
@@ -1693,11 +1907,22 @@ export class PlaylistView
private renderPlaylistList() {
if (this.entries.length === 0) {
return html`
<div class="empty-state">
<div
class="empty-state ${this.dragOverEmptyZone ? 'drag-over' : ''}"
@dragover=${this.onEmptyZoneDragOver}
@dragleave=${this.onEmptyZoneDragLeave}
@drop=${this.onEmptyZoneDrop}
>
<div class="drop-zone-icon">
<wa-icon
name="plus"
></wa-icon>
</div>
<wa-icon name="list"></wa-icon>
<p>No playlists yet</p>
<p style="font-size: 12px;">
Create a playlist to get started.
Create a playlist or drop
tracks here.
</p>
</div>
`;
@@ -1707,8 +1932,21 @@ export class PlaylistView
if (visible.length === 0) {
return html`
<div class="empty-state">
<p>No playlists match your search.</p>
<div
class="empty-state ${this.dragOverEmptyZone ? 'drag-over' : ''}"
@dragover=${this.onEmptyZoneDragOver}
@dragleave=${this.onEmptyZoneDragLeave}
@drop=${this.onEmptyZoneDrop}
>
<div class="drop-zone-icon">
<wa-icon
name="plus"
></wa-icon>
</div>
<p>
No playlists match your
search.
</p>
</div>
`;
}
@@ -1727,6 +1965,20 @@ export class PlaylistView
originalIndex,
);
})}
<li
class="drop-zone ${this.dragOverEmptyZone ? 'drag-over' : ''}"
@dragover=${this
.onEmptyZoneDragOver}
@dragleave=${this
.onEmptyZoneDragLeave}
@drop=${this.onEmptyZoneDrop}
>
<div class="drop-zone-icon">
<wa-icon
name="plus"
></wa-icon>
</div>
</li>
</ul>
`;
}
@@ -57,6 +57,7 @@ export class QueuePanel
private playlistSubmenuOpen = false;
private dragOver = false;
private dragEnterCount = 0;
private dropTargetIndex = -1;
private dropTargetRafId = 0;
@@ -344,21 +345,6 @@ export class QueuePanel
outline-offset: -2px;
}
.drop-indicator {
display: none;
padding: 12px 16px;
text-align: center;
font-size: 12px;
color: var(--yj-accent, #ffd43b);
border-bottom: 1px solid
rgba(255, 212, 59, 0.2);
}
.panel-content.drag-over.empty-drag
.drop-indicator {
display: block;
}
.track-item.drop-before::before {
content: '';
position: absolute;
@@ -386,18 +372,39 @@ export class QueuePanel
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
gap: 8px;
}
.empty-state wa-icon {
font-size: 32px;
.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;
}
.panel-content.drag-over.empty-drag
.empty-state {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
}
.panel-content.drag-over.empty-drag
.drop-zone-icon {
display: flex;
}
#context-menu {
@@ -761,6 +768,7 @@ export class QueuePanel
if (!hasTrackPayload(e)) return;
e.preventDefault();
this.dragEnterCount++;
if (e.dataTransfer) {
const isInternal =
@@ -796,9 +804,16 @@ export class QueuePanel
};
private onPanelDragLeave = (_e: DragEvent) => {
// No-op: cleanup is handled by dragend / drop.
// Firing here would break due to child-boundary
// and virtualizer re-render events.
this.dragEnterCount--;
// Each child-boundary crossing fires a
// paired dragenter/dragleave. The counter
// only reaches 0 when the cursor truly
// leaves the panel.
if (this.dragEnterCount <= 0) {
this.dragEnterCount = 0;
this.cleanupDragState();
}
};
private onPanelDrop = (e: DragEvent) => {
@@ -995,6 +1010,7 @@ export class QueuePanel
if (!this.dragOver) return;
this.dragOver = false;
this.dragEnterCount = 0;
this.dropTargetIndex = -1;
if (this.dropTargetRafId) {
@@ -1255,21 +1271,14 @@ export class QueuePanel
: nothing}
</wa-popup>
<div class="drop-indicator">
Drop tracks here to add to queue
</div>
${tracks.length === 0
? html`
<div class="empty-state">
<wa-icon name="list"></wa-icon>
<p>Queue is empty</p>
<p style="font-size: 12px;">
Click a track to start
playing
</p>
? html`<div class="empty-state">
<div class="drop-zone-icon">
<wa-icon
name="plus"
></wa-icon>
</div>
`
</div>`
: html`
<lit-virtualizer
scroller
+57 -24
View File
@@ -12,9 +12,10 @@ interface NavItem {
icon: string;
}
const MIN_WIDTH = 120;
const MIN_WIDTH = 56;
const MAX_WIDTH = 400;
const DEFAULT_WIDTH = 200;
const COLLAPSE_WIDTH = 142;
@customElement('app-sidebar')
export class AppSidebar extends LitElement {
@@ -84,10 +85,31 @@ export class AppSidebar extends LitElement {
}
li.drag-hover {
background-color: var(--yj-accent-bg-strong, rgba(255, 212, 59, 0.15));
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
outline: 1px dashed var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
/* Icon-only collapsed mode */
:host(.collapsed) ul {
padding: 0.5em;
}
:host(.collapsed) li {
justify-content: center;
padding: 0.6em;
}
:host(.collapsed) li p {
display: none;
}
:host(.collapsed) li wa-icon {
font-size: 1.1em;
}
`;
/** Delay in ms before a drag-hover triggers navigation. */
@@ -99,6 +121,9 @@ export class AppSidebar extends LitElement {
@state()
private isDragging = false;
@state()
private collapsed = false;
/** Whether a track drag is in progress somewhere in the app. */
@state()
private trackDragActive = false;
@@ -155,6 +180,10 @@ export class AppSidebar extends LitElement {
this.clearDragHoverTimer();
}
override updated() {
this.classList.toggle('collapsed', this.collapsed);
}
override render() {
return html`
<div
@@ -163,33 +192,33 @@ export class AppSidebar extends LitElement {
></div>
<ul>
${this.navItems.map((item) => {
const classes = [
this.activeView === item.id
? 'active'
: '',
this.dragHoverView === item.id
? 'drag-hover'
: '',
]
.filter(Boolean)
.join(' ');
const classes = [
this.activeView === item.id
? 'active'
: '',
this.dragHoverView === item.id
? 'drag-hover'
: '',
]
.filter(Boolean)
.join(' ');
return html`
return html`
<li
class=${classes}
@click=${() =>
this.navigate(item.id)}
this.navigate(item.id)}
@dragover=${(e: DragEvent) =>
this.onNavDragOver(
e,
item.id,
)}
this.onNavDragOver(
e,
item.id,
)}
@dragleave=${() =>
this.onNavDragLeave(
item.id,
)}
this.onNavDragLeave(
item.id,
)}
@drop=${(e: DragEvent) =>
this.onNavDrop(e)}
this.onNavDrop(e)}
>
<wa-icon
name=${item.icon}
@@ -197,7 +226,7 @@ export class AppSidebar extends LitElement {
<p>${item.label}</p>
</li>
`;
})}
})}
</ul>
`;
}
@@ -212,9 +241,13 @@ export class AppSidebar extends LitElement {
const rect = this.getBoundingClientRect();
const newWidth = e.clientX - rect.left;
const clampedWidth = Math.min(Math.max(newWidth, MIN_WIDTH), MAX_WIDTH);
const clampedWidth = Math.min(
Math.max(newWidth, MIN_WIDTH),
MAX_WIDTH,
);
this.style.width = `${clampedWidth}px`;
this.collapsed = clampedWidth < COLLAPSE_WIDTH;
};
private handleMouseUp = () => {