feat(quick-8): create duplicate-tracks-dialog component

- Step through duplicate tracks one-by-one with Add/Skip buttons
- Apply to all remaining toggle for batch operations
- Dispatches playlist-action-complete when done
- Follows project wa-dialog patterns from track-details and phantom-resolver
This commit is contained in:
2026-03-01 11:13:32 -05:00
parent 83de934c39
commit 9f3ba2b9d4
@@ -0,0 +1,346 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/switch/switch.js';
import { AddTracksToPlaylist } from '@go/playlist/Service';
import { formatMilliseconds } from '@utils/time';
interface DuplicateTrack {
FilePath: string;
Title: string;
Artist: string;
Album: string;
Duration: string;
}
/**
* Modal dialog for resolving duplicate tracks when adding
* to a playlist. Steps through each duplicate one at a time,
* allowing the user to Add or Skip with an "apply to all"
* toggle for batch operations.
*
* @fires playlist-action-complete - When all tracks have been processed.
*/
@customElement('duplicate-tracks-dialog')
export class DuplicateTracksDialog extends LitElement {
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
@state() private duplicates: DuplicateTrack[] = [];
@state() private currentIndex = 0;
@state() private applyToAll = false;
private playlistId = 0;
private uniquePaths: string[] = [];
private tracksToAdd: string[] = [];
/** Opens the dialog. Called by playlist-picker when duplicates are found. */
show(
playlistId: number,
duplicates: DuplicateTrack[],
uniquePaths: string[],
): void {
this.playlistId = playlistId;
this.duplicates = duplicates;
this.uniquePaths = uniquePaths;
this.currentIndex = 0;
this.applyToAll = false;
this.tracksToAdd = [];
this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true;
});
}
close(): void {
if (this.dialog) this.dialog.open = false;
}
// =================================================================
// STYLES
// =================================================================
static override styles = css`
wa-dialog {
--width: 480px;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border, #444);
border-radius: 8px;
}
wa-dialog::part(title) {
font-size: 16px;
font-weight: 600;
color: var(--yj-text-primary, #fff);
padding: 16px 20px 8px;
}
wa-dialog::part(header-actions) {
padding: 16px 20px 8px;
}
wa-dialog::part(close-button__base) {
color: var(--yj-text-tertiary, #888);
}
wa-dialog::part(body) {
padding: 0 20px 20px;
}
.summary {
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
margin-bottom: 16px;
}
.summary strong {
color: var(--yj-text-primary, #fff);
}
.progress {
font-size: 12px;
color: var(--yj-text-tertiary, #888);
margin-bottom: 12px;
}
.track-card {
background: var(--yj-bg-elevated, #343a40);
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
}
.track-title {
font-size: 15px;
font-weight: 600;
color: var(--yj-text-primary, #fff);
margin-bottom: 4px;
word-break: break-word;
}
.track-artist {
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
margin-bottom: 2px;
}
.track-album {
font-size: 13px;
color: var(--yj-text-tertiary, #888);
margin-bottom: 2px;
}
.track-duration {
font-size: 12px;
color: var(--yj-text-tertiary, #888);
font-variant-numeric: tabular-nums;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
padding: 6px 16px;
border-radius: 4px;
border: 1px solid var(--yj-border, #444);
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
font-size: 13px;
cursor: pointer;
font-family: inherit;
transition: background-color 0.15s ease;
}
.btn:hover {
background: var(--yj-bg-overlay, #495057);
}
.btn-primary {
background: var(--yj-accent, #ffd43b);
color: #000;
border-color: var(--yj-accent, #ffd43b);
}
.btn-primary:hover {
background: var(--yj-accent-hover, #ffe066);
border-color: var(--yj-accent-hover, #ffe066);
}
`;
// =================================================================
// HANDLERS
// =================================================================
private handleAdd = () => {
const current = this.duplicates[this.currentIndex];
if (!current) return;
this.tracksToAdd.push(current.FilePath);
if (this.applyToAll) {
// Add all remaining duplicates.
for (
let i = this.currentIndex + 1;
i < this.duplicates.length;
i++
) {
this.tracksToAdd.push(
this.duplicates[i]!.FilePath,
);
}
void this.finalize();
return;
}
this.currentIndex++;
if (this.currentIndex >= this.duplicates.length) {
void this.finalize();
} else {
this.requestUpdate();
}
};
private handleSkip = () => {
if (this.applyToAll) {
// Skip all remaining — finalize immediately.
void this.finalize();
return;
}
this.currentIndex++;
if (this.currentIndex >= this.duplicates.length) {
void this.finalize();
} else {
this.requestUpdate();
}
};
private async finalize(): Promise<void> {
const combined = [
...this.uniquePaths,
...this.tracksToAdd,
];
if (combined.length > 0) {
try {
await AddTracksToPlaylist(
this.playlistId,
combined,
);
} catch (err) {
console.error(
'Failed to add tracks to playlist:',
err,
);
}
}
this.dispatchEvent(
new CustomEvent('playlist-action-complete', {
bubbles: true,
composed: true,
}),
);
this.close();
}
// =================================================================
// RENDER
// =================================================================
override render() {
const current = this.duplicates[this.currentIndex];
return html`
<wa-dialog label="Duplicate Tracks Found">
${current ? this.renderContent(current) : nothing}
</wa-dialog>
`;
}
private renderContent(current: DuplicateTrack) {
const total = this.duplicates.length;
const num = this.currentIndex + 1;
return html`
<div class="summary">
<strong>${total} duplicate track${total !== 1 ? 's' : ''}</strong>
already exist in this playlist.
</div>
<div class="progress">
Track ${num} of ${total}
</div>
<div class="track-card">
<div class="track-title">
${current.Title || current.FilePath}
</div>
${current.Artist
? html`<div class="track-artist">
${current.Artist}
</div>`
: nothing}
${current.Album
? html`<div class="track-album">
${current.Album}
</div>`
: nothing}
<div class="track-duration">
${formatMilliseconds(current.Duration)}
</div>
</div>
<div class="toggle-row">
<wa-switch
size="small"
?checked=${this.applyToAll}
@wa-change=${(e: Event) => {
this.applyToAll = (
e.target as HTMLInputElement
).checked;
}}
>
Apply to all remaining
</wa-switch>
</div>
<div class="actions">
<button class="btn" @click=${this.handleSkip}>
Skip
</button>
<button
class="btn btn-primary"
@click=${this.handleAdd}
>
Add
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'duplicate-tracks-dialog': DuplicateTracksDialog;
}
}