feat(playlist-drop-overlay): add component and directive for file drop overlay functionality

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-25 22:08:03 +02:00
1 parent d51ea4b2b0
commit caed2ce396
5 files changed
+356

No files matched your search

@@ -0,0 +1,2 @@
export * from './playlist-drop-overlay.component';
export * from './playlist-drop-zone.directive';
@@ -0,0 +1,31 @@
@if (isVisible()) {
<div class="drop-overlay__backdrop" aria-hidden="true"></div>
<div
class="drop-overlay__card"
[class.is-rejected]="isRejected()"
role="status"
aria-live="polite"
>
<div class="drop-overlay__icon-wrap">
<mat-icon class="drop-overlay__icon">
{{ isRejected() ? 'error_outline' : 'file_download' }}
</mat-icon>
</div>
<h2 class="drop-overlay__title">
{{
(isRejected()
? rejectionKey()
: 'WORKSPACE.SHELL.DROP_OVERLAY_TITLE'
) | translate
}}
</h2>
<p class="drop-overlay__body">
{{
(isRejected()
? 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_BODY'
: 'WORKSPACE.SHELL.DROP_OVERLAY_BODY'
) | translate
}}
</p>
</div>
}
@@ -0,0 +1,149 @@
:host {
position: fixed;
inset: 0;
z-index: 9999;
display: none;
pointer-events: none;
align-items: center;
justify-content: center;
padding: 24px;
}
:host.is-visible {
display: flex;
animation: drop-overlay-fade 0.15s ease-out;
}
.drop-overlay__backdrop {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.drop-overlay__card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
padding: clamp(28px, 5vh, 48px) clamp(28px, 5vw, 56px);
min-width: 320px;
max-width: min(520px, 88vw);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.12),
rgba(37, 99, 235, 0.08)
);
border: 2px dashed rgba(96, 165, 250, 0.55);
border-radius: 18px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
color: #f1f5f9;
animation: drop-overlay-scale 0.18s ease-out;
&.is-rejected {
border-color: rgba(248, 113, 113, 0.55);
background: linear-gradient(
145deg,
rgba(248, 113, 113, 0.12),
rgba(220, 38, 38, 0.08)
);
.drop-overlay__icon-wrap {
background: linear-gradient(135deg, #f87171, #dc2626);
box-shadow: 0 8px 22px rgba(220, 38, 38, 0.4);
}
}
}
.drop-overlay__icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 8px 22px rgba(59, 130, 246, 0.45);
margin-bottom: 4px;
}
.drop-overlay__icon {
font-size: 36px;
width: 36px;
height: 36px;
color: white;
}
.drop-overlay__title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 600;
font-size: 1.25rem;
margin: 0;
letter-spacing: -0.01em;
}
.drop-overlay__body {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.9rem;
margin: 0;
opacity: 0.78;
line-height: 1.45;
}
@keyframes drop-overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes drop-overlay-scale {
from {
opacity: 0;
transform: translateY(6px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
:host.is-visible {
animation: drop-overlay-fade 0.1s ease-out;
}
.drop-overlay__card {
animation: drop-overlay-fade 0.1s ease-out;
}
}
@media (prefers-color-scheme: light) {
.drop-overlay__backdrop {
background: rgba(15, 23, 42, 0.45);
}
.drop-overlay__card {
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.96),
rgba(241, 245, 249, 0.96)
);
color: #0f172a;
border-color: rgba(37, 99, 235, 0.55);
&.is-rejected {
background: linear-gradient(
145deg,
rgba(254, 226, 226, 0.96),
rgba(252, 165, 165, 0.92)
);
border-color: rgba(220, 38, 38, 0.6);
}
}
}
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
export type PlaylistDropOverlayState =
| { kind: 'idle' }
| { kind: 'dragging' }
| { kind: 'rejected'; reason: 'unsupported' | 'empty' | 'read-error' };
@Component({
selector: 'app-playlist-drop-overlay',
templateUrl: './playlist-drop-overlay.component.html',
styleUrl: './playlist-drop-overlay.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, TranslatePipe],
host: {
'[class.is-visible]': 'isVisible()',
'[attr.aria-hidden]': '!isVisible()',
},
})
export class PlaylistDropOverlayComponent {
readonly state = input<PlaylistDropOverlayState>({ kind: 'idle' });
readonly isVisible = (): boolean => this.state().kind !== 'idle';
readonly isRejected = (): boolean => this.state().kind === 'rejected';
readonly rejectionKey = (): string => {
const current = this.state();
if (current.kind !== 'rejected') return '';
switch (current.reason) {
case 'unsupported':
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_UNSUPPORTED';
case 'empty':
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_EMPTY';
case 'read-error':
return 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_READ_ERROR';
}
};
}
@@ -0,0 +1,134 @@
import {
DestroyRef,
Directive,
ElementRef,
HostListener,
OnInit,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import { fromEvent, merge } from 'rxjs';
import type { PlaylistDropOverlayState } from './playlist-drop-overlay.component';
const REJECTED_DISMISS_MS = 1800;
@Directive({
selector: '[appPlaylistDropZone]',
exportAs: 'playlistDropZone',
})
export class PlaylistDropZoneDirective implements OnInit {
private readonly host = inject(ElementRef<HTMLElement>);
private readonly destroyRef = inject(DestroyRef);
private readonly importService = inject(PlaylistFileImportService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private dragDepth = 0;
private rejectionTimer: ReturnType<typeof setTimeout> | null = null;
readonly overlayState = signal<PlaylistDropOverlayState>({ kind: 'idle' });
ngOnInit(): void {
merge(
fromEvent<KeyboardEvent>(window, 'keydown'),
fromEvent(window, 'blur')
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
if (event instanceof KeyboardEvent && event.key !== 'Escape') {
return;
}
this.resetState();
});
}
@HostListener('dragenter', ['$event'])
onDragEnter(event: DragEvent): void {
if (!this.isFileDrag(event)) return;
event.preventDefault();
this.dragDepth += 1;
if (this.overlayState().kind === 'rejected') return;
this.overlayState.set({ kind: 'dragging' });
}
@HostListener('dragover', ['$event'])
onDragOver(event: DragEvent): void {
if (!this.isFileDrag(event)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
}
@HostListener('dragleave', ['$event'])
onDragLeave(event: DragEvent): void {
if (!this.isFileDrag(event)) return;
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0 && this.overlayState().kind === 'dragging') {
this.overlayState.set({ kind: 'idle' });
}
}
@HostListener('drop', ['$event'])
async onDrop(event: DragEvent): Promise<void> {
if (!this.isFileDrag(event)) return;
event.preventDefault();
this.dragDepth = 0;
const file = event.dataTransfer?.files?.[0];
if (!file) {
this.overlayState.set({ kind: 'idle' });
return;
}
const result = await this.importService.importFile(file);
if (result.ok === true) {
this.overlayState.set({ kind: 'idle' });
this.snackBar.open(
this.translate.instant(
'WORKSPACE.SHELL.DROP_IMPORT_SUCCESS',
{ title: result.title }
),
undefined,
{ duration: 3000 }
);
return;
}
this.flashRejection(result.reason);
}
private isFileDrag(event: DragEvent): boolean {
const types = event.dataTransfer?.types;
if (!types) return false;
return Array.from(types).includes('Files');
}
private flashRejection(
reason: 'unsupported' | 'empty' | 'read-error'
): void {
this.overlayState.set({ kind: 'rejected', reason });
this.clearRejectionTimer();
this.rejectionTimer = setTimeout(() => {
this.overlayState.set({ kind: 'idle' });
this.rejectionTimer = null;
}, REJECTED_DISMISS_MS);
}
private resetState(): void {
this.dragDepth = 0;
this.clearRejectionTimer();
this.overlayState.set({ kind: 'idle' });
}
private clearRejectionTimer(): void {
if (this.rejectionTimer !== null) {
clearTimeout(this.rejectionTimer);
this.rejectionTimer = null;
}
}
}