mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat(playlist-drop-overlay): add component and directive for file drop overlay functionality
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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';
|
||||
+31
@@ -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>
|
||||
}
|
||||
+149
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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';
|
||||
}
|
||||
};
|
||||
}
|
||||
+134
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user