mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #283 from paperless-ngx/feature-global-dragdrop
Feature: global drag'n'drop
This commit is contained in:
		| @@ -1,3 +1,13 @@ | |||||||
| <app-toasts></app-toasts> | <app-toasts></app-toasts> | ||||||
|  |  | ||||||
| <router-outlet></router-outlet> | <ngx-file-drop dropZoneClassName="main-dropzone" contentClassName="main-content" [disabled]="!dragDropEnabled" | ||||||
|  | (onFileDrop)="dropped($event)" (onFileOver)="fileOver()" (onFileLeave)="fileLeave()"> | ||||||
|  |     <ng-template ngx-file-drop-content-tmp> | ||||||
|  |         <div class="global-dropzone-overlay fade" [class.show]="fileIsOver" [class.hide]="hidden"> | ||||||
|  |             <h2 i18n>Drop files to begin upload</h2> | ||||||
|  |         </div> | ||||||
|  |         <div [class.inert]="fileIsOver"> | ||||||
|  |             <router-outlet></router-outlet> | ||||||
|  |         </div> | ||||||
|  |     </ng-template> | ||||||
|  | </ngx-file-drop> | ||||||
|   | |||||||
| @@ -4,6 +4,8 @@ import { Router } from '@angular/router' | |||||||
| import { Subscription } from 'rxjs' | import { Subscription } from 'rxjs' | ||||||
| import { ConsumerStatusService } from './services/consumer-status.service' | import { ConsumerStatusService } from './services/consumer-status.service' | ||||||
| import { ToastService } from './services/toast.service' | import { ToastService } from './services/toast.service' | ||||||
|  | import { NgxFileDropEntry } from 'ngx-file-drop' | ||||||
|  | import { UploadDocumentsService } from './services/upload-documents.service' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-root', |   selector: 'app-root', | ||||||
| @@ -15,11 +17,16 @@ export class AppComponent implements OnInit, OnDestroy { | |||||||
|   successSubscription: Subscription |   successSubscription: Subscription | ||||||
|   failedSubscription: Subscription |   failedSubscription: Subscription | ||||||
|  |  | ||||||
|  |   private fileLeaveTimeoutID: any | ||||||
|  |   fileIsOver: boolean = false | ||||||
|  |   hidden: boolean = true | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     private settings: SettingsService, |     private settings: SettingsService, | ||||||
|     private consumerStatusService: ConsumerStatusService, |     private consumerStatusService: ConsumerStatusService, | ||||||
|     private toastService: ToastService, |     private toastService: ToastService, | ||||||
|     private router: Router |     private router: Router, | ||||||
|  |     private uploadDocumentsService: UploadDocumentsService | ||||||
|   ) { |   ) { | ||||||
|     let anyWindow = window as any |     let anyWindow = window as any | ||||||
|     anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js' |     anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js' | ||||||
| @@ -100,4 +107,36 @@ export class AppComponent implements OnInit, OnDestroy { | |||||||
|         } |         } | ||||||
|       }) |       }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public get dragDropEnabled(): boolean { | ||||||
|  |     return !this.router.url.includes('dashboard') | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public fileOver() { | ||||||
|  |     // allows transition | ||||||
|  |     setTimeout(() => { | ||||||
|  |       this.fileIsOver = true | ||||||
|  |     }, 1) | ||||||
|  |     this.hidden = false | ||||||
|  |     // stop fileLeave timeout | ||||||
|  |     clearTimeout(this.fileLeaveTimeoutID) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public fileLeave(immediate: boolean = false) { | ||||||
|  |     const ms = immediate ? 0 : 500 | ||||||
|  |  | ||||||
|  |     this.fileLeaveTimeoutID = setTimeout(() => { | ||||||
|  |       this.fileIsOver = false | ||||||
|  |       // await transition completed | ||||||
|  |       setTimeout(() => { | ||||||
|  |         this.hidden = true | ||||||
|  |       }, 150) | ||||||
|  |     }, ms) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public dropped(files: NgxFileDropEntry[]) { | ||||||
|  |     this.fileLeave(true) | ||||||
|  |     this.uploadDocumentsService.uploadFiles(files) | ||||||
|  |     this.toastService.showInfo($localize`Initiating upload...`, 3000) | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
|   *ngFor="let toast of toasts" |   *ngFor="let toast of toasts" | ||||||
|   [header]="toast.title" [autohide]="true" [delay]="toast.delay" |   [header]="toast.title" [autohide]="true" [delay]="toast.delay" | ||||||
|   [class]="toast.classname" |   [class]="toast.classname" | ||||||
|   (hide)="toastService.closeToast(toast)"> |   (hidden)="toastService.closeToast(toast)"> | ||||||
|   <p>{{toast.content}}</p> |   <p>{{toast.content}}</p> | ||||||
|   <p *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p> |   <p *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p> | ||||||
| </ngb-toast> | </ngb-toast> | ||||||
|   | |||||||
| @@ -5,3 +5,7 @@ | |||||||
|   margin: 0.5em; |   margin: 0.5em; | ||||||
|   z-index: 1200; |   z-index: 1200; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .toast:not(.show) { | ||||||
|  |   display: block; // this corrects an ng-bootstrap bug that prevented animations | ||||||
|  | } | ||||||
| @@ -33,3 +33,7 @@ form { | |||||||
|   mix-blend-mode: soft-light; |   mix-blend-mode: soft-light; | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | ::ng-deep .ngx-file-drop__drop-zone--over { | ||||||
|  |   background-color: var(--ngx-primary-faded) !important; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import { | |||||||
|   FileStatus, |   FileStatus, | ||||||
|   FileStatusPhase, |   FileStatusPhase, | ||||||
| } from 'src/app/services/consumer-status.service' | } from 'src/app/services/consumer-status.service' | ||||||
| import { DocumentService } from 'src/app/services/rest/document.service' | import { UploadDocumentsService } from 'src/app/services/upload-documents.service' | ||||||
|  |  | ||||||
| const MAX_ALERTS = 5 | const MAX_ALERTS = 5 | ||||||
|  |  | ||||||
| @@ -19,8 +19,8 @@ export class UploadFileWidgetComponent implements OnInit { | |||||||
|   alertsExpanded = false |   alertsExpanded = false | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     private documentService: DocumentService, |     private consumerStatusService: ConsumerStatusService, | ||||||
|     private consumerStatusService: ConsumerStatusService |     private uploadDocumentsService: UploadDocumentsService | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   getStatus() { |   getStatus() { | ||||||
| @@ -116,48 +116,6 @@ export class UploadFileWidgetComponent implements OnInit { | |||||||
|   public fileLeave(event) {} |   public fileLeave(event) {} | ||||||
|  |  | ||||||
|   public dropped(files: NgxFileDropEntry[]) { |   public dropped(files: NgxFileDropEntry[]) { | ||||||
|     for (const droppedFile of files) { |     this.uploadDocumentsService.uploadFiles(files) | ||||||
|       if (droppedFile.fileEntry.isFile) { |  | ||||||
|         const fileEntry = droppedFile.fileEntry as FileSystemFileEntry |  | ||||||
|         fileEntry.file((file: File) => { |  | ||||||
|           let formData = new FormData() |  | ||||||
|           formData.append('document', file, file.name) |  | ||||||
|           let status = this.consumerStatusService.newFileUpload(file.name) |  | ||||||
|  |  | ||||||
|           status.message = $localize`Connecting...` |  | ||||||
|  |  | ||||||
|           this.documentService.uploadDocument(formData).subscribe( |  | ||||||
|             (event) => { |  | ||||||
|               if (event.type == HttpEventType.UploadProgress) { |  | ||||||
|                 status.updateProgress( |  | ||||||
|                   FileStatusPhase.UPLOADING, |  | ||||||
|                   event.loaded, |  | ||||||
|                   event.total |  | ||||||
|                 ) |  | ||||||
|                 status.message = $localize`Uploading...` |  | ||||||
|               } else if (event.type == HttpEventType.Response) { |  | ||||||
|                 status.taskId = event.body['task_id'] |  | ||||||
|                 status.message = $localize`Upload complete, waiting...` |  | ||||||
|               } |  | ||||||
|             }, |  | ||||||
|             (error) => { |  | ||||||
|               switch (error.status) { |  | ||||||
|                 case 400: { |  | ||||||
|                   this.consumerStatusService.fail(status, error.error.document) |  | ||||||
|                   break |  | ||||||
|                 } |  | ||||||
|                 default: { |  | ||||||
|                   this.consumerStatusService.fail( |  | ||||||
|                     status, |  | ||||||
|                     $localize`HTTP error: ${error.status} ${error.statusText}` |  | ||||||
|                   ) |  | ||||||
|                   break |  | ||||||
|                 } |  | ||||||
|               } |  | ||||||
|             } |  | ||||||
|           ) |  | ||||||
|         }) |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -75,7 +75,7 @@ | |||||||
|  |  | ||||||
| </app-page-header> | </app-page-header> | ||||||
|  |  | ||||||
| <div class="sticky-top py-2 mt-n2 mt-sm-n3 py-sm-4 bg-body mx-n3 px-3"> | <div class="row sticky-top pt-4 pb-2 pb-lg-4 bg-body"> | ||||||
|   <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" #filterEditor></app-filter-editor> |   <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" #filterEditor></app-filter-editor> | ||||||
|   <app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor> |   <app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor> | ||||||
| </div> | </div> | ||||||
| @@ -185,7 +185,7 @@ | |||||||
|     </tbody> |     </tbody> | ||||||
|   </table> |   </table> | ||||||
|  |  | ||||||
|   <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> |   <div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||||
|     <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> |     <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> | ||||||
|   </div> |   </div> | ||||||
|   <div *ngIf="list.documents?.length > 15" class="mt-3"> |   <div *ngIf="list.documents?.length > 15" class="mt-3"> | ||||||
|   | |||||||
| @@ -1,5 +1,9 @@ | |||||||
| @import "/src/theme"; | @import "/src/theme"; | ||||||
|  |  | ||||||
|  | ::ng-deep app-document-list app-page-header > div.mb-3 { | ||||||
|  |   margin-bottom: 0 !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| tr { | tr { | ||||||
|   user-select: none; |   user-select: none; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ | |||||||
|      </div> |      </div> | ||||||
|    </div> |    </div> | ||||||
|    <div class="w-100 d-xl-none"></div> |    <div class="w-100 d-xl-none"></div> | ||||||
|    <div class="col col-xl-auto mb-2 mb-xl-0"> |    <div class="col col-xl-auto"> | ||||||
|      <button class="btn btn-link btn-sm px-0 mx-0 ms-xl-n3" [disabled]="!rulesModified" (click)="resetSelected()"> |      <button class="btn btn-link btn-sm px-0 mx-0 ms-xl-n3" [disabled]="!rulesModified" (click)="resetSelected()"> | ||||||
|        <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x me-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> |        <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x me-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||||
|          <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> |          <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> | ||||||
|   | |||||||
							
								
								
									
										74
									
								
								src-ui/src/app/services/upload-documents.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src-ui/src/app/services/upload-documents.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | |||||||
|  | import { Injectable } from '@angular/core' | ||||||
|  | import { HttpEventType } from '@angular/common/http' | ||||||
|  | import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' | ||||||
|  | import { | ||||||
|  |   ConsumerStatusService, | ||||||
|  |   FileStatusPhase, | ||||||
|  | } from './consumer-status.service' | ||||||
|  | import { DocumentService } from './rest/document.service' | ||||||
|  | import { Subscription } from 'rxjs' | ||||||
|  |  | ||||||
|  | @Injectable({ | ||||||
|  |   providedIn: 'root', | ||||||
|  | }) | ||||||
|  | export class UploadDocumentsService { | ||||||
|  |   private uploadSubscriptions: Array<Subscription> = [] | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     private documentService: DocumentService, | ||||||
|  |     private consumerStatusService: ConsumerStatusService | ||||||
|  |   ) {} | ||||||
|  |  | ||||||
|  |   uploadFiles(files: NgxFileDropEntry[]) { | ||||||
|  |     for (const droppedFile of files) { | ||||||
|  |       if (droppedFile.fileEntry.isFile) { | ||||||
|  |         const fileEntry = droppedFile.fileEntry as FileSystemFileEntry | ||||||
|  |         fileEntry.file((file: File) => { | ||||||
|  |           let formData = new FormData() | ||||||
|  |           formData.append('document', file, file.name) | ||||||
|  |           let status = this.consumerStatusService.newFileUpload(file.name) | ||||||
|  |  | ||||||
|  |           status.message = $localize`Connecting...` | ||||||
|  |  | ||||||
|  |           this.uploadSubscriptions[file.name] = this.documentService | ||||||
|  |             .uploadDocument(formData) | ||||||
|  |             .subscribe({ | ||||||
|  |               next: (event) => { | ||||||
|  |                 if (event.type == HttpEventType.UploadProgress) { | ||||||
|  |                   status.updateProgress( | ||||||
|  |                     FileStatusPhase.UPLOADING, | ||||||
|  |                     event.loaded, | ||||||
|  |                     event.total | ||||||
|  |                   ) | ||||||
|  |                   status.message = $localize`Uploading...` | ||||||
|  |                 } else if (event.type == HttpEventType.Response) { | ||||||
|  |                   status.taskId = event.body['task_id'] | ||||||
|  |                   status.message = $localize`Upload complete, waiting...` | ||||||
|  |                   this.uploadSubscriptions[file.name]?.complete() | ||||||
|  |                 } | ||||||
|  |               }, | ||||||
|  |               error: (error) => { | ||||||
|  |                 switch (error.status) { | ||||||
|  |                   case 400: { | ||||||
|  |                     this.consumerStatusService.fail( | ||||||
|  |                       status, | ||||||
|  |                       error.error.document | ||||||
|  |                     ) | ||||||
|  |                     break | ||||||
|  |                   } | ||||||
|  |                   default: { | ||||||
|  |                     this.consumerStatusService.fail( | ||||||
|  |                       status, | ||||||
|  |                       $localize`HTTP error: ${error.status} ${error.statusText}` | ||||||
|  |                     ) | ||||||
|  |                     break | ||||||
|  |                   } | ||||||
|  |                 } | ||||||
|  |                 this.uploadSubscriptions[file.name]?.complete() | ||||||
|  |               }, | ||||||
|  |             }) | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -15,6 +15,10 @@ | |||||||
|   --ngx-focus-alpha: 0.3; |   --ngx-focus-alpha: 0.3; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |   height: 100vh; | ||||||
|  | } | ||||||
|  |  | ||||||
| svg.logo { | svg.logo { | ||||||
|   .leaf { |   .leaf { | ||||||
|     fill: var(--bs-primary) !important; |     fill: var(--bs-primary) !important; | ||||||
| @@ -244,8 +248,44 @@ table.table { | |||||||
|   color: var(--bs-body-color); |   color: var(--bs-body-color); | ||||||
| } | } | ||||||
|  |  | ||||||
| .ngx-file-drop__drop-zone--over { | .main-dropzone { | ||||||
|   background-color: var(--ngx-primary-faded) !important; |   height: 100%; | ||||||
|  |   width: 100%; | ||||||
|  |  | ||||||
|  |   &.ngx-file-drop__drop-zone--over { | ||||||
|  |     background-color: transparent !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .global-dropzone-overlay { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   right: 0; | ||||||
|  |   bottom: 0; | ||||||
|  |   left: 0; | ||||||
|  |   background-color: rgba(23, 84, 31, .8); | ||||||
|  |   z-index: 1055; // $zindex-modal | ||||||
|  |   pointer-events: none !important; | ||||||
|  |   user-select: none !important; | ||||||
|  |   text-align: center; | ||||||
|  |   padding-top: 25%; | ||||||
|  |  | ||||||
|  |   &.show { | ||||||
|  |     opacity: 1 !important; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.hide { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .ngx-file-drop__drop-zone--over .global-dropzone-overlay { | ||||||
|  |   opacity: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .inert { | ||||||
|  |   pointer-events: none !important; | ||||||
|  |   user-select: none !important; | ||||||
| } | } | ||||||
|  |  | ||||||
| .alert-danger { | .alert-danger { | ||||||
|   | |||||||
| @@ -141,11 +141,11 @@ $border-color-dark-mode: #47494f; | |||||||
|     color: $text-color-dark-mode-accent; |     color: $text-color-dark-mode-accent; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   .close, .modal .btn-close { |   .close, .modal .btn-close, .alert .btn-close { | ||||||
|     text-shadow: 0 1px 0 #666; |     text-shadow: 0 1px 0 #666; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   .modal .btn-close { |   .modal .btn-close, .alert .btn-close { | ||||||
|     filter: invert(1) grayscale(100%) brightness(200%); |     filter: invert(1) grayscale(100%) brightness(200%); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon