diff --git a/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/index.ts b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/index.ts new file mode 100644 index 000000000..3923f1f92 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/index.ts @@ -0,0 +1,2 @@ +export * from './playlist-drop-overlay.component'; +export * from './playlist-drop-zone.directive'; diff --git a/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.html b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.html new file mode 100644 index 000000000..127ca9517 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.html @@ -0,0 +1,31 @@ +@if (isVisible()) { + +
+
+ + {{ isRejected() ? 'error_outline' : 'file_download' }} + +
+

+ {{ + (isRejected() + ? rejectionKey() + : 'WORKSPACE.SHELL.DROP_OVERLAY_TITLE' + ) | translate + }} +

+

+ {{ + (isRejected() + ? 'WORKSPACE.SHELL.DROP_OVERLAY_REJECTED_BODY' + : 'WORKSPACE.SHELL.DROP_OVERLAY_BODY' + ) | translate + }} +

+
+} diff --git a/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.scss b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.scss new file mode 100644 index 000000000..7dd81dfe2 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.scss @@ -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); + } + } +} diff --git a/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.ts b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.ts new file mode 100644 index 000000000..883e1d6bd --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-overlay.component.ts @@ -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({ 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'; + } + }; +} diff --git a/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-zone.directive.ts b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-zone.directive.ts new file mode 100644 index 000000000..77134ffd0 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/playlist-drop-overlay/playlist-drop-zone.directive.ts @@ -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); + 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 | null = null; + + readonly overlayState = signal({ kind: 'idle' }); + + ngOnInit(): void { + merge( + fromEvent(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 { + 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; + } + } +}