Add status handling to file context menu

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/4/head
trivernis 3 years ago
parent 981e23a192
commit 1d69eb4a46

@ -1499,8 +1499,8 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "mediarepo-api"
version = "0.26.0"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=bd21f48f41aa2943f76b21addf137b2e58d492ca#bd21f48f41aa2943f76b21addf137b2e58d492ca"
version = "0.27.0"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=57d85eb3a668a8d4d344b7fbe2403731a16625a7#57d85eb3a668a8d4d344b7fbe2403731a16625a7"
dependencies = [
"async-trait",
"bromine",

@ -28,7 +28,7 @@ features = ["env-filter"]
[dependencies.mediarepo-api]
git = "https://github.com/Trivernis/mediarepo-api.git"
rev = "bd21f48f41aa2943f76b21addf137b2e58d492ca"
rev = "57d85eb3a668a8d4d344b7fbe2403731a16625a7"
features = ["tauri-plugin"]
[features]

@ -8,6 +8,7 @@ import {
CheckDaemonRunningRequest,
CheckLocalRepositoryExistsRequest,
CreateTagsRequest,
DeleteFileRequest,
DeleteRepositoryRequest,
DeleteThumbnailsRequest,
FindFilesRequest,
@ -23,7 +24,8 @@ import {
SelectRepositoryRequest,
SetFrontendStateRequest,
StartDaemonRequest,
UpdateFileNameRequest
UpdateFileNameRequest,
UpdateFileStatusRequest
} from "./api-types/requests";
import {RepositoryData, RepositoryMetadata, SizeMetadata} from "./api-types/repo";
import {NamespaceData, TagData} from "./api-types/tags";
@ -107,6 +109,10 @@ export class MediarepoApi {
return this.invokePlugin(ApiFunction.UpdateFileName, request);
}
public static async updateFileStatus(request: UpdateFileStatusRequest): Promise<FileBasicData> {
return this.invokePlugin(ApiFunction.UpdateFileStatus, request);
}
public static async saveFileLocally(request: SaveFileRequest): Promise<void> {
return this.invokePlugin(ApiFunction.SaveFileLocally, request);
}
@ -119,6 +125,10 @@ export class MediarepoApi {
return this.invokePlugin(ApiFunction.ReadFile, request);
}
public static async deleteFile(request: DeleteFileRequest): Promise<void> {
return this.invokePlugin(ApiFunction.DeleteFile, request);
}
public static async getAllTags(): Promise<TagData[]> {
return ShortCache.cached("all-tags", () => this.invokePlugin(ApiFunction.GetAllTags), 2000);
}

@ -20,9 +20,11 @@ export enum ApiFunction {
FindFiles = "find_files",
GetFileMetadata = "get_file_metadata",
UpdateFileName = "update_file_name",
UpdateFileStatus = "update_file_status",
SaveFileLocally = "save_file_locally",
DeleteThumbnails = "delete_thumbnails",
ReadFile = "read_file",
DeleteFile = "delete_file",
// tags
GetAllTags = "get_all_tags",
GetAllNamespace = "get_all_namespaces",

@ -1,4 +1,4 @@
import {FileOsMetadata, FilterExpression, SortKey} from "./files";
import {FileOsMetadata, FileStatus, FilterExpression, SortKey} from "./files";
import {RepositoryData, SizeType} from "./repo";
import {JobType} from "./job";
@ -60,8 +60,15 @@ export type ReadFileRequest = {
mimeType: string,
};
export type DeleteFileRequest = IdIdentifierRequest;
export type GetFileMetadataRequest = IdIdentifierRequest;
export type UpdateFileStatusRequest = {
id: number,
status: FileStatus
};
export type GetTagsForFilesRequest = {
cds: string[]
};

@ -123,7 +123,7 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
}
private openStartupDialog(repository: Repository): BusyDialogContext {
let dialogMessage = new BehaviorSubject<string>(
const dialogMessage = new BehaviorSubject<string>(
"Opening repository...");
let dialog = this.dialog.open(BusyDialogComponent, {
data: {

@ -13,6 +13,7 @@ import {InputReceiverDirective} from "./input-receiver/input-receiver.directive"
import {MetadataEntryComponent} from "./metadata-entry/metadata-entry.component";
import {BusyDialogComponent} from "./busy-dialog/busy-dialog.component";
import {SelectableComponent} from "./selectable/selectable.component";
import {MatProgressBarModule} from "@angular/material/progress-bar";
@NgModule({
@ -41,7 +42,8 @@ import {SelectableComponent} from "./selectable/selectable.component";
MatProgressSpinnerModule,
MatButtonModule,
MatDialogModule,
MatMenuModule
MatMenuModule,
MatProgressBarModule
]
})
export class AppCommonModule {

@ -1,12 +1,12 @@
<h1 mat-dialog-title class="title">
<h1 class="title" mat-dialog-title>
{{title}}
</h1>
<div class="content" mat-dialog-content>
<mat-progress-spinner mode="indeterminate" color="primary"></mat-progress-spinner>
<mat-progress-bar [mode]="this.mode" [value]="this.progress" color="primary"></mat-progress-bar>
{{message}}
</div>
<div mat-dialog-actions *ngIf="this.allowCancel" class="busy-dialog-actions">
<button mat-flat-button (click)="this.dialogRef.close(false)">
<div *ngIf="this.allowCancel" class="busy-dialog-actions" mat-dialog-actions>
<button (click)="this.dialogRef.close(false)" mat-flat-button>
Cancel
</button>
</div>

@ -1,10 +1,12 @@
import {Component, Inject} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {BehaviorSubject} from "rxjs";
import {ProgressBarMode} from "@angular/material/progress-bar";
export type BusyDialogData = {
title: string,
message: BehaviorSubject<string>,
message?: BehaviorSubject<string>,
progress?: BehaviorSubject<number>,
allowCancel?: boolean,
}
@ -18,10 +20,18 @@ export class BusyDialogComponent {
public title: string;
public message?: string;
public allowCancel: boolean;
public progress = 0;
public mode: ProgressBarMode = "indeterminate";
constructor(public dialogRef: MatDialogRef<BusyDialogComponent>, @Inject(MAT_DIALOG_DATA) data: BusyDialogData) {
this.title = data.title;
if (data.message) {
data.message.subscribe(m => this.message = m);
}
if (data.progress) {
data.progress.subscribe(p => this.progress = p);
this.mode = "determinate";
}
this.allowCancel = data.allowCancel ?? false;
}
}

@ -1,6 +1,24 @@
<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>
<button (click)="this.updateStatus('Imported')" *ngIf="actionImported" mat-menu-item>Back to
imported
</button>
<button (click)="this.updateStatus('Deleted')"
*ngIf="actionDelete"
mat-menu-item>Delete
</button>
<button (click)="this.deletePermanently()" *ngIf="actionDeletePermantently" mat-menu-item>Delete permanently
</button>
<button (click)="this.updateStatus('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>
</ng-container>
</ng-container>
<ng-content></ng-content>
</app-context-menu>

@ -1,14 +1,20 @@
import {Component, ViewChild} from "@angular/core";
import {Component, EventEmitter, Output, ViewChild} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {
ContextMenuComponent
} from "../../app-common/context-menu/context-menu.component";
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 {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {FileHelper} from "../../../../services/file/file.helper";
import {FileStatus} from "../../../../../api/api-types/files";
import {MatDialog, MatDialogRef} from "@angular/material/dialog";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {BehaviorSubject} from "rxjs";
type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>,
progress: BehaviorSubject<number>,
message: BehaviorSubject<string>,
};
@Component({
selector: "app-file-context-menu",
@ -17,31 +23,115 @@ import {FileHelper} from "../../../../services/file/file.helper";
})
export class FileContextMenuComponent {
public file!: File;
public files: File[] = [];
public actionImported = false;
public actionArchive = false;
public actionRestore = false;
public actionDelete = false;
public actionDeletePermantently = false;
@ViewChild("contextMenu") contextMenu!: ContextMenuComponent;
@Output() fileUpdate = new EventEmitter<void>();
constructor(private fileService: FileService, private errorBroker: ErrorBrokerService) {
constructor(private fileService: FileService, private errorBroker: ErrorBrokerService, private dialog: MatDialog) {
}
public onContextMenu(event: MouseEvent, file: File) {
this.file = file;
public onContextMenu(event: MouseEvent, files: File[]) {
this.files = files;
this.applyStatus();
this.contextMenu.onContextMenu(event);
}
public async copyFileHash(): Promise<void> {
await clipboard.writeText(this.file.cd);
await clipboard.writeText(this.files[0].cd);
}
public async exportFile(): Promise<void> {
const path = await FileHelper.getFileDownloadLocation(this.file);
const path = await FileHelper.getFileDownloadLocation(this.files[0]);
if (path) {
try {
await this.fileService.saveFile(this.file, path);
await this.fileService.saveFile(this.files[0], path);
} catch (err) {
this.errorBroker.showError(err);
}
}
}
public async updateStatus(status: FileStatus) {
if (this.files.length === 1) {
const newFile = await this.fileService.updateFileStatus(this.files[0].id, status);
this.files[0].status = newFile.status;
} else {
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();
}
public async deletePermanently() {
if (this.files.length === 1) {
await this.fileService.deleteFile(this.files[0].id);
} else {
await this.iterateWithProgress(
"Deleting files",
this.files,
async (file) => this.fileService.deleteFile(file.id)
);
}
this.fileUpdate.emit();
}
private applyStatus() {
for (const file of this.files) {
this.actionDeletePermantently &&= file.status === "Deleted";
this.actionDelete ||= file.status !== "Deleted";
this.actionArchive ||= file.status !== "Archived";
this.actionImported ||= file.status !== "Imported";
this.actionRestore ||= file.status === "Deleted";
}
}
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,
};
}
}

@ -1,12 +1,12 @@
<div class="gallery-container" #inner fxLayout="column" appInputReceiver (keyDownEvent)="handleKeydownEvent($event)">
<div #inner (keyDownEvent)="handleKeydownEvent($event)" appInputReceiver class="gallery-container" fxLayout="column">
<button (click)="this.closeEvent.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"
class="file-full-view"
fxFlex="80%">
<app-content-viewer *ngIf="this.selectedFile"
(contextmenu)="this.selectedFile && fileContextMenu.onContextMenu($event, this.selectedFile!.data)"
<app-content-viewer (contextmenu)="this.selectedFile && fileContextMenu.onContextMenu($event, [this.selectedFile!.data])"
*ngIf="this.selectedFile"
[file]="this.selectedFile!.data"></app-content-viewer>
</div>
<mat-divider fxFlex></mat-divider>

@ -1,11 +1,15 @@
<div class="file-gallery-inner" #inner appInputReceiver (keyDownEvent)="handleKeydownEvent($event)" (keyUpEvent)="handleKeyupEvent($event)">
<div #inner
(keyDownEvent)="handleKeydownEvent($event)"
(keyUpEvent)="handleKeyupEvent($event)"
appInputReceiver
class="file-gallery-inner">
<cdk-virtual-scroll-viewport #virtualScrollGrid class="file-scroll" itemSize="260" maxBufferPx="2000"
minBufferPx="500">
<div *cdkVirtualFor="let rowEntry of partitionedGridEntries">
<div class="file-row">
<app-file-card
(clickEvent)="setSelectedFile($event.entry)"
(contextmenu)="fileContextMenu.onContextMenu($event, gridEntry.data)"
(contextmenu)="this.selectEntryWhenNotSelected(gridEntry); fileContextMenu.onContextMenu($event, this.getSelectedFiles())"
(dblClickEvent)="fileOpenEvent.emit($event.entry.data)"
*ngFor="let gridEntry of rowEntry"
[entry]="gridEntry"></app-file-card>
@ -15,7 +19,11 @@
</div>
<app-file-context-menu #fileContextMenu>
<button (click)="this.fileOpenEvent.emit(fileContextMenu.file)" mat-menu-item content-before="">Open</button>
<button (click)="this.regenerateThumbnail(fileContextMenu.file)" mat-menu-item>Regenerate thumbnail</button>
<button (click)="this.fileOpenEvent.emit(fileContextMenu.files[0])"
*ngIf="fileContextMenu.files.length === 1"
content-before=""
mat-menu-item>Open
</button>
<button (click)="this.regenerateThumbnail(fileContextMenu.files)" mat-menu-item>Regenerate thumbnail</button>
</app-file-context-menu>

@ -92,15 +92,23 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.fileSelectEvent.emit(this.selectedEntries.map(g => g.data));
}
public selectEntryWhenNotSelected(entry: Selectable<File>) {
if (!entry.selected) {
this.setSelectedFile(entry);
}
}
public adjustElementSizes(): void {
if (this.virtualScroll) {
this.virtualScroll.checkViewportSize();
}
}
public async regenerateThumbnail(file: File) {
public async regenerateThumbnail(files: File[]) {
for (const file of files) {
await this.fileService.deleteThumbnails(file);
}
}
public focus() {
this.inner.nativeElement.focus();
@ -146,6 +154,10 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
}
}
public getSelectedFiles(): File[] {
return this.selectedEntries.map(e => e.data);
}
public handleKeyupEvent(event: KeyboardEvent) {
this.shiftClicked = event.shiftKey ? false : this.shiftClicked;
this.ctrlClicked = event.ctrlKey ? false : this.ctrlClicked;

@ -10,3 +10,7 @@
<ng-icon *ngIf="getFileType() === 'audio'" name="mat-audiotrack"></ng-icon>
<ng-icon *ngIf="getFileType() === 'text'" name="mat-description"></ng-icon>
</div>
<div class="file-status-icon">
<ng-icon *ngIf="file.status === 'Deleted'" name="mat-auto-delete"></ng-icon>
<ng-icon *ngIf="file.status === 'Imported'" name="mat-fiber-new"></ng-icon>
</div>

@ -24,6 +24,21 @@ app-content-aware-image {
right: 0;
}
.file-status-icon {
position: absolute;
top: 0;
right: 0;
height: 20%;
width: 20%;
display: flex;
ng-icon {
align-self: flex-end;
margin: 0 0 auto auto;
font-size: 2em;
}
}
ng-icon.gif-icon {
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);

@ -7,7 +7,7 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {ReactiveFormsModule} from "@angular/forms";
import {MatInputModule} from "@angular/material/input";
import {NgIconsModule} from "@ng-icons/core";
import {MatFolder, MatInsertDriveFile} from "@ng-icons/material-icons";
import {MatDeleteForever, MatFiberNew, MatFolder, MatInsertDriveFile} from "@ng-icons/material-icons";
import {MatButtonModule} from "@angular/material/button";
import {FlexModule} from "@angular/flex-layout";
import {FilterInputComponent} from "./filter-input/filter-input.component";
@ -30,7 +30,7 @@ import {FilterInputComponent} from "./filter-input/filter-input.component";
MatFormFieldModule,
ReactiveFormsModule,
MatInputModule,
NgIconsModule.withIcons({ MatInsertDriveFile, MatFolder }),
NgIconsModule.withIcons({ MatInsertDriveFile, MatFolder, MatDeleteForever, MatFiberNew }),
MatButtonModule,
FlexModule,
]

@ -4,7 +4,7 @@ import {DomSanitizer, SafeResourceUrl} from "@angular/platform-browser";
import {SortKey} from "../../models/SortKey";
import {MediarepoApi} from "../../../api/Api";
import {mapMany, mapNew} from "../../../api/models/adaptors";
import {FileMetadata} from "../../../api/api-types/files";
import {FileMetadata, FileStatus} from "../../../api/api-types/files";
import {SearchFilters} from "../../../api/models/SearchFilters";
@ -31,14 +31,44 @@ export class FileService {
.then(mapMany(mapNew(File)));
}
/**
* Returns metadata about a file
* @param {number} id
* @returns {Promise<FileMetadata>}
*/
public async getFileMetadata(id: number): Promise<FileMetadata> {
return MediarepoApi.getFileMetadata({ id });
}
/**
* Updates the filename of a file
* @param {number} id
* @param {string} name
* @returns {Promise<FileMetadata>}
*/
public async updateFileName(id: number, name: string): Promise<FileMetadata> {
return MediarepoApi.updateFileName({ id, name });
}
/**
* Updates the status of a file
* @param {number} id
* @param {FileStatus} status
* @returns {Promise<File>}
*/
public async updateFileStatus(id: number, status: FileStatus): Promise<File> {
return MediarepoApi.updateFileStatus({ id, status }).then(mapNew(File));
}
/***
* Permanently deletes a file
* @param {number} id
* @returns {Promise<void>}
*/
public async deleteFile(id: number): Promise<void> {
return MediarepoApi.deleteFile({ id });
}
/**
* Builds a safe thumbnail url that accesses custom scheme for thumbnails
* @param {File} file

Loading…
Cancel
Save