Add delete handler for delete key

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/4/head
trivernis 3 years ago
parent 0edd5e4edc
commit ae01ae9284

@ -32,6 +32,7 @@
"primeng": "^13.0.4",
"rxjs": "~7.5.2",
"tslib": "^2.3.1",
"w3c-keys": "^1.0.3",
"zone.js": "~0.11.4"
},
"devDependencies": {

@ -0,0 +1,18 @@
import {NgModule} from "@angular/core";
import {CommonModule} from "@angular/common";
import {FileActionBaseComponent} from "./file-action-base/file-action-base.component";
@NgModule({
declarations: [
FileActionBaseComponent,
],
exports: [
FileActionBaseComponent,
],
imports: [
CommonModule
]
})
export class AppBaseModule {
}

@ -0,0 +1,176 @@
import {Component} from "@angular/core";
import {FileService} from "../../../../services/file/file.service";
import {clipboard} from "@tauri-apps/api";
import {FileHelper} from "../../../../services/file/file.helper";
import {FileStatus} from "../../../../../api/api-types/files";
import {File} from "../../../../../api/models/File";
import {SafeResourceUrl} from "@angular/platform-browser";
import {BehaviorSubject} from "rxjs";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {ConfirmDialogComponent, ConfirmDialogData} from "../../app-common/confirm-dialog/confirm-dialog.component";
import {MatDialog, MatDialogConfig, MatDialogRef} from "@angular/material/dialog";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>,
progress: BehaviorSubject<number>,
message: BehaviorSubject<string>,
};
@Component({
selector: "app-file-action-base",
template: "<h1>Do not use</h1>",
})
export class FileActionBaseComponent {
constructor(private dialog: MatDialog, private errorBroker: ErrorBrokerService, private fileService: FileService) {
}
public async copyFileContentDescriptor(file: File): Promise<void> {
await clipboard.writeText(file.cd);
}
public async exportFile(file: File): Promise<void> {
const path = await FileHelper.getFileDownloadLocation(file);
if (path) {
await this.errorBroker.try(() => this.fileService.saveFile(file, path));
}
}
public async updateStatus(files: File[], status: FileStatus) {
if (files.length === 1) {
let changeConfirmed;
if (status === "Deleted") {
changeConfirmed = await this.openConfirmDialog(
"Confirm deletion",
"Do you really want to move this file to trash?",
"Delete",
"warn",
this.getImageThumbnail(files[0])
);
} else {
changeConfirmed = true;
}
if (changeConfirmed) {
await this.errorBroker.try(async () => {
const newFile = await this.fileService.updateFileStatus(files[0].id, status);
files[0].status = newFile.status;
});
}
} else {
const statusChangeConfirmed = await this.openConfirmDialog(
"Confirm mass status change",
`Do you really want to change the status of ${files.length} files to '${status}'?`,
"Change status",
status === "Deleted" ? "warn" : "primary"
);
if (statusChangeConfirmed) {
await this.iterateWithProgress(
`Updating file status to '${status}'`,
files,
(file) => this.errorBroker.try(async () => {
const newFile = await this.fileService.updateFileStatus(file.id, status);
file.status = newFile.status;
})
);
}
}
}
public async deletePermanently(files: File[]) {
if (files.length === 1) {
const deletionConfirmed = await this.openConfirmDialog(
"Confirm deletion",
"Do you really want to permanently delete this file?",
"Delete permanently",
"warn",
this.getImageThumbnail(files[0]),
);
if (deletionConfirmed) {
await this.errorBroker.try(() => this.fileService.deleteFile(files[0].id));
}
} else {
const deletionConfirmed = await this.openConfirmDialog(
"Confirm mass deletion",
`Do you really want to permanently delete ${files.length} files?`,
"Delete permanently",
"warn"
);
if (deletionConfirmed) {
await this.iterateWithProgress(
"Deleting files",
files,
(file) => this.errorBroker.try(() => this.fileService.deleteFile(file.id))
);
}
}
}
protected getImageThumbnail(file: File): SafeResourceUrl | undefined {
const mimeParts = FileHelper.parseMime(file.mimeType);
if (mimeParts && ["image", "video"].includes(mimeParts[0])) {
return this.fileService.buildThumbnailUrl(file, 250, 250);
} else {
return;
}
}
protected async iterateWithProgress<T>(title: string, items: T[], action: (arg: T) => Promise<any>): Promise<void> {
const totalCount = items.length;
const dialogCtx = this.openProgressDialog(title, `0/${totalCount}`);
let count = 0;
for (const item of items) {
await action(item);
dialogCtx.message.next(`${++count}/${totalCount}`);
dialogCtx.progress.next(count / totalCount);
}
dialogCtx.dialog.close(true);
}
protected openProgressDialog(title: string, message: string): ProgressDialogContext {
const dialogMessage = new BehaviorSubject(message);
const dialogProgress = new BehaviorSubject(0);
const dialog = this.dialog.open(BusyDialogComponent, {
data: {
message: dialogMessage,
progress: dialogProgress,
title,
allowCancel: false,
},
disableClose: true,
minWidth: "30%",
minHeight: "30%",
});
return {
dialog,
message: dialogMessage,
progress: dialogProgress,
};
}
protected openConfirmDialog(
title: string,
question: string,
confirmAction: string,
confirmColor?: "primary" | "warn",
image?: SafeResourceUrl | string
): Promise<boolean> {
const dialog = this.dialog.open(ConfirmDialogComponent, {
data: {
title,
message: question,
confirmAction,
denyAction: "Cancel",
confirmColor,
image
}
} as MatDialogConfig & { data: ConfirmDialogData });
return dialog.afterClosed().toPromise();
}
}

@ -1,23 +1,25 @@
<app-context-menu #contextMenu>
<ng-content select="[content-before]"></ng-content>
<ng-container *ngIf="this.files">
<button (click)="this.updateStatus('Archived')" *ngIf="actionArchive" mat-menu-item>Archive
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionArchive" mat-menu-item>Archive
</button>
<button (click)="this.updateStatus('Imported')" *ngIf="actionImported" mat-menu-item>Back to
<button (click)="this.updateStatus(this.files, 'Imported')" *ngIf="actionImported" mat-menu-item>Back to
imported
</button>
<button (click)="this.updateStatus('Deleted')"
<button (click)="this.updateStatus(this.files, 'Deleted')"
*ngIf="actionDelete"
mat-menu-item>Delete
</button>
<button (click)="this.deletePermanently()" *ngIf="actionDeletePermantently" mat-menu-item>Delete permanently
<button (click)="this.deletePermanently(this.files)" *ngIf="actionDeletePermantently" mat-menu-item>Delete
permanently
</button>
<button (click)="this.updateStatus('Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button>
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button>
<!-- everything that only applies to a single file -->
<ng-container>
<button (click)="this.copyFileHash()" mat-menu-item>Copy Hash</button>
<button (click)="this.exportFile()" mat-menu-item>Save As...</button>
<button (click)="this.copyFileContentDescriptor(this.files[0])" mat-menu-item>Copy Content Descriptor
</button>
<button (click)="this.exportFile(this.files[0])" mat-menu-item>Save As...</button>
</ng-container>
</ng-container>
<ng-content></ng-content>

@ -1,16 +1,12 @@
import {Component, EventEmitter, OnChanges, Output, SimpleChanges, ViewChild} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {ContextMenuComponent} from "../../app-common/context-menu/context-menu.component";
import {clipboard} from "@tauri-apps/api";
import {FileService} from "../../../../services/file/file.service";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {FileHelper} from "../../../../services/file/file.helper";
import {FileStatus} from "../../../../../api/api-types/files";
import {MatDialog, MatDialogConfig, MatDialogRef} from "@angular/material/dialog";
import {MatDialog, MatDialogRef} from "@angular/material/dialog";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {BehaviorSubject} from "rxjs";
import {ConfirmDialogComponent, ConfirmDialogData} from "../../app-common/confirm-dialog/confirm-dialog.component";
import {SafeResourceUrl} from "@angular/platform-browser";
import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component";
type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>,
@ -23,7 +19,7 @@ type ProgressDialogContext = {
templateUrl: "./file-context-menu.component.html",
styleUrls: ["./file-context-menu.component.scss"]
})
export class FileContextMenuComponent implements OnChanges {
export class FileContextMenuComponent extends FileActionBaseComponent implements OnChanges {
public files: File[] = [];
@ -36,7 +32,8 @@ export class FileContextMenuComponent implements OnChanges {
@ViewChild("contextMenu") contextMenu!: ContextMenuComponent;
@Output() fileUpdate = new EventEmitter<void>();
constructor(private fileService: FileService, private errorBroker: ErrorBrokerService, private dialog: MatDialog) {
constructor(fileService: FileService, errorBroker: ErrorBrokerService, dialog: MatDialog) {
super(dialog, errorBroker, fileService);
}
public ngOnChanges(changes: SimpleChanges): void {
@ -51,99 +48,6 @@ export class FileContextMenuComponent implements OnChanges {
this.contextMenu.onContextMenu(event);
}
public async copyFileHash(): Promise<void> {
await clipboard.writeText(this.files[0].cd);
}
public async exportFile(): Promise<void> {
const path = await FileHelper.getFileDownloadLocation(this.files[0]);
if (path) {
try {
await this.fileService.saveFile(this.files[0], path);
} catch (err) {
this.errorBroker.showError(err);
}
}
}
public async updateStatus(status: FileStatus) {
if (this.files.length === 1) {
let changeConfirmed;
if (status === "Deleted") {
changeConfirmed = await this.openConfirmDialog(
"Confirm deletion",
"Do you really want to move this file to trash?",
"Delete",
"warn",
this.getImageThumbnail(this.files[0])
);
} else {
changeConfirmed = true;
}
if (changeConfirmed) {
const newFile = await this.fileService.updateFileStatus(this.files[0].id, status);
this.files[0].status = newFile.status;
this.fileUpdate.emit();
this.applyStatus();
}
} else {
const statusChangeConfirmed = await this.openConfirmDialog(
"Confirm mass status change",
`Do you really want to change the status of ${this.files.length} files to '${status}'?`,
"Change status",
status === "Deleted" ? "warn" : "primary"
);
if (statusChangeConfirmed) {
await this.iterateWithProgress(
`Updating file status to '${status}'`,
this.files,
async (file) => {
const newFile = await this.fileService.updateFileStatus(file.id, status);
file.status = newFile.status;
}
);
this.fileUpdate.emit();
this.applyStatus();
}
}
}
public async deletePermanently() {
if (this.files.length === 1) {
const deletionConfirmed = await this.openConfirmDialog(
"Confirm deletion",
"Do you really want to permanently delete this file?",
"Delete permanently",
"warn",
this.getImageThumbnail(this.files[0]),
);
if (deletionConfirmed) {
await this.fileService.deleteFile(this.files[0].id);
this.fileUpdate.emit();
this.applyStatus();
}
} else {
const deletionConfirmed = await this.openConfirmDialog(
"Confirm mass deletion",
`Do you really want to permanently delete ${this.files.length} files?`,
"Delete permanently",
"warn"
);
if (deletionConfirmed) {
await this.iterateWithProgress(
"Deleting files",
this.files,
async (file) => this.fileService.deleteFile(file.id)
);
this.fileUpdate.emit();
this.applyStatus();
}
}
}
private applyStatus() {
this.actionDeletePermantently = true;
this.actionDelete = this.actionArchive = this.actionImported = this.actionRestore = false;
@ -156,70 +60,4 @@ export class FileContextMenuComponent implements OnChanges {
this.actionRestore ||= file.status === "Deleted";
}
}
private getImageThumbnail(file: File): SafeResourceUrl | undefined {
const mimeParts = FileHelper.parseMime(file.mimeType);
if (mimeParts && ["image", "video"].includes(mimeParts[0])) {
return this.fileService.buildThumbnailUrl(file, 250, 250);
} else {
return;
}
}
private async iterateWithProgress<T>(title: string, items: T[], action: (arg: T) => Promise<any>): Promise<void> {
const totalCount = items.length;
const dialogCtx = this.openProgressDialog(title, `0/${totalCount}`);
let count = 0;
for (const item of items) {
await action(item);
dialogCtx.message.next(`${++count}/${totalCount}`);
dialogCtx.progress.next(count / totalCount);
}
dialogCtx.dialog.close(true);
}
private openProgressDialog(title: string, message: string): ProgressDialogContext {
const dialogMessage = new BehaviorSubject(message);
const dialogProgress = new BehaviorSubject(0);
const dialog = this.dialog.open(BusyDialogComponent, {
data: {
message: dialogMessage,
progress: dialogProgress,
title,
allowCancel: false,
},
disableClose: true,
minWidth: "30%",
minHeight: "30%",
});
return {
dialog,
message: dialogMessage,
progress: dialogProgress,
};
}
private openConfirmDialog(
title: string,
question: string,
confirmAction: string,
confirmColor?: "primary" | "warn",
image?: SafeResourceUrl | string
): Promise<boolean> {
const dialog = this.dialog.open(ConfirmDialogComponent, {
data: {
title,
message: question,
confirmAction,
denyAction: "Cancel",
confirmColor,
image
}
} as MatDialogConfig & { data: ConfirmDialogData });
return dialog.afterClosed().toPromise();
}
}

@ -1,8 +1,8 @@
<div #inner (keyDownEvent)="handleKeydownEvent($event)" appInputReceiver class="gallery-container" fxLayout="column">
<button (click)="this.closeEvent.emit(this)" class="close-button" mat-icon-button>
<button (click)="this.appClose.emit(this)" class="close-button" mat-icon-button>
<ng-icon name="mat-close"></ng-icon>
</button>
<div (dblclick)="this.selectedFile? this.fileDblClickEvent.emit(this.selectedFile.data) : null"
<div (dblclick)="this.selectedFile? this.fileDblClick.emit(this.selectedFile.data) : null"
class="file-full-view"
fxFlex="80%">
<app-content-viewer (contextmenu)="this.selectedFile && fileContextMenu.onContextMenu($event, [this.selectedFile!.data])"

@ -16,6 +16,7 @@ import {SafeResourceUrl} from "@angular/platform-browser";
import {Selectable} from "../../../../../models/Selectable";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../../../../services/tab/tab.service";
import {Key} from "w3c-keys";
@Component({
selector: "app-file-gallery",
@ -26,9 +27,10 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
@Input() files: File[] = [];
@Input() preselectedFile: File | undefined;
@Output() fileSelectEvent = new EventEmitter<File | undefined>();
@Output() fileDblClickEvent = new EventEmitter<File>();
@Output() closeEvent = new EventEmitter<FileGalleryComponent>();
@Output() fileSelect = new EventEmitter<File | undefined>();
@Output() fileDblClick = new EventEmitter<File>();
@Output() appClose = new EventEmitter<FileGalleryComponent>();
@Output() fileDelete = new EventEmitter<File>();
entries: Selectable<File>[] = [];
@ViewChild("virtualScroll") virtualScroll!: CdkVirtualScrollViewport;
@ -86,11 +88,13 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
if (this.virtualScroll) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => this.scrollToSelection(),
0); // we need to make sure the viewport has rendered
this.scrollTimeout = setTimeout(
() => this.scrollToSelection(),
0
); // we need to make sure the viewport has rendered
}
this.fileSelectEvent.emit(this.selectedFile.data);
this.fileSelect.emit(this.selectedFile.data);
}
}
@ -150,15 +154,20 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
public async handleKeydownEvent(event: KeyboardEvent) {
switch (event.key) {
case "ArrowRight":
case Key.ArrowRight:
await this.nextItem();
break;
case "ArrowLeft":
case Key.ArrowLeft:
await this.previousItem();
break;
case "Escape":
case Key.Escape:
this.onEscapeClick();
break;
case Key.Delete:
if (this.selectedFile) {
this.fileDelete.emit(this.selectedFile.data);
}
break;
}
}
@ -173,7 +182,8 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
if (selectedIndex > indexAdjustment) {
this.virtualScroll.scrollToOffset(
this.virtualScroll.measureScrollOffset("left") + 130,
"smooth");
"smooth"
);
}
}
}
@ -192,7 +202,7 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
private onEscapeClick(): void {
if (this.escapeCount === 1) {
this.closeEvent.emit(this);
this.appClose.emit(this);
} else {
this.escapeCount++;
setTimeout(() => this.escapeCount--, 500);

@ -10,7 +10,7 @@
<app-file-card
(clickEvent)="setSelectedFile($event.entry)"
(contextmenu)="this.selectEntryWhenNotSelected(gridEntry); fileContextMenu.onContextMenu($event, this.getSelectedFiles())"
(dblClickEvent)="fileOpenEvent.emit($event.entry.data)"
(dblClickEvent)="fileOpen.emit($event.entry.data)"
*ngFor="let gridEntry of rowEntry"
[entry]="gridEntry"></app-file-card>
</div>
@ -19,7 +19,7 @@
</div>
<app-file-context-menu #fileContextMenu>
<button (click)="this.fileOpenEvent.emit(fileContextMenu.files[0])"
<button (click)="this.fileOpen.emit(fileContextMenu.files[0])"
*ngIf="fileContextMenu.files.length === 1"
content-before=""
mat-menu-item>Open

@ -16,6 +16,7 @@ import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../../../../services/tab/tab.service";
import {FileService} from "../../../../../services/file/file.service";
import {Selectable} from "../../../../../models/Selectable";
import {Key} from "w3c-keys";
@Component({
selector: "app-file-grid",
@ -27,8 +28,9 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
@Input() files: File[] = [];
@Input() columns: number = 6;
@Input() preselectedFile: File | undefined;
@Output() fileOpenEvent = new EventEmitter<File>();
@Output() fileSelectEvent = new EventEmitter<File[]>();
@Output() fileOpen = new EventEmitter<File>();
@Output() fileSelect = new EventEmitter<File[]>();
@Output() fileDelete = new EventEmitter<File[]>();
@ViewChild("virtualScrollGrid") virtualScroll!: CdkVirtualScrollViewport;
@ViewChild("inner") inner!: ElementRef<HTMLDivElement>;
@ -89,7 +91,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.selectedEntries.push(clickedEntry);
}
}
this.fileSelectEvent.emit(this.selectedEntries.map(g => g.data));
this.fileSelect.emit(this.selectedEntries.map(g => g.data));
}
public selectEntryWhenNotSelected(entry: Selectable<File>) {
@ -119,26 +121,26 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.ctrlClicked ||= event.ctrlKey;
switch (event.key) {
case "ArrowRight":
case Key.ArrowRight:
this.handleArrowSelect("right");
break;
case "ArrowLeft":
case Key.ArrowLeft:
this.handleArrowSelect("left");
break;
case "ArrowDown":
case Key.ArrowDown:
this.handleArrowSelect("down");
break;
case "ArrowUp":
case Key.ArrowUp:
this.handleArrowSelect("up");
break;
case "PageDown":
case Key.PageDown:
this.pageDown();
break;
case "PageUp":
case Key.PageUp:
this.pageUp();
break;
case "a":
case "A":
case Key.a:
case Key.A:
if (this.shiftClicked && this.ctrlClicked) {
this.selectNone();
} else if (this.ctrlClicked) {
@ -146,11 +148,14 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.selectAll();
}
break;
case "Enter":
case Key.Enter:
if (this.selectedEntries.length === 1) {
this.fileOpenEvent.emit(this.selectedEntries[0].data);
this.fileOpen.emit(this.selectedEntries[0].data);
}
break;
case Key.Delete:
this.fileDelete.emit(this.selectedEntries.map(e => e.data));
break;
}
}

@ -1,7 +1,11 @@
<app-file-grid (fileOpenEvent)="this.onFileOpen($event)" (fileSelectEvent)="this.onFileSelect($event)"
<app-file-grid (fileDelete)="this.onFileDelete($event)"
(fileOpen)="this.onFileOpen($event)"
(fileSelect)="this.onFileSelect($event)"
*ngIf="this.mode === 'grid'"
[files]="this.files" [preselectedFile]="this.preselectedFile"></app-file-grid>
<app-file-gallery (closeEvent)="this.setMode('grid')" (fileSelectEvent)="this.onSingleFileSelect($event)"
[files]="this.files"
[preselectedFile]="this.preselectedFile"></app-file-grid>
<app-file-gallery (appClose)="this.setMode('grid')" (fileDelete)="this.onFileDelete([$event])"
(fileSelect)="this.onSingleFileSelect($event)"
*ngIf="this.mode === 'gallery'"
[files]="this.files"
[preselectedFile]="this.preselectedFile"></app-file-gallery>

@ -1,21 +1,18 @@
import {
AfterViewInit,
Component,
EventEmitter,
Input,
Output,
ViewChild
} from "@angular/core";
import {AfterViewInit, Component, EventEmitter, Input, Output, ViewChild} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {FileGalleryComponent} from "./file-gallery/file-gallery.component";
import {FileGridComponent} from "./file-grid/file-grid.component";
import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component";
import {MatDialog} from "@angular/material/dialog";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {FileService} from "../../../../services/file/file.service";
@Component({
selector: "app-file-multiview",
templateUrl: "./file-multiview.component.html",
styleUrls: ["./file-multiview.component.scss"]
})
export class FileMultiviewComponent implements AfterViewInit {
export class FileMultiviewComponent extends FileActionBaseComponent implements AfterViewInit {
@Input() files!: File[];
@Input() mode: "grid" | "gallery" = "grid";
@ -30,7 +27,8 @@ export class FileMultiviewComponent implements AfterViewInit {
public selectedFiles: File[] = [];
@Input() public preselectedFile: File | undefined;
constructor() {
constructor(dialog: MatDialog, errorBroker: ErrorBrokerService, fileService: FileService) {
super(dialog, errorBroker, fileService);
}
public ngAfterViewInit(): void {
@ -66,4 +64,18 @@ export class FileMultiviewComponent implements AfterViewInit {
this.mode = mode;
this.modeChangeEvent.emit(mode);
}
public async onFileDelete(files: File[]): Promise<void> {
let deletePermanently = true;
for (const file of files) {
deletePermanently &&= file.status === "Deleted";
}
if (deletePermanently) {
await this.deletePermanently(files);
} else {
await this.updateStatus(files, "Deleted");
}
}
}

@ -24,6 +24,15 @@ export class ErrorBrokerService {
});
}
async try<T>(fn: () => Promise<T>): Promise<T | undefined> {
try {
return await fn();
} catch (err) {
this.showError(err);
return;
}
}
showInfo(info: string) {
console.log(info);
if (this.infoCb) {

@ -8861,6 +8861,11 @@ void-elements@^2.0.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
w3c-keys@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/w3c-keys/-/w3c-keys-1.0.3.tgz#822e055545943427d94b4233661aeb277db4846a"
integrity sha512-us/8uEJL9s/TXLgkJ+MCIh4/ceena10XW/Bl+7trCYCxLcUigspZkcqpRPTxTs4x4usm04BGfex7dENoTQ6JaA==
watchpack@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"

Loading…
Cancel
Save