mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev' into feature-websockets-status
This commit is contained in:
		| @@ -58,6 +58,7 @@ | ||||
| 									"with": "src/environments/environment.prod.ts" | ||||
| 								} | ||||
| 							], | ||||
| 							"outputPath": "../src/documents/static/frontend/", | ||||
| 							"optimization": true, | ||||
| 							"outputHashing": "none", | ||||
| 							"sourceMap": false, | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										14
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -331,6 +331,12 @@ | ||||
|             "ms": "^2.1.1" | ||||
|           } | ||||
|         }, | ||||
|         "ini": { | ||||
|           "version": "1.3.5", | ||||
|           "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", | ||||
|           "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", | ||||
|           "dev": true | ||||
|         }, | ||||
|         "uuid": { | ||||
|           "version": "8.3.0", | ||||
|           "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", | ||||
| @@ -2178,6 +2184,14 @@ | ||||
|         "pacote": "9.5.12", | ||||
|         "semver": "7.3.2", | ||||
|         "semver-intersect": "1.4.0" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "ini": { | ||||
|           "version": "1.3.5", | ||||
|           "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", | ||||
|           "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", | ||||
|           "dev": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "@types/glob": { | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import { DocumentTypeListComponent } from './components/manage/document-type-lis | ||||
| import { LogsComponent } from './components/manage/logs/logs.component'; | ||||
| import { SettingsComponent } from './components/manage/settings/settings.component'; | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||
| import { DatePipe } from '@angular/common'; | ||||
| import { DatePipe, registerLocaleData } from '@angular/common'; | ||||
| import { NotFoundComponent } from './components/not-found/not-found.component'; | ||||
| import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'; | ||||
| import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component'; | ||||
| @@ -58,8 +58,18 @@ import { MetadataCollapseComponent } from './components/document-detail/metadata | ||||
| import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component'; | ||||
| import { NgSelectModule } from '@ng-select/ng-select'; | ||||
| import { NumberComponent } from './components/common/input/number/number.component'; | ||||
| import { SafePipe } from './pipes/safe.pipe'; | ||||
| import { CustomDatePipe } from './pipes/custom-date.pipe'; | ||||
| import { ConsumerStatusWidgetComponent } from './components/dashboard/widgets/consumer-status-widget/consumer-status-widget.component'; | ||||
|  | ||||
| import localeFr from '@angular/common/locales/fr'; | ||||
| import localeNl from '@angular/common/locales/nl'; | ||||
| import localeDe from '@angular/common/locales/de'; | ||||
|  | ||||
| registerLocaleData(localeFr) | ||||
| registerLocaleData(localeNl) | ||||
| registerLocaleData(localeDe) | ||||
|  | ||||
| @NgModule({ | ||||
|   declarations: [ | ||||
|     AppComponent, | ||||
| @@ -108,6 +118,8 @@ import { ConsumerStatusWidgetComponent } from './components/dashboard/widgets/co | ||||
|     MetadataCollapseComponent, | ||||
|     SelectDialogComponent, | ||||
|     NumberComponent, | ||||
|     SafePipe, | ||||
|     CustomDatePipe, | ||||
|     ConsumerStatusWidgetComponent | ||||
|   ], | ||||
|   imports: [ | ||||
|   | ||||
| @@ -140,6 +140,13 @@ | ||||
|               </svg> <ng-container i18n>Logs</ng-container> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item"> | ||||
|             <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||
|               </svg> <ng-container i18n>Settings</ng-container> | ||||
|             </a> | ||||
|           </li> | ||||
|           <li class="nav-item"> | ||||
|             <a class="nav-link" href="admin/"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|   | ||||
| @@ -113,7 +113,7 @@ | ||||
|     background-color: rgba(0, 0, 0, 0.15); | ||||
|     padding-left: 1.8rem; | ||||
|     border-color: rgba(255, 255, 255, 0.2); | ||||
|     transition: flex 0.3s ease; | ||||
|     transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all | ||||
|     max-width: 600px; | ||||
|     min-width: 300px; // 1/2 max | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ | ||||
|       <p *ngIf="message">{{message}}</p> | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|       <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" [disabled]="!buttonsEnabled">Cancel</button> | ||||
|       <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button> | ||||
|       <button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|         <span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
|   <div class="btn-group" ngbDropdown role="group"> | ||||
|   <div class="btn-group w-100" ngbDropdown role="group"> | ||||
|   <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'"> | ||||
|     {{title}} | ||||
|   </button> | ||||
|   | ||||
| @@ -27,6 +27,8 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI | ||||
|  | ||||
|   networkActive = false | ||||
|  | ||||
|   closeEnabled = false | ||||
|  | ||||
|   error = null | ||||
|  | ||||
|   abstract getForm(): FormGroup | ||||
| @@ -37,6 +39,11 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI | ||||
|     if (this.object != null) { | ||||
|       this.objectForm.patchValue(this.object) | ||||
|     } | ||||
|  | ||||
|     // wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM | ||||
|     setTimeout(() => { | ||||
|       this.closeEnabled = true | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -86,7 +93,6 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI | ||||
|     serverResponse.subscribe(result => { | ||||
|       this.activeModal.close() | ||||
|       this.success.emit(result) | ||||
|       this.networkActive = false | ||||
|     }, error => { | ||||
|       this.error = error.error | ||||
|       this.networkActive = false | ||||
|   | ||||
| @@ -1,11 +1,9 @@ | ||||
| <div class="btn-group" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown"> | ||||
| <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown"> | ||||
|   <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'"> | ||||
|     <div class="d-none d-md-inline">{{title}}</div> | ||||
|     <div class="d-inline-block d-md-none"> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> | ||||
|       </svg> | ||||
|     </div> | ||||
|     <svg class="toolbaricon" fill="currentColor"> | ||||
|       <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> | ||||
|     </svg> | ||||
|     <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|     <ng-container *ngIf="!editing && selectionModel.selectionSize() > 0"> | ||||
|       <div class="badge bg-secondary text-light rounded-pill badge-corner"> | ||||
|         {{selectionModel.selectionSize()}} | ||||
| @@ -20,7 +18,7 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|       <div *ngIf="selectionModel.items" class="items"> | ||||
|         <ng-container *ngFor="let item of (editing ? selectionModel.itemsSorted : selectionModel.items) | filter: filterText"> | ||||
|         <ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText"> | ||||
|           <app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)"></app-toggleable-dropdown-button> | ||||
|         </ng-container> | ||||
|       </div> | ||||
|   | ||||
| @@ -19,8 +19,13 @@ export class FilterableDropdownSelectionModel { | ||||
|   items: MatchingModel[] = [] | ||||
|  | ||||
|   get itemsSorted(): MatchingModel[] { | ||||
|     // TODO: this is getting called very often | ||||
|     return this.items.sort((a,b) => { | ||||
|       if (this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(b.id) != ToggleableItemState.NotSelected) { | ||||
|       if (a.id == null && b.id != null) { | ||||
|         return -1 | ||||
|       } else if (a.id != null && b.id == null) { | ||||
|         return 1 | ||||
|       } else if (this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(b.id) != ToggleableItemState.NotSelected) { | ||||
|         return 1 | ||||
|       } else if (this.getNonTemporary(a.id) != ToggleableItemState.NotSelected && this.getNonTemporary(b.id) == ToggleableItemState.NotSelected) { | ||||
|         return -1 | ||||
|   | ||||
| @@ -1,10 +1,13 @@ | ||||
| import { Directive, Input, OnInit } from '@angular/core'; | ||||
| import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ControlValueAccessor } from '@angular/forms'; | ||||
| import { v4 as uuidv4 } from 'uuid'; | ||||
|  | ||||
| @Directive() | ||||
| export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor { | ||||
|  | ||||
|   @ViewChild("inputField") | ||||
|   inputField: ElementRef | ||||
|  | ||||
|   constructor() { } | ||||
|  | ||||
|   onChange = (newValue: T) => {}; | ||||
| @@ -24,6 +27,12 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor { | ||||
|     this.disabled = isDisabled; | ||||
|   } | ||||
|  | ||||
|   focus() { | ||||
|     if (this.inputField && this.inputField.nativeElement) { | ||||
|       this.inputField.nativeElement.focus() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   title: string | ||||
|  | ||||
|   | ||||
| @@ -29,7 +29,7 @@ export class NumberComponent extends AbstractInputComponent<number> { | ||||
|         if (results.count > 0) { | ||||
|           this.value = results.results[0].archive_serial_number + 1 | ||||
|         } else { | ||||
|           this.value + 1 | ||||
|           this.value = 1 | ||||
|         } | ||||
|         this.onChange(this.value) | ||||
|       } | ||||
|   | ||||
| @@ -6,9 +6,11 @@ | ||||
|       [style.color]="textColor" | ||||
|       [style.background]="backgroundColor" | ||||
|       [clearable]="allowNull" | ||||
|       [items]="items" | ||||
|       bindLabel="name" | ||||
|       bindValue="id" | ||||
|       (change)="onChange(value)" | ||||
|       (blur)="onTouched()"> | ||||
|       <ng-option *ngFor="let i of items" [value]="i.id">{{i.name}}</ng-option> | ||||
|     </ng-select> | ||||
|  | ||||
|     <div *ngIf="showPlusButton()" class="input-group-append"> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <div class="form-group paperless-input-select paperless-input-tags"> | ||||
|   <label for="tags">Tags</label> | ||||
|   <label for="tags" i18n>Tags</label> | ||||
|  | ||||
|   <div class="input-group flex-nowrap"> | ||||
|     <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="displayValue" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <div class="form-group"> | ||||
|   <label [for]="inputId">{{title}}</label> | ||||
|   <input type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)"> | ||||
|   <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)"> | ||||
|   <small *ngIf="hint" class="form-text text-muted">{{hint}}</small> | ||||
|   <div class="invalid-feedback"> | ||||
|     {{error}} | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| <div class="row pt-3 pb-1 mb-3 border-bottom align-items-center" > | ||||
| <div class="row pt-3 pb-3 pb-md-1 mb-3 border-bottom align-items-center"> | ||||
|   <div class="col-md text-truncate"> | ||||
|     <p class="h2 text-truncate" style="line-height: 1.4">{{title}}</p> | ||||
|     <p *ngIf="subTitle" class="h5 text-truncate" style="line-height: 1.4">{{subTitle}}</p> | ||||
|   </div> | ||||
|   <div class="btn-toolbar col-auto"> | ||||
|   <div class="btn-toolbar col col-md-auto"> | ||||
|     <ng-content></ng-content> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -12,7 +12,7 @@ | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <tr *ngFor="let doc of documents" routerLink="/documents/{{doc.id}}"> | ||||
|         <td>{{doc.created | date}}</td> | ||||
|         <td>{{doc.created | customDate}}</td> | ||||
|         <td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ml-1"></app-tag> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| table { | ||||
|   overflow-wrap: anywhere; | ||||
|   table-layout: fixed; | ||||
| } | ||||
|  | ||||
| th:first-child { | ||||
|   min-width: 5rem; | ||||
|   width: 25%; | ||||
| } | ||||
|   | ||||
| @@ -50,7 +50,7 @@ export class SavedViewWidgetComponent implements OnInit { | ||||
|     } else { | ||||
|       this.list.load(this.savedView) | ||||
|       this.router.navigate(["documents"]) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <app-page-header [(title)]="title"> | ||||
|     <div class="input-group input-group-sm mr-5" *ngIf="getContentType() == 'application/pdf'"> | ||||
|     <div class="input-group input-group-sm mr-5 d-none d-md-flex" *ngIf="getContentType() == 'application/pdf' && !useNativePdfViewer"> | ||||
|       <div class="input-group-prepend"> | ||||
|         <div class="input-group-text" i18n>Page</div> | ||||
|       </div> | ||||
| @@ -9,7 +9,7 @@ | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <button type="button" class="btn btn-sm btn-outline-danger mr-2" (click)="delete()"> | ||||
|     <button type="button" class="btn btn-sm btn-outline-danger mr-2 ml-auto" (click)="delete()"> | ||||
|         <svg class="buttonicon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|         </svg> <span class="d-none d-lg-inline" i18n>Delete</span> | ||||
| @@ -56,14 +56,14 @@ | ||||
|                     <a ngbNavLink i18n>Details</a> | ||||
|                     <ng-template ngbNavContent> | ||||
|  | ||||
|                         <app-input-text i18n-title title="Title" formControlName="title" [error]="error?.title"></app-input-text> | ||||
|                         <app-input-text #inputTitle i18n-title title="Title" formControlName="title" [error]="error?.title"></app-input-text> | ||||
|                         <app-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></app-input-number> | ||||
|                         <app-input-date-time i18n-titleDate titleDate="Date created" formControlName="created"></app-input-date-time> | ||||
|                         <app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" | ||||
|                             (createNew)="createCorrespondent()"></app-input-select> | ||||
|                         <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" | ||||
|                             (createNew)="createDocumentType()"></app-input-select> | ||||
|                         <app-input-tags formControlName="tags" i18n-title title="Tags"></app-input-tags> | ||||
|                         <app-input-tags formControlName="tags"></app-input-tags> | ||||
|  | ||||
|                     </ng-template> | ||||
|                 </li> | ||||
| @@ -85,11 +85,11 @@ | ||||
|                             <tbody> | ||||
|                                 <tr> | ||||
|                                     <td i18n>Date modified</td> | ||||
|                                     <td>{{document.modified | date:'medium'}}</td> | ||||
|                                     <td>{{document.modified | customDate}}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <td i18n>Date added</td> | ||||
|                                     <td>{{document.added | date:'medium'}}</td> | ||||
|                                     <td>{{document.added | customDate}}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <td i18n>Media filename</td> | ||||
| @@ -134,8 +134,17 @@ | ||||
|     </div> | ||||
|  | ||||
|     <div class="col-md-6 col-xl-8 mb-3"> | ||||
|       <div class="pdf-viewer-container" *ngIf="getContentType() == 'application/pdf'"> | ||||
|         <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> | ||||
|       </div> | ||||
|         <ng-container *ngIf="getContentType() == 'application/pdf'"> | ||||
|             <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> | ||||
|                 <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> | ||||
|             </div> | ||||
|             <ng-template #nativePdfViewer> | ||||
|                 <object [data]="previewUrl | safe" type="application/pdf" class="preview-sticky" width="100%"></object> | ||||
|             </ng-template> | ||||
|         </ng-container> | ||||
|         <ng-container *ngIf="getContentType() == 'text/plain'"> | ||||
|             <object [data]="previewUrl | safe" type="text/plain" class="preview-sticky" width="100%"></object> | ||||
|         </ng-container> | ||||
|            | ||||
|     </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| .pdf-viewer-container { | ||||
| .preview-sticky { | ||||
|   height: calc(100vh - 160px); | ||||
|   top: 70px; | ||||
|   position: sticky; | ||||
| } | ||||
|  | ||||
| .pdf-viewer-container { | ||||
|   background-color: gray; | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | ||||
| import { FormControl, FormGroup } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| @@ -17,6 +17,8 @@ import { CorrespondentEditDialogComponent } from '../manage/correspondent-list/c | ||||
| import { DocumentTypeEditDialogComponent } from '../manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component'; | ||||
| import { PDFDocumentProxy } from 'ng2-pdf-viewer'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
| import { TextComponent } from '../common/input/text/text.component'; | ||||
| import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-document-detail', | ||||
| @@ -25,6 +27,9 @@ import { ToastService } from 'src/app/services/toast.service'; | ||||
| }) | ||||
| export class DocumentDetailComponent implements OnInit { | ||||
|  | ||||
|   @ViewChild("inputTitle") | ||||
|   titleInput: TextComponent | ||||
|  | ||||
|   expandOriginalMetadata = false | ||||
|   expandArchivedMetadata = false | ||||
|  | ||||
| @@ -66,7 +71,12 @@ export class DocumentDetailComponent implements OnInit { | ||||
|     private openDocumentService: OpenDocumentsService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private documentTitlePipe: DocumentTitlePipe, | ||||
|     private toastService: ToastService) { } | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService) { } | ||||
|  | ||||
|   get useNativePdfViewer(): boolean { | ||||
|     return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER) | ||||
|   } | ||||
|  | ||||
|   getContentType() { | ||||
|     return this.metadata?.has_archive_version ? 'application/pdf' : this.metadata?.original_mime_type | ||||
| @@ -157,6 +167,7 @@ export class DocumentDetailComponent implements OnInit { | ||||
|         if (nextDocId) { | ||||
|           this.openDocumentService.closeDocument(this.document) | ||||
|           this.router.navigate(['documents', nextDocId]) | ||||
|           this.titleInput.focus() | ||||
|         } | ||||
|       }, error => { | ||||
|         this.networkActive = false | ||||
|   | ||||
| @@ -6,8 +6,7 @@ | ||||
|       </svg> <ng-container i18n>Cancel</ng-container> | ||||
|     </button> | ||||
|   </div> | ||||
|   <div class="w-100 d-xl-none"></div> | ||||
|   <div class="col-auto mb-2 mb-xl-0" role="group" aria-label="Select"> | ||||
|   <div class="col-auto mb-2 mb-xl-0 ml-auto ml-md-0" role="group" aria-label="Select"> | ||||
|     <label class="mr-2 mb-0" i18n>Select:</label> | ||||
|     <div class="btn-group"> | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
| @@ -56,9 +55,8 @@ | ||||
|       </app-filterable-dropdown> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="w-100 d-xl-none"></div> | ||||
|   <div class="col mb-2 mb-xl-0 d-flex"> | ||||
|     <button type="button" class="btn btn-sm btn-outline-danger ml-0 ml-lg-auto" (click)="applyDelete()"> | ||||
|   <div class="col-auto ml-auto mb-2 mb-xl-0 d-flex"> | ||||
|     <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> | ||||
|       <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|       </svg> <ng-container i18n>Delete</ng-container> | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| <div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable"> | ||||
|   <div class="row no-gutters"> | ||||
|     <div class="col-md-2 d-none d-lg-block doc-img-background rounded-left" [class.doc-img-background-selected]="selected"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left" (click)="setSelected(selectable ? !selected : false)"> | ||||
|     <div class="col-md-2 d-none d-lg-block doc-img-background rounded-left" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left"> | ||||
|  | ||||
|       <div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="setSelected($event.target.checked)"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|           <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label> | ||||
|         </div> | ||||
|       </div> | ||||
| @@ -17,11 +17,11 @@ | ||||
|         <div class="d-flex justify-content-between align-items-center"> | ||||
|           <h5 class="card-title"> | ||||
|             <ng-container *ngIf="document.correspondent"> | ||||
|               <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a> | ||||
|               <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a> | ||||
|               <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>: | ||||
|             </ng-container> | ||||
|             {{document.title | documentTitle}} | ||||
|             <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag> | ||||
|             <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></app-tag> | ||||
|           </h5> | ||||
|           <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5> | ||||
|         </div> | ||||
| @@ -31,38 +31,40 @@ | ||||
|         </p> | ||||
|  | ||||
|  | ||||
|         <div class="d-flex align-items-center"> | ||||
|         <div class="d-flex flex-column flex-md-row align-items-md-center"> | ||||
|           <div class="btn-group"> | ||||
|             <a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis"> | ||||
|               <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16"> | ||||
|                 <path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/> | ||||
|               </svg> <ng-container i18n>More like this</ng-container> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>More like this</span> | ||||
|             </a> | ||||
|             <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> | ||||
|               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|                 <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> | ||||
|               </svg> <ng-container i18n>Edit</ng-container> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>Edit</span> | ||||
|             </a> | ||||
|             <a type="button" class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()"> | ||||
|             <a class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()"> | ||||
|               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|                 <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/> | ||||
|                 <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> | ||||
|               </svg> <ng-container i18n>View</ng-container> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>View</span> | ||||
|             </a> | ||||
|             <a type="button" class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()"> | ||||
|             <a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()"> | ||||
|               <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|                 <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> | ||||
|                 <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> | ||||
|               </svg> <ng-container i18n>Download</ng-container> | ||||
|               </svg> <span class="d-block d-md-inline" i18n>Download</span> | ||||
|             </a> | ||||
|  | ||||
|           </div> | ||||
|  | ||||
|           <small *ngIf="searchScore" class="text-muted ml-auto" i18n>Score:</small> | ||||
|           <div *ngIf="searchScore" class="d-flex align-items-center ml-md-auto mt-2 mt-md-0"> | ||||
|             <small class="text-muted" i18n>Score:</small> | ||||
|  | ||||
|           <ngb-progressbar *ngIf="searchScore" [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2" [max]="1"></ngb-progressbar> | ||||
|             <ngb-progressbar [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2" [max]="1"></ngb-progressbar> | ||||
|           </div> | ||||
|  | ||||
|           <small class="text-muted" [class.ml-auto]="!searchScore" i18n>Created: {{document.created | date}}</small> | ||||
|           <small class="text-muted" [class.ml-auto]="!searchScore" i18n>Created: {{document.created | customDate}}</small> | ||||
|         </div> | ||||
|  | ||||
|       </div> | ||||
|   | ||||
| @@ -12,10 +12,14 @@ | ||||
|   mix-blend-mode: multiply; | ||||
| } | ||||
|  | ||||
| .card-title { | ||||
|   word-break: break-word; | ||||
| } | ||||
|  | ||||
| .search-score-bar { | ||||
|   width: 100px; | ||||
|   height: 5px; | ||||
|   margin-top: 2px; | ||||
|   margin-top: 1px; | ||||
| } | ||||
|  | ||||
| .document-card-check { | ||||
|   | ||||
| @@ -15,16 +15,11 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|   @Input() | ||||
|   selected = false | ||||
|  | ||||
|   setSelected(value: boolean) { | ||||
|     this.selected = value | ||||
|     this.selectedChange.emit(value) | ||||
|   } | ||||
|  | ||||
|   @Output() | ||||
|   selectedChange = new EventEmitter<boolean>() | ||||
|   toggleSelected = new EventEmitter() | ||||
|  | ||||
|   get selectable() { | ||||
|     return this.selectedChange.observers.length > 0 | ||||
|     return this.toggleSelected.observers.length > 0 | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   | ||||
| @@ -1,18 +1,18 @@ | ||||
| <div class="col p-2 h-100"> | ||||
|   <div class="card h-100 shadow-sm document-card" [class.card-selected]="selected"> | ||||
|     <div class="border-bottom doc-img-container" [class.doc-img-background-selected]="selected"> | ||||
|       <img class="card-img doc-img rounded-top" [src]="getThumbUrl()" (click)="setSelected(!selected)"> | ||||
|     <div class="border-bottom doc-img-container" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|       <img class="card-img doc-img rounded-top" [src]="getThumbUrl()"> | ||||
|  | ||||
|       <div class="border-right border-bottom bg-light p-1 rounded document-card-check"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="setSelected($event.target.checked)"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|           <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1"> | ||||
|         <div *ngFor="let t of getTagsLimited$() | async"> | ||||
|           <app-tag [tag]="t" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag> | ||||
|           <app-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag> | ||||
|         </div> | ||||
|         <div *ngIf="moreTags"> | ||||
|           <span class="badge badge-secondary">+ {{moreTags}}</span> | ||||
| @@ -23,9 +23,9 @@ | ||||
|     <div class="card-body p-2"> | ||||
|       <p class="card-text"> | ||||
|         <ng-container *ngIf="document.correspondent"> | ||||
|           <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: | ||||
|           <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: | ||||
|         </ng-container> | ||||
|         {{document.title | documentTitle}} | ||||
|         {{document.title | documentTitle}} <span *ngIf="document.archive_serial_number">(#{{document.archive_serial_number}})</span> | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="card-footer"> | ||||
| @@ -43,14 +43,14 @@ | ||||
|               <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> | ||||
|             </svg> | ||||
|           </a> | ||||
|           <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title> | ||||
|           <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" (click)="$event.stopPropagation()" i18n-title> | ||||
|             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|               <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> | ||||
|               <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> | ||||
|             </svg> | ||||
|           </a> | ||||
|         </div> | ||||
|         <small class="text-muted pl-1">{{document.created | date}}</small> | ||||
|         <small class="text-muted pl-1">{{document.created | customDate:'shortDate'}}</small> | ||||
|       </div> | ||||
|  | ||||
|     </div> | ||||
|   | ||||
| @@ -14,14 +14,9 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|  | ||||
|   @Input() | ||||
|   selected = false | ||||
|  | ||||
|   setSelected(value: boolean) { | ||||
|     this.selected = value | ||||
|     this.selectedChange.emit(value) | ||||
|   } | ||||
|  | ||||
|    | ||||
|   @Output() | ||||
|   selectedChange = new EventEmitter<boolean>() | ||||
|   toggleSelected = new EventEmitter() | ||||
|  | ||||
|   @Input() | ||||
|   document: PaperlessDocument | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| <app-page-header [title]="getTitle()"> | ||||
|  | ||||
|   <div ngbDropdown class="d-inline-block mr-2"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|   <div ngbDropdown class="mr-2 flex-fill d-flex"> | ||||
|     <button class="btn btn-sm btn-outline-primary flex-fill" id="dropdownSelect" ngbDropdownToggle> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#text-indent-left" /> | ||||
|       </svg> <ng-container i18n>Select</ng-container> | ||||
|        | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> | ||||
| @@ -14,7 +13,7 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode" | ||||
|   <div class="btn-group btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="displayMode" | ||||
|     (ngModelChange)="saveDisplayMode()"> | ||||
|     <label ngbButtonLabel class="btn-outline-primary btn-sm"> | ||||
|       <input ngbButton type="radio" class="btn btn-sm" value="details"> | ||||
| @@ -36,49 +35,48 @@ | ||||
|     </label> | ||||
|   </div> | ||||
|  | ||||
|   <div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortReverse"> | ||||
|     <div ngbDropdown class="btn-group"> | ||||
|       <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort by</button> | ||||
|       <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow"> | ||||
|   <div ngbDropdown class="btn-group ml-2 flex-fill"> | ||||
|     <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right"> | ||||
|       <div class="w-100 d-flex btn-group-toggle pb-2 mb-1 border-bottom" ngbRadioGroup [(ngModel)]="list.sortReverse"> | ||||
|         <label ngbButtonLabel class="btn-outline-primary btn-sm mx-2 flex-fill"> | ||||
|           <input ngbButton type="radio" class="btn btn-sm" [value]="false"> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" /> | ||||
|           </svg> | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-outline-primary btn-sm mr-2 flex-fill"> | ||||
|           <input ngbButton type="radio" class="btn btn-sm" [value]="true"> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" /> | ||||
|           </svg> | ||||
|         </label> | ||||
|       </div> | ||||
|       <div> | ||||
|         <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field" | ||||
|           [class.active]="list.sortField == f.field">{{f.name}}</button> | ||||
|           [class.active]="list.sortField == f.field">{{f.name}} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|     <label ngbButtonLabel class="btn-outline-primary btn-sm"> | ||||
|       <input ngbButton type="radio" class="btn btn-sm" [value]="false"> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" /> | ||||
|       </svg> | ||||
|     </label> | ||||
|     <label ngbButtonLabel class="btn-outline-primary btn-sm"> | ||||
|       <input ngbButton type="radio" class="btn btn-sm" [value]="true"> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" /> | ||||
|       </svg> | ||||
|     </label> | ||||
|   </div> | ||||
|  | ||||
|   <div class="btn-group ml-2"> | ||||
|   <div class="btn-group ml-2 flex-fill" ngbDropdown role="group"> | ||||
|     <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" ngbDropdownToggle i18n>Views</button> | ||||
|     <div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu> | ||||
|       <ng-container *ngIf="!list.savedViewId"> | ||||
|         <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button> | ||||
|         <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> | ||||
|       </ng-container> | ||||
|  | ||||
|     <div class="btn-group" ngbDropdown role="group"> | ||||
|       <button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle i18n>Views</button> | ||||
|       <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||
|         <ng-container *ngIf="!list.savedViewId"> | ||||
|           <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button> | ||||
|           <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> | ||||
|         </ng-container> | ||||
|  | ||||
|         <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId" i18n>Save "{{list.savedViewTitle}}"</button> | ||||
|         <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> | ||||
|       </div> | ||||
|       <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId" i18n>Save "{{list.savedViewTitle}}"</button> | ||||
|       <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> | ||||
|     </div> | ||||
|  | ||||
|   </div> | ||||
|  | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="w-100 mb-2 mb-sm-4"> | ||||
|   <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" #filterEditor></app-filter-editor> | ||||
|   <app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [rulesModified]="filterRulesModified" (filterRulesChange)="rulesChanged()" (reset)="resetFilters()" #filterEditor></app-filter-editor> | ||||
|   <app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor> | ||||
| </div> | ||||
|  | ||||
| @@ -92,7 +90,7 @@ | ||||
| </div> | ||||
|  | ||||
| <div *ngIf="displayMode == 'largeCards'"> | ||||
|   <app-document-card-large [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)"   *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   </app-document-card-large> | ||||
| </div> | ||||
|  | ||||
| @@ -104,55 +102,43 @@ | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n> | ||||
|       ASN | ||||
|     </th> | ||||
|       i18n>ASN</th> | ||||
|     <th class="d-none d-md-table-cell" | ||||
|       sortable="correspondent__name" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n> | ||||
|       Correspondent | ||||
|     </th> | ||||
|       i18n>Correspondent</th> | ||||
|     <th | ||||
|       sortable="title" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n> | ||||
|       Title | ||||
|     </th> | ||||
|       i18n>Title</th> | ||||
|     <th class="d-none d-xl-table-cell" | ||||
|       sortable="document_type__name" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n> | ||||
|       Document type | ||||
|     </th> | ||||
|       i18n>Document type</th> | ||||
|     <th | ||||
|       sortable="created" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n> | ||||
|       Created | ||||
|     </th> | ||||
|       i18n>Created</th> | ||||
|     <th class="d-none d-xl-table-cell" | ||||
|       sortable="added" | ||||
|       [currentSortField]="list.sortField" | ||||
|       [currentSortReverse]="list.sortReverse" | ||||
|       (sort)="onSort($event)" | ||||
|       i18n> | ||||
|       Added | ||||
|     </th> | ||||
|       i18n>Added</th> | ||||
|   </thead> | ||||
|   <tbody> | ||||
|     <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|     <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|       <td> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (change)="list.setSelected(d, $event.target.checked)"> | ||||
|           <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)"> | ||||
|           <label class="custom-control-label" for="docCheck{{d.id}}"></label> | ||||
|         </div> | ||||
|       </td> | ||||
| @@ -161,28 +147,28 @@ | ||||
|       </td> | ||||
|       <td class="d-none d-md-table-cell"> | ||||
|         <ng-container *ngIf="d.correspondent"> | ||||
|           <a [routerLink]="" (click)="clickCorrespondent(d.correspondent)" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||
|           <a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||
|         </ng-container> | ||||
|       </td> | ||||
|       <td> | ||||
|         <a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> | ||||
|         <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="clickTag(t.id)"></app-tag> | ||||
|         <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ml-1" clickable="true" linkTitle="Filter by tag" (click)="clickTag(t.id);$event.stopPropagation()"></app-tag> | ||||
|       </td> | ||||
|       <td class="d-none d-xl-table-cell"> | ||||
|         <ng-container *ngIf="d.document_type"> | ||||
|           <a [routerLink]="" (click)="clickDocumentType(d.document_type)" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|           <a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|         </ng-container> | ||||
|       </td> | ||||
|       <td> | ||||
|         {{d.created | date}} | ||||
|         {{d.created | customDate}} | ||||
|       </td> | ||||
|       <td class="d-none d-xl-table-cell"> | ||||
|         {{d.added | date}} | ||||
|         {{d.added | customDate}} | ||||
|       </td> | ||||
|     </tr> | ||||
|   </tbody> | ||||
| </table> | ||||
|  | ||||
| <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|   <app-document-card-small [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
|   <app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
| </div> | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| @import "/src/theme"; | ||||
|  | ||||
| tr { | ||||
|   user-select: none; | ||||
| } | ||||
|  | ||||
| .table-row-selected { | ||||
|   background-color: $primaryFaded; | ||||
| } | ||||
| @@ -25,3 +29,8 @@ $paperless-card-breakpoints: ( | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .dropdown-menu-right { | ||||
|   right: 0 !important; | ||||
|   left: auto !important; | ||||
| } | ||||
|   | ||||
| @@ -33,6 +33,8 @@ export class DocumentListComponent implements OnInit { | ||||
|  | ||||
|   displayMode = 'smallCards' // largeCards, smallCards, details | ||||
|  | ||||
|   filterRulesModified: boolean = false | ||||
|  | ||||
|   get isFiltered() { | ||||
|     return this.list.filterRules?.length > 0 | ||||
|   } | ||||
| @@ -69,13 +71,14 @@ export class DocumentListComponent implements OnInit { | ||||
|             this.router.navigate(["404"]) | ||||
|             return | ||||
|           } | ||||
|  | ||||
|           this.list.savedView = view | ||||
|           this.list.reload() | ||||
|           this.rulesChanged() | ||||
|         }) | ||||
|       } else { | ||||
|         this.list.savedView = null | ||||
|         this.list.reload() | ||||
|         this.rulesChanged() | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| @@ -83,6 +86,7 @@ export class DocumentListComponent implements OnInit { | ||||
|   loadViewConfig(view: PaperlessSavedView) { | ||||
|     this.list.load(view) | ||||
|     this.list.reload() | ||||
|     this.rulesChanged() | ||||
|   } | ||||
|  | ||||
|   saveViewConfig() { | ||||
| @@ -105,6 +109,7 @@ export class DocumentListComponent implements OnInit { | ||||
|         sort_reverse: this.list.sortReverse, | ||||
|         sort_field: this.list.sortField | ||||
|       } | ||||
|  | ||||
|       this.savedViewService.create(savedView).subscribe(() => { | ||||
|         modal.close() | ||||
|         this.toastService.showInfo($localize`View "${savedView.name}" created successfully.`) | ||||
| @@ -115,6 +120,51 @@ export class DocumentListComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   resetFilters(): void { | ||||
|     this.filterRulesModified = false | ||||
|     if (this.list.savedViewId) { | ||||
|       this.savedViewService.getCached(this.list.savedViewId).subscribe(viewUntouched => { | ||||
|         this.list.filterRules = viewUntouched.filter_rules | ||||
|         this.list.reload() | ||||
|       }) | ||||
|     } else { | ||||
|       this.list.filterRules = [] | ||||
|       this.list.reload() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   rulesChanged() { | ||||
|     let modified = false | ||||
|     if (this.list.savedView == null) { | ||||
|       modified = this.list.filterRules.length > 0 // documents list is modified if it has any filters | ||||
|     } else { | ||||
|       // compare savedView current filters vs original | ||||
|       this.savedViewService.getCached(this.list.savedViewId).subscribe(view => { | ||||
|         let filterRulesInitial = view.filter_rules | ||||
|  | ||||
|         if (this.list.filterRules.length !== filterRulesInitial.length) modified = true | ||||
|         else { | ||||
|           modified = this.list.filterRules.some(rule => { | ||||
|             return (filterRulesInitial.find(fri => fri.rule_type == rule.rule_type && fri.value == rule.value) == undefined) | ||||
|           }) | ||||
|  | ||||
|           if (!modified) { | ||||
|             // only check other direction if we havent already determined is modified | ||||
|             modified = filterRulesInitial.some(rule => { | ||||
|               this.list.filterRules.find(fr => fr.rule_type == rule.rule_type && fr.value == rule.value) == undefined | ||||
|             }) | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|     this.filterRulesModified = modified | ||||
|   } | ||||
|  | ||||
|   toggleSelected(document: PaperlessDocument, event: MouseEvent): void { | ||||
|     if (!event.shiftKey) this.list.toggleSelected(document) | ||||
|     else this.list.selectRangeTo(document) | ||||
|   } | ||||
|  | ||||
|   clickTag(tagID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|   | ||||
| @@ -1,33 +1,36 @@ | ||||
| <div class="row"> | ||||
|    <div class="col mb-2 mb-xl-0"> | ||||
|      <div class="form-inline d-flex"> | ||||
|          <label class="text-muted mr-2" i18n>Filter by:</label> | ||||
|          <input class="form-control form-control-sm flex-grow-1" type="text" [(ngModel)]="titleFilter" placeholder="Title" i18n-placeholder> | ||||
|      <div class="form-inline d-flex align-items-center"> | ||||
|          <label class="text-muted mr-2 mb-0" i18n>Filter by:</label> | ||||
|          <input class="form-control form-control-sm flex-fill w-auto" type="text" [(ngModel)]="titleFilter" placeholder="Title" i18n-placeholder> | ||||
|      </div> | ||||
|   </div> | ||||
|   <div class="w-100 d-xl-none"></div> | ||||
|     <div class="col col-xl-auto mb-2 mb-xl-0"> | ||||
|       <div class="d-flex"> | ||||
|         <app-filterable-dropdown class="mr-2 mr-md-3" title="Tags" icon="tag-fill" i18n-title | ||||
|         <app-filterable-dropdown class="mr-2 flex-fill" title="Tags" icon="tag-fill" i18n-title | ||||
|           filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|           [items]="tags" | ||||
|           [(selectionModel)]="tagSelectionModel" | ||||
|           (selectionModelChange)="updateRules()" | ||||
|           [multiple]="true" | ||||
|           (open)="onTagsDropdownOpen()" | ||||
|           [allowSelectNone]="true"></app-filterable-dropdown> | ||||
|         <app-filterable-dropdown class="mr-2 mr-md-3" title="Correspondent" icon="person-fill" i18n-title | ||||
|         <app-filterable-dropdown class="mr-2 flex-fill" title="Correspondent" icon="person-fill" i18n-title | ||||
|           filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|           [items]="correspondents" | ||||
|           [(selectionModel)]="correspondentSelectionModel" | ||||
|           (selectionModelChange)="updateRules()" | ||||
|           (open)="onCorrespondentDropdownOpen()" | ||||
|           [allowSelectNone]="true"></app-filterable-dropdown> | ||||
|         <app-filterable-dropdown class="mr-2 mr-md-3" title="Document type" icon="file-earmark-fill" i18n-title | ||||
|         <app-filterable-dropdown class="mr-2 flex-fill" title="Document type" icon="file-earmark-fill" i18n-title | ||||
|           filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|           [items]="documentTypes" | ||||
|           [(selectionModel)]="documentTypeSelectionModel" | ||||
|           (open)="onDocumentTypeDropdownOpen()" | ||||
|           (selectionModelChange)="updateRules()" | ||||
|           [allowSelectNone]="true"></app-filterable-dropdown> | ||||
|         <app-date-dropdown class="mr-2 mr-md-3" | ||||
|         <app-date-dropdown class="mr-2" | ||||
|           title="Created" i18n-title | ||||
|           (datesSet)="updateRules()" | ||||
|           [(dateBefore)]="dateCreatedBefore" | ||||
| @@ -41,11 +44,11 @@ | ||||
|    </div> | ||||
|    <div class="w-100 d-xl-none"></div> | ||||
|    <div class="col col-xl-auto mb-2 mb-xl-0"> | ||||
|      <button class="btn btn-link btn-sm px-0 mx-0 ml-xl-n4" [disabled]="!hasFilters()" (click)="clearSelected()"> | ||||
|      <button class="btn btn-link btn-sm px-0 mx-0 ml-xl-n4" [disabled]="!rulesModified" (click)="resetSelected()"> | ||||
|        <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x" 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"/> | ||||
|        </svg> <ng-container i18n>Clear all filters</ng-container> | ||||
|         | ||||
|        </svg> <ng-container i18n>Reset filters</ng-container> | ||||
|  | ||||
|      </button> | ||||
|    </div> | ||||
| </div> | ||||
|   | ||||
| @@ -40,7 +40,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|         case FILTER_HAS_TAG: | ||||
|           return $localize`Tag: ${this.tags.find(t => t.id == +rule.value)?.name}` | ||||
|          | ||||
|  | ||||
|         case FILTER_HAS_ANY_TAG: | ||||
|           if (rule.value == "false") { | ||||
|             return $localize`Without any tag` | ||||
| @@ -127,7 +127,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     } else { | ||||
|       this.tagSelectionModel.getSelectedItems().filter(tag => tag.id).forEach(tag => { | ||||
|         filterRules.push({rule_type: FILTER_HAS_TAG, value: tag.id?.toString()}) | ||||
|       })   | ||||
|       }) | ||||
|     } | ||||
|     this.correspondentSelectionModel.getSelectedItems().forEach(correspondent => { | ||||
|       filterRules.push({rule_type: FILTER_CORRESPONDENT, value: correspondent.id?.toString()}) | ||||
| @@ -153,16 +153,16 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   @Output() | ||||
|   filterRulesChange = new EventEmitter<FilterRule[]>() | ||||
|  | ||||
|   @Output() | ||||
|   reset = new EventEmitter() | ||||
|  | ||||
|   @Input() | ||||
|   rulesModified: boolean = false | ||||
|  | ||||
|   updateRules() { | ||||
|     this.filterRulesChange.next(this.filterRules) | ||||
|   } | ||||
|  | ||||
|   hasFilters() { | ||||
|     return this._titleFilter ||  | ||||
|       this.dateAddedAfter || this.dateAddedBefore || this.dateCreatedAfter || this.dateCreatedBefore || | ||||
|       this.tagSelectionModel.selectionSize() || this.correspondentSelectionModel.selectionSize() || this.documentTypeSelectionModel.selectionSize() | ||||
|   } | ||||
|  | ||||
|   get titleFilter() { | ||||
|     return this._titleFilter | ||||
|   } | ||||
| @@ -194,16 +194,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this.titleFilterDebounce.complete() | ||||
|   } | ||||
|  | ||||
|   clearSelected() { | ||||
|     this._titleFilter = "" | ||||
|     this.tagSelectionModel.clear(false) | ||||
|     this.documentTypeSelectionModel.clear(false) | ||||
|     this.correspondentSelectionModel.clear(false) | ||||
|     this.dateAddedBefore = null | ||||
|     this.dateAddedAfter = null | ||||
|     this.dateCreatedBefore = null | ||||
|     this.dateCreatedAfter = null | ||||
|     this.updateRules() | ||||
|   resetSelected() { | ||||
|     this.reset.next() | ||||
|   } | ||||
|  | ||||
|   toggleTag(tagId: number) { | ||||
| @@ -218,4 +210,15 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this.documentTypeSelectionModel.toggle(documentTypeId) | ||||
|   } | ||||
|  | ||||
|   onTagsDropdownOpen() { | ||||
|     this.tagSelectionModel.apply() | ||||
|   } | ||||
|  | ||||
|   onCorrespondentDropdownOpen() { | ||||
|     this.correspondentSelectionModel.apply() | ||||
|   } | ||||
|  | ||||
|   onDocumentTypeDropdownOpen() { | ||||
|     this.documentTypeSelectionModel.apply() | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <form [formGroup]="saveViewConfigForm" (ngSubmit)="save()"> | ||||
|   <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title" i18n>Save current view</h4> | ||||
|     <button type="button" class="close" aria-label="Close" (click)="cancel()"> | ||||
|     <button type="button" [disabled]="!closeEnabled" class="close" aria-label="Close" (click)="cancel()"> | ||||
|       <span aria-hidden="true">×</span> | ||||
|     </button> | ||||
|   </div> | ||||
|   | ||||
| @@ -20,6 +20,8 @@ export class SaveViewConfigDialogComponent implements OnInit { | ||||
|   @Input() | ||||
|   buttonsEnabled = true | ||||
|  | ||||
|   closeEnabled = false | ||||
|  | ||||
|   _defaultName = "" | ||||
|  | ||||
|   get defaultName() { | ||||
| @@ -31,7 +33,7 @@ export class SaveViewConfigDialogComponent implements OnInit { | ||||
|     this._defaultName = value | ||||
|     this.saveViewConfigForm.patchValue({name: value}) | ||||
|   } | ||||
|    | ||||
|  | ||||
|   saveViewConfigForm = new FormGroup({ | ||||
|     name: new FormControl(''), | ||||
|     showInSideBar: new FormControl(false), | ||||
| @@ -39,6 +41,10 @@ export class SaveViewConfigDialogComponent implements OnInit { | ||||
|   }) | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     // wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM | ||||
|     setTimeout(() => { | ||||
|       this.closeEnabled = true | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   save() { | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| <form [formGroup]="objectForm" (ngSubmit)="save()"> | ||||
|   <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> | ||||
|     <button type="button" class="close" aria-label="Close" (click)="cancel()"> | ||||
|     <button type="button" [disabled]="!closeEnabled" class="close" aria-label="Close" (click)="cancel()"> | ||||
|       <span aria-hidden="true">×</span> | ||||
|     </button> | ||||
|   </div> | ||||
|   <div class="modal-body"> | ||||
|  | ||||
|     <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|     <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|     <app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match"></app-input-text> | ||||
|   | ||||
| @@ -2,11 +2,18 @@ | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button> | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="row m-0 justify-content-end"> | ||||
|   <ngb-pagination [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" aria-label="Default pagination"></ngb-pagination> | ||||
| <div class="row"> | ||||
|   <div class="col-md mb-2 mb-xl-0"> | ||||
|     <div class="form-inline d-flex align-items-center"> | ||||
|       <label class="text-muted mr-2 mb-0" i18n>Filter by:</label> | ||||
|       <input class="form-control form-control-sm flex-fill w-auto" type="text" [(ngModel)]="nameFilter" placeholder="Name" i18n-placeholder> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" aria-label="Default pagination"></ngb-pagination> | ||||
| </div> | ||||
|  | ||||
| <table class="table table-striped border shadow"> | ||||
| <table class="table table-striped border shadow-sm"> | ||||
|     <thead> | ||||
|     <tr> | ||||
|       <th scope="col" sortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> | ||||
| @@ -21,7 +28,7 @@ | ||||
|       <td scope="row">{{ correspondent.name }}</td> | ||||
|       <td scope="row">{{ getMatching(correspondent) }}</td> | ||||
|       <td scope="row">{{ correspondent.document_count }}</td> | ||||
|       <td scope="row">{{ correspondent.last_correspondence | date }}</td> | ||||
|       <td scope="row">{{ correspondent.last_correspondence | customDate }}</td> | ||||
|         <td scope="row"> | ||||
|           <div class="btn-group"> | ||||
|             <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(correspondent)"> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <form [formGroup]="objectForm" (ngSubmit)="save()"> | ||||
|     <div class="modal-header"> | ||||
|       <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> | ||||
|       <button type="button" class="close" aria-label="Close" (click)="cancel()"> | ||||
|       <button type="button" [disabled]="!closeEnabled" class="close" aria-label="Close" (click)="cancel()"> | ||||
|         <span aria-hidden="true">×</span> | ||||
|       </button> | ||||
|     </div> | ||||
|   | ||||
| @@ -2,12 +2,18 @@ | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button> | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="row m-0 justify-content-end"> | ||||
|   <ngb-pagination [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" | ||||
|   aria-label="Default pagination"></ngb-pagination> | ||||
| <div class="row"> | ||||
|   <div class="col-md mb-2 mb-xl-0"> | ||||
|     <div class="form-inline d-flex align-items-center"> | ||||
|       <label class="text-muted mr-2 mb-0" i18n>Filter by:</label> | ||||
|       <input class="form-control form-control-sm flex-fill w-auto" type="text" [(ngModel)]="nameFilter" placeholder="Name" i18n-placeholder> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" aria-label="Default pagination"></ngb-pagination> | ||||
| </div> | ||||
|  | ||||
| <table class="table table-striped border shadow"> | ||||
| <table class="table table-striped border shadow-sm"> | ||||
|   <thead> | ||||
|     <tr> | ||||
|       <th scope="col" sortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> | ||||
|   | ||||
| @@ -1,17 +1,19 @@ | ||||
| import { Directive, OnInit, QueryList, ViewChildren } from '@angular/core'; | ||||
| import { Directive, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { Subject, Subscription } from 'rxjs'; | ||||
| import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; | ||||
| import { MatchingModel, MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'; | ||||
| import { ObjectWithId } from 'src/app/data/object-with-id'; | ||||
| import { SortableDirective, SortEvent } from 'src/app/directives/sortable.directive'; | ||||
| import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'; | ||||
| import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'; | ||||
|  | ||||
| @Directive() | ||||
| export abstract class GenericListComponent<T extends ObjectWithId> implements OnInit { | ||||
| export abstract class GenericListComponent<T extends ObjectWithId> implements OnInit, OnDestroy { | ||||
|  | ||||
|   constructor( | ||||
|     private service: AbstractPaperlessService<T>, | ||||
|     private service: AbstractNameFilterService<T>, | ||||
|     private modalService: NgbModal, | ||||
|     private editDialogComponent: any, | ||||
|     private toastService: ToastService) { | ||||
| @@ -28,6 +30,10 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On | ||||
|   public sortField: string | ||||
|   public sortReverse: boolean | ||||
|  | ||||
|   private nameFilterDebounce: Subject<string> | ||||
|   private subscription: Subscription | ||||
|   private _nameFilter: string | ||||
|  | ||||
|   getMatching(o: MatchingModel) { | ||||
|     if (o.matching_algorithm == MATCH_AUTO) { | ||||
|       return $localize`Automatic` | ||||
| @@ -44,12 +50,27 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On | ||||
|     this.reloadData() | ||||
|   } | ||||
|  | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reloadData() | ||||
|  | ||||
|     this.nameFilterDebounce = new Subject<string>() | ||||
|  | ||||
|     this.subscription = this.nameFilterDebounce.pipe( | ||||
|       debounceTime(400), | ||||
|       distinctUntilChanged() | ||||
|     ).subscribe(title => { | ||||
|       this._nameFilter = title | ||||
|       this.reloadData() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     this.subscription.unsubscribe() | ||||
|   } | ||||
|  | ||||
|   reloadData() { | ||||
|     this.service.list(this.page, null, this.sortField, this.sortReverse).subscribe(c => { | ||||
|     this.service.listFiltered(this.page, null, this.sortField, this.sortReverse, this._nameFilter).subscribe(c => { | ||||
|       this.data = c.results | ||||
|       this.collectionSize = c.count | ||||
|     }); | ||||
| @@ -95,4 +116,12 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On | ||||
|     } | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get nameFilter() { | ||||
|     return this._nameFilter | ||||
|   } | ||||
|  | ||||
|   set nameFilter(nameFilter: string) { | ||||
|     this.nameFilterDebounce.next(nameFilter) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,8 +5,8 @@ | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#funnel" /> | ||||
|       </svg> <ng-container i18n>Filter</ng-container> | ||||
|        | ||||
|        | ||||
|  | ||||
|  | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownBasic1"> | ||||
|       <button *ngFor="let f of getLevels()" ngbDropdownItem (click)="setLevel(f.id)" | ||||
| @@ -16,12 +16,12 @@ | ||||
|  | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="bg-dark p-3 mb-3" infiniteScroll (scrolled)="onScroll()"> | ||||
| <div class="bg-dark p-3 mb-3 text-light text-monospace" infiniteScroll (scrolled)="onScroll()"> | ||||
|   <p | ||||
|     class="text-light text-monospace m-0 p-0 log-entry-{{log.level}}" | ||||
|     class="m-0 p-0 log-entry-{{log.level}}" | ||||
|     *ngFor="let log of logs"> | ||||
|       {{log.created | date:'short'}} | ||||
|       {{log.created | customDate:'short'}} | ||||
|       {{getLevelText(log.level)}} | ||||
|       {{log.message}} | ||||
|   </p> | ||||
| </div> | ||||
| </div> | ||||
|   | ||||
| @@ -12,6 +12,56 @@ | ||||
|  | ||||
|         <h4 i18n>Appearance</h4> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Display language</span> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|  | ||||
|             <select class="form-control" formControlName="displayLanguage"> | ||||
|               <option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale != 'en-US'"> - {{lang.englishName}}</span></option> | ||||
|             </select> | ||||
|  | ||||
|             <small class="form-text text-muted" i18n>You need to reload the page after applying a new language.</small> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Date display</span> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|  | ||||
|             <select class="form-control" formControlName="dateLocale"> | ||||
|               <option *ngFor="let lang of dateLocaleOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> - {{today | date:'shortDate':null:lang.code}}</span></option> | ||||
|             </select> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Date format</span> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|  | ||||
|             <div class="custom-control custom-radio"> | ||||
|               <input type="radio" id="dateFormatShort" name="dateFormat" class="custom-control-input" formControlName="dateFormat" value="shortDate"> | ||||
|               <label class="custom-control-label" for="dateFormatShort" i18n>Short: {{today | customDate:'shortDate':null:computedDateLocale}}</label> | ||||
|             </div> | ||||
|             <div class="custom-control custom-radio"> | ||||
|               <input type="radio" id="dateFormatMedium" name="dateFormat" class="custom-control-input" formControlName="dateFormat" value="mediumDate"> | ||||
|               <label class="custom-control-label" for="dateFormatMedium" i18n>Medium: {{today | customDate:'mediumDate':null:computedDateLocale}}</label> | ||||
|             </div> | ||||
|             <div class="custom-control custom-radio"> | ||||
|               <input type="radio" id="dateFormatLong" name="dateFormat" class="custom-control-input" formControlName="dateFormat" value="longDate"> | ||||
|               <label class="custom-control-label" for="dateFormatLong" i18n>Long: {{today | customDate:'longDate':null:computedDateLocale}}</label> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Items per page</span> | ||||
| @@ -28,16 +78,24 @@ | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Document editor</span> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|  | ||||
|             <app-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></app-input-check> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="form-row form-group"> | ||||
|           <div class="col-md-3 col-form-label"> | ||||
|             <span i18n>Dark mode</span> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|             <app-input-check i18n-title title="Use system settings" formControlName="darkModeUseSystem" (change)="toggleDarkModeSetting()"></app-input-check> | ||||
|             <div class="custom-control custom-switch" *ngIf="!settingsForm.value.darkModeUseSystem"> | ||||
|               <input type="checkbox" class="custom-control-input" id="darkModeEnabled" formControlName="darkModeEnabled" [checked]="settingsForm.value.darkModeEnabled"> | ||||
|               <label class="custom-control-label" for="darkModeEnabled">Enabled</label> | ||||
|             </div> | ||||
|             <app-input-check i18n-title title="Use system settings" formControlName="darkModeUseSystem"></app-input-check> | ||||
|             <app-input-check [hidden]="settingsForm.value.darkModeUseSystem" i18n-title title="Enable dark mode" formControlName="darkModeEnabled"></app-input-check> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
| @@ -92,5 +150,5 @@ | ||||
|  | ||||
|   <div [ngbNavOutlet]="nav" class="border-left border-right border-bottom p-3 mb-3 shadow"></div> | ||||
|  | ||||
|   <button type="submit" class="btn btn-primary">Save</button> | ||||
|   <button type="submit" class="btn btn-primary" i18n>Save</button> | ||||
| </form> | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { Component, OnInit, Renderer2  } from '@angular/core'; | ||||
| import { Component, Inject, LOCALE_ID, OnInit, Renderer2  } from '@angular/core'; | ||||
| import { FormControl, FormGroup } from '@angular/forms'; | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service'; | ||||
| import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
| import { LanguageOption, SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; | ||||
| import { ToastService } from 'src/app/services/toast.service'; | ||||
|  | ||||
| @Component({ | ||||
| @@ -21,16 +21,25 @@ export class SettingsComponent implements OnInit { | ||||
|     'documentListItemPerPage': new FormControl(this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)), | ||||
|     'darkModeUseSystem': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM)), | ||||
|     'darkModeEnabled': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED)), | ||||
|     'savedViews': this.savedViewGroup | ||||
|     'useNativePdfViewer': new FormControl(this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)), | ||||
|     'savedViews': this.savedViewGroup, | ||||
|     'displayLanguage': new FormControl(this.settings.getLanguage()), | ||||
|     'dateLocale': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_LOCALE)), | ||||
|     'dateFormat': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_FORMAT)), | ||||
|   }) | ||||
|  | ||||
|   savedViews: PaperlessSavedView[] | ||||
|  | ||||
|   get computedDateLocale(): string { | ||||
|     return this.settingsForm.value.dateLocale || this.settingsForm.value.displayLanguage | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public savedViewService: SavedViewService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService | ||||
|     private settings: SettingsService, | ||||
|     @Inject(LOCALE_ID) public currentLocale: string | ||||
|   ) { } | ||||
|  | ||||
|   ngOnInit() { | ||||
| @@ -55,25 +64,33 @@ export class SettingsComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   toggleDarkModeSetting() { | ||||
|     if (this.settingsForm.value.darkModeUseSystem) { | ||||
|       (this.settingsForm.controls.darkModeEnabled as FormControl).disable() | ||||
|     } else { | ||||
|       (this.settingsForm.controls.darkModeEnabled as FormControl).enable() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private saveLocalSettings() { | ||||
|     this.settings.set(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE, this.settingsForm.value.bulkEditApplyOnClose) | ||||
|     this.settings.set(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS, this.settingsForm.value.bulkEditConfirmationDialogs) | ||||
|     this.settings.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage) | ||||
|     this.settings.set(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, this.settingsForm.value.darkModeUseSystem) | ||||
|     this.settings.set(SETTINGS_KEYS.DARK_MODE_ENABLED, (this.settingsForm.value.darkModeEnabled == true).toString()) | ||||
|     this.settings.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, this.settingsForm.value.useNativePdfViewer) | ||||
|     this.settings.set(SETTINGS_KEYS.DATE_LOCALE, this.settingsForm.value.dateLocale) | ||||
|     this.settings.set(SETTINGS_KEYS.DATE_FORMAT, this.settingsForm.value.dateFormat) | ||||
|     this.settings.setLanguage(this.settingsForm.value.displayLanguage) | ||||
|     this.documentListViewService.updatePageSize() | ||||
|     this.settings.updateDarkModeSettings() | ||||
|     this.toastService.showInfo($localize`Settings saved successfully.`) | ||||
|   } | ||||
|  | ||||
|   get displayLanguageOptions(): LanguageOption[] { | ||||
|     return [{code: "", name: $localize`Use system language`}].concat(this.settings.getLanguageOptions()) | ||||
|   } | ||||
|  | ||||
|   get dateLocaleOptions(): LanguageOption[] { | ||||
|     return [{code: "", name: $localize`Use date format of display language`}].concat(this.settings.getLanguageOptions()) | ||||
|   } | ||||
|  | ||||
|   get today() { | ||||
|     return new Date() | ||||
|   } | ||||
|  | ||||
|   saveSettings() { | ||||
|     let x = [] | ||||
|     for (let id in this.savedViewGroup.value) { | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
|   <form [formGroup]="objectForm" (ngSubmit)="save()"> | ||||
|     <div class="modal-header"> | ||||
|       <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> | ||||
|       <button type="button" class="close" aria-label="Close" (click)="cancel()"> | ||||
|       <button type="button" [disabled]="!closeEnabled" class="close" aria-label="Close" (click)="cancel()"> | ||||
|         <span aria-hidden="true">×</span> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="modal-body"> | ||||
|       <app-input-text title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|       <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||
|  | ||||
|  | ||||
|       <div class="form-group paperless-input-select"> | ||||
|   | ||||
| @@ -2,9 +2,15 @@ | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button> | ||||
| </app-page-header> | ||||
|  | ||||
| <div class="row m-0 justify-content-end"> | ||||
|   <ngb-pagination [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" | ||||
|     aria-label="Default pagination"></ngb-pagination> | ||||
| <div class="row"> | ||||
|   <div class="col-md mb-2 mb-xl-0"> | ||||
|     <div class="form-inline d-flex align-items-center"> | ||||
|       <label class="text-muted mr-2 mb-0" i18n>Filter by:</label> | ||||
|       <input class="form-control form-control-sm flex-fill w-auto" type="text" [(ngModel)]="nameFilter" placeholder="Name" i18n-placeholder> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" aria-label="Default pagination"></ngb-pagination> | ||||
| </div> | ||||
|  | ||||
| <table class="table table-striped border shadow-sm"> | ||||
|   | ||||
| @@ -16,11 +16,13 @@ | ||||
|  | ||||
| <div *ngIf="!errorMessage" [class.result-content-searching]="searching" infiniteScroll (scrolled)="onScroll()"> | ||||
|     <p i18n>{resultCount, plural, =0 {No results} =1 {One result} other {{{resultCount}} results}}</p> | ||||
|     <app-document-card-large *ngFor="let result of results" | ||||
|         [document]="result.document" | ||||
|         [details]="result.highlights" | ||||
|         [searchScore]="result.score / maxScore" | ||||
|         [moreLikeThis]="true"> | ||||
|     <ng-container *ngFor="let result of results"> | ||||
|         <app-document-card-large *ngIf="result.document" | ||||
|             [document]="result.document" | ||||
|             [details]="result.highlights" | ||||
|             [searchScore]="result.score / maxScore" | ||||
|             [moreLikeThis]="true"> | ||||
|         </app-document-card-large> | ||||
|     </ng-container> | ||||
|  | ||||
| </app-document-card-large> | ||||
| </div> | ||||
|   | ||||
| @@ -22,37 +22,36 @@ export const FILTER_ASN_ISNULL = 18 | ||||
|  | ||||
| export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|  | ||||
|   {id: FILTER_TITLE, name: "Title contains", filtervar: "title__icontains", datatype: "string", multi: false, default: ""}, | ||||
|   {id: FILTER_CONTENT, name: "Content contains", filtervar: "content__icontains", datatype: "string", multi: false, default: ""}, | ||||
|   {id: FILTER_TITLE, filtervar: "title__icontains", datatype: "string", multi: false, default: ""}, | ||||
|   {id: FILTER_CONTENT, filtervar: "content__icontains", datatype: "string", multi: false, default: ""}, | ||||
|  | ||||
|   {id: FILTER_ASN, name: "ASN is", filtervar: "archive_serial_number", datatype: "number", multi: false}, | ||||
|   {id: FILTER_ASN, filtervar: "archive_serial_number", datatype: "number", multi: false}, | ||||
|  | ||||
|   {id: FILTER_CORRESPONDENT, name: "Correspondent is", filtervar: "correspondent__id", isnull_filtervar: "correspondent__isnull", datatype: "correspondent", multi: false}, | ||||
|   {id: FILTER_DOCUMENT_TYPE, name: "Document type is", filtervar: "document_type__id", isnull_filtervar: "document_type__isnull", datatype: "document_type", multi: false}, | ||||
|   {id: FILTER_CORRESPONDENT, filtervar: "correspondent__id", isnull_filtervar: "correspondent__isnull", datatype: "correspondent", multi: false}, | ||||
|   {id: FILTER_DOCUMENT_TYPE, filtervar: "document_type__id", isnull_filtervar: "document_type__isnull", datatype: "document_type", multi: false}, | ||||
|  | ||||
|   {id: FILTER_IS_IN_INBOX, name: "Is in Inbox", filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true}, | ||||
|   {id: FILTER_HAS_TAG, name: "Has tag", filtervar: "tags__id__all", datatype: "tag", multi: true}, | ||||
|   {id: FILTER_DOES_NOT_HAVE_TAG, name: "Does not have tag", filtervar: "tags__id__none", datatype: "tag", multi: true}, | ||||
|   {id: FILTER_HAS_ANY_TAG, name: "Has any tag", filtervar: "is_tagged", datatype: "boolean", multi: false, default: true}, | ||||
|   {id: FILTER_IS_IN_INBOX, filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true}, | ||||
|   {id: FILTER_HAS_TAG, filtervar: "tags__id__all", datatype: "tag", multi: true}, | ||||
|   {id: FILTER_DOES_NOT_HAVE_TAG, filtervar: "tags__id__none", datatype: "tag", multi: true}, | ||||
|   {id: FILTER_HAS_ANY_TAG, filtervar: "is_tagged", datatype: "boolean", multi: false, default: true}, | ||||
|  | ||||
|   {id: FILTER_CREATED_BEFORE, name: "Created before", filtervar: "created__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_CREATED_AFTER, name: "Created after", filtervar: "created__date__gt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_CREATED_BEFORE, filtervar: "created__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_CREATED_AFTER, filtervar: "created__date__gt", datatype: "date", multi: false}, | ||||
|  | ||||
|   {id: FILTER_CREATED_YEAR, name: "Year created is", filtervar: "created__year", datatype: "number", multi: false}, | ||||
|   {id: FILTER_CREATED_MONTH, name: "Month created is", filtervar: "created__month", datatype: "number", multi: false}, | ||||
|   {id: FILTER_CREATED_DAY, name: "Day created is", filtervar: "created__day", datatype: "number", multi: false}, | ||||
|   {id: FILTER_CREATED_YEAR, filtervar: "created__year", datatype: "number", multi: false}, | ||||
|   {id: FILTER_CREATED_MONTH, filtervar: "created__month", datatype: "number", multi: false}, | ||||
|   {id: FILTER_CREATED_DAY, filtervar: "created__day", datatype: "number", multi: false}, | ||||
|  | ||||
|   {id: FILTER_ADDED_BEFORE, name: "Added before", filtervar: "added__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_ADDED_AFTER, name: "Added after", filtervar: "added__date__gt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_ADDED_BEFORE, filtervar: "added__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_ADDED_AFTER, filtervar: "added__date__gt", datatype: "date", multi: false}, | ||||
|  | ||||
|   {id: FILTER_MODIFIED_BEFORE, name: "Modified before", filtervar: "modified__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_MODIFIED_AFTER, name: "Modified after", filtervar: "modified__date__gt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_ASN_ISNULL, name: "ASN is null", filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false} | ||||
|   {id: FILTER_MODIFIED_BEFORE, filtervar: "modified__date__lt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_MODIFIED_AFTER, filtervar: "modified__date__gt", datatype: "date", multi: false}, | ||||
|   {id: FILTER_ASN_ISNULL, filtervar: "archive_serial_number__isnull", datatype: "boolean", multi: false} | ||||
| ] | ||||
|  | ||||
| export interface FilterRuleType { | ||||
|   id: number | ||||
|   name: string | ||||
|   filtervar: string | ||||
|   isnull_filtervar?: string | ||||
|   datatype: string //number, string, boolean, date | ||||
|   | ||||
							
								
								
									
										8
									
								
								src-ui/src/app/pipes/custom-date.pipe.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src-ui/src/app/pipes/custom-date.pipe.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| import { CustomDatePipe } from './custom-date.pipe'; | ||||
|  | ||||
| describe('CustomDatePipe', () => { | ||||
|   it('create an instance', () => { | ||||
|     const pipe = new CustomDatePipe(); | ||||
|     expect(pipe).toBeTruthy(); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										19
									
								
								src-ui/src/app/pipes/custom-date.pipe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src-ui/src/app/pipes/custom-date.pipe.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { DatePipe } from '@angular/common'; | ||||
| import { Inject, LOCALE_ID, Pipe, PipeTransform } from '@angular/core'; | ||||
| import { SettingsService, SETTINGS_KEYS } from '../services/settings.service'; | ||||
|  | ||||
| @Pipe({ | ||||
|   name: 'customDate' | ||||
| }) | ||||
| export class CustomDatePipe extends DatePipe implements PipeTransform { | ||||
|  | ||||
|   constructor(@Inject(LOCALE_ID) locale: string, private settings: SettingsService) { | ||||
|     super(settings.get(SETTINGS_KEYS.DATE_LOCALE) || locale) | ||||
|  | ||||
|   } | ||||
|  | ||||
|   transform(value: any, format?: string, timezone?: string, locale?: string): string | null { | ||||
|     return super.transform(value, format || this.settings.get(SETTINGS_KEYS.DATE_FORMAT), timezone, locale) | ||||
|   } | ||||
|  | ||||
| } | ||||
							
								
								
									
										8
									
								
								src-ui/src/app/pipes/safe.pipe.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src-ui/src/app/pipes/safe.pipe.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| import { SafePipe } from './safe.pipe'; | ||||
|  | ||||
| describe('SafePipe', () => { | ||||
|   it('create an instance', () => { | ||||
|     const pipe = new SafePipe(); | ||||
|     expect(pipe).toBeTruthy(); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										19
									
								
								src-ui/src/app/pipes/safe.pipe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src-ui/src/app/pipes/safe.pipe.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { Pipe, PipeTransform } from '@angular/core'; | ||||
| import { DomSanitizer } from '@angular/platform-browser'; | ||||
|  | ||||
| @Pipe({ | ||||
|   name: 'safe' | ||||
| }) | ||||
| export class SafePipe implements PipeTransform { | ||||
|  | ||||
|   constructor(private sanitizer: DomSanitizer) { } | ||||
|  | ||||
|   transform(url) { | ||||
|     if (url == null) { | ||||
|       return this.sanitizer.bypassSecurityTrustResourceUrl("") | ||||
|     } else { | ||||
|       return this.sanitizer.bypassSecurityTrustResourceUrl(url); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| }  | ||||
| @@ -27,6 +27,8 @@ export class DocumentListViewService { | ||||
|   currentPage = 1 | ||||
|   currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) | ||||
|   collectionSize: number | ||||
|   rangeSelectionAnchorIndex: number | ||||
|   lastRangeSelectionToIndex: number | ||||
|  | ||||
|   /** | ||||
|    * This is the current config for the document list. The service will always remember the last settings used for the document list. | ||||
| @@ -108,6 +110,7 @@ export class DocumentListViewService { | ||||
|           if (onFinish) { | ||||
|             onFinish() | ||||
|           } | ||||
|           this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null | ||||
|           this.isReloading = false | ||||
|         }, | ||||
|         error => { | ||||
| @@ -218,6 +221,7 @@ export class DocumentListViewService { | ||||
|  | ||||
|   selectNone() { | ||||
|     this.selected.clear() | ||||
|     this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null | ||||
|   } | ||||
|  | ||||
|   reduceSelectionToFilter() { | ||||
| @@ -249,14 +253,39 @@ export class DocumentListViewService { | ||||
|     return this.selected.has(d.id) | ||||
|   } | ||||
|  | ||||
|   setSelected(d: PaperlessDocument, value: boolean) { | ||||
|     if (value) { | ||||
|       this.selected.add(d.id) | ||||
|     } else if (!value) { | ||||
|       this.selected.delete(d.id) | ||||
|   toggleSelected(d: PaperlessDocument): void { | ||||
|     if (this.selected.has(d.id)) this.selected.delete(d.id) | ||||
|     else this.selected.add(d.id) | ||||
|     this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) | ||||
|     this.lastRangeSelectionToIndex = null | ||||
|   } | ||||
|  | ||||
|   selectRangeTo(d: PaperlessDocument) { | ||||
|     if (this.rangeSelectionAnchorIndex !== null) { | ||||
|       const documentToIndex = this.documentIndexInCurrentView(d.id) | ||||
|       const fromIndex = Math.min(this.rangeSelectionAnchorIndex, documentToIndex) | ||||
|       const toIndex = Math.max(this.rangeSelectionAnchorIndex, documentToIndex) | ||||
|  | ||||
|       if (this.lastRangeSelectionToIndex !== null) { | ||||
|         // revert the old selection | ||||
|         this.documents.slice(Math.min(this.rangeSelectionAnchorIndex, this.lastRangeSelectionToIndex), Math.max(this.rangeSelectionAnchorIndex, this.lastRangeSelectionToIndex) + 1).forEach(d => { | ||||
|           this.selected.delete(d.id) | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       this.documents.slice(fromIndex, toIndex + 1).forEach(d => { | ||||
|         this.selected.add(d.id) | ||||
|       }) | ||||
|       this.lastRangeSelectionToIndex = documentToIndex | ||||
|     } else { // e.g. shift key but was first click | ||||
|       this.toggleSelected(d) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   documentIndexInCurrentView(documentID: number): number { | ||||
|     return this.documents.map(d => d.id).indexOf(documentID) | ||||
|   } | ||||
|  | ||||
|   constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router) { | ||||
|     let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) | ||||
|     if (documentListViewConfigJson) { | ||||
|   | ||||
							
								
								
									
										14
									
								
								src-ui/src/app/services/rest/abstract-name-filter-service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src-ui/src/app/services/rest/abstract-name-filter-service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
|  | ||||
| export abstract class AbstractNameFilterService<T extends ObjectWithId> extends AbstractPaperlessService<T> { | ||||
|  | ||||
|   listFiltered(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, nameFilter?: string) { | ||||
|     let params = {} | ||||
|     if (nameFilter) { | ||||
|       params = {'name__icontains': nameFilter} | ||||
|     } | ||||
|     return this.list(page, pageSize, sortField, sortReverse, params) | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service'; | ||||
| import { AbstractNameFilterService } from './abstract-name-filter-service'; | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root' | ||||
| }) | ||||
| export class CorrespondentService extends AbstractPaperlessService<PaperlessCorrespondent> { | ||||
| export class CorrespondentService extends AbstractNameFilterService<PaperlessCorrespondent> { | ||||
|  | ||||
|   constructor(http: HttpClient) { | ||||
|     super(http, 'correspondents') | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service'; | ||||
| import { AbstractNameFilterService } from './abstract-name-filter-service'; | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root' | ||||
| }) | ||||
| export class DocumentTypeService extends AbstractPaperlessService<PaperlessDocumentType> { | ||||
| export class DocumentTypeService extends AbstractNameFilterService<PaperlessDocumentType> { | ||||
|  | ||||
|   constructor(http: HttpClient) { | ||||
|     super(http, 'document_types') | ||||
|   | ||||
| @@ -28,7 +28,11 @@ export class SearchService { | ||||
|     } | ||||
|     return this.http.get<SearchResult>(`${environment.apiBaseUrl}search/`, {params: httpParams}).pipe( | ||||
|       map(result => { | ||||
|         result.results.forEach(hit => this.documentService.addObservablesToDocument(hit.document)) | ||||
|         result.results.forEach(hit => { | ||||
|           if (hit.document) { | ||||
|             this.documentService.addObservablesToDocument(hit.document) | ||||
|           } | ||||
|         }) | ||||
|         return result | ||||
|       }) | ||||
|     ) | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import { HttpClient } from '@angular/common/http'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag'; | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service'; | ||||
| import { AbstractNameFilterService } from './abstract-name-filter-service'; | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root' | ||||
| }) | ||||
| export class TagService extends AbstractPaperlessService<PaperlessTag> { | ||||
| export class TagService extends AbstractNameFilterService<PaperlessTag> { | ||||
|  | ||||
|   constructor(http: HttpClient) { | ||||
|     super(http, 'tags') | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import { DOCUMENT } from '@angular/common'; | ||||
| import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; | ||||
| import { Meta } from '@angular/platform-browser'; | ||||
| import { CookieService } from 'ngx-cookie-service'; | ||||
|  | ||||
| export interface PaperlessSettings { | ||||
|   key: string | ||||
| @@ -7,12 +9,21 @@ export interface PaperlessSettings { | ||||
|   default: any | ||||
| } | ||||
|  | ||||
| export interface LanguageOption { | ||||
|   code: string, | ||||
|   name: string, | ||||
|   englishName?: string | ||||
| } | ||||
|  | ||||
| export const SETTINGS_KEYS = { | ||||
|   BULK_EDIT_CONFIRMATION_DIALOGS: 'general-settings:bulk-edit:confirmation-dialogs', | ||||
|   BULK_EDIT_APPLY_ON_CLOSE: 'general-settings:bulk-edit:apply-on-close', | ||||
|   DOCUMENT_LIST_SIZE: 'general-settings:documentListSize', | ||||
|   DARK_MODE_USE_SYSTEM: 'general-settings:dark-mode:use-system', | ||||
|   DARK_MODE_ENABLED: 'general-settings:dark-mode:enabled' | ||||
|   DARK_MODE_ENABLED: 'general-settings:dark-mode:enabled', | ||||
|   USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer', | ||||
|   DATE_LOCALE: 'general-settings:date-display:date-locale', | ||||
|   DATE_FORMAT: 'general-settings:date-display:date-format' | ||||
| } | ||||
|  | ||||
| const SETTINGS: PaperlessSettings[] = [ | ||||
| @@ -20,7 +31,10 @@ const SETTINGS: PaperlessSettings[] = [ | ||||
|   {key: SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE, type: "boolean", default: false}, | ||||
|   {key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE, type: "number", default: 50}, | ||||
|   {key: SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, type: "boolean", default: true}, | ||||
|   {key: SETTINGS_KEYS.DARK_MODE_ENABLED, type: "boolean", default: false} | ||||
|   {key: SETTINGS_KEYS.DARK_MODE_ENABLED, type: "boolean", default: false}, | ||||
|   {key: SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, type: "boolean", default: false}, | ||||
|   {key: SETTINGS_KEYS.DATE_LOCALE, type: "string", default: ""}, | ||||
|   {key: SETTINGS_KEYS.DATE_FORMAT, type: "string", default: "mediumDate"} | ||||
| ] | ||||
|  | ||||
| @Injectable({ | ||||
| @@ -32,7 +46,9 @@ export class SettingsService { | ||||
|  | ||||
|   constructor( | ||||
|     private rendererFactory: RendererFactory2, | ||||
|     @Inject(DOCUMENT) private document | ||||
|     @Inject(DOCUMENT) private document, | ||||
|     private cookieService: CookieService, | ||||
|     private meta: Meta | ||||
|   ) { | ||||
|     this.renderer = rendererFactory.createRenderer(null, null); | ||||
|  | ||||
| @@ -53,6 +69,35 @@ export class SettingsService { | ||||
|  | ||||
|   } | ||||
|  | ||||
|   getLanguageOptions(): LanguageOption[] { | ||||
|     return [ | ||||
|       {code: "en-US", name: $localize`English (US)`, englishName: "English (US)"}, | ||||
|       {code: "de", name: $localize`German`, englishName: "German"}, | ||||
|       {code: "nl", name: $localize`Dutch`, englishName: "Dutch"}, | ||||
|       {code: "fr", name: $localize`French`, englishName: "French"} | ||||
|     ] | ||||
|   } | ||||
|  | ||||
|   private getLanguageCookieName() { | ||||
|     let prefix = "" | ||||
|     if (this.meta.getTag('name=cookie_prefix')) { | ||||
|       prefix = this.meta.getTag('name=cookie_prefix').content | ||||
|     } | ||||
|     return `${prefix || ''}django_language` | ||||
|   } | ||||
|  | ||||
|   getLanguage(): string { | ||||
|     return this.cookieService.get(this.getLanguageCookieName()) | ||||
|   } | ||||
|  | ||||
|   setLanguage(language: string) { | ||||
|     if (language) { | ||||
|       this.cookieService.set(this.getLanguageCookieName(), language) | ||||
|     } else { | ||||
|       this.cookieService.delete(this.getLanguageCookieName()) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   get(key: string): any { | ||||
|     let setting = SETTINGS.find(s => s.key == key) | ||||
|  | ||||
|   | ||||
| @@ -2,5 +2,5 @@ export const environment = { | ||||
|   production: true, | ||||
|   apiBaseUrl: "/api/", | ||||
|   appTitle: "Paperless-ng", | ||||
|   version: "0.9.11" | ||||
|   version: "1.0.0" | ||||
| }; | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -84,7 +84,7 @@ $border-color-dark-mode: #47494f; | ||||
|   } | ||||
|  | ||||
|   .dropdown-menu { | ||||
|     background-color: $bg-dark-mode; | ||||
|     background-color: $bg-light-dark-mode; | ||||
|  | ||||
|     .dropdown-divider { | ||||
|       border-color: $border-color-dark-mode; | ||||
| @@ -174,6 +174,17 @@ $border-color-dark-mode: #47494f; | ||||
|     color: $text-color-dark-mode; | ||||
|     border-color: $border-color-dark-mode; | ||||
|  | ||||
|     .des, | ||||
|     .asc { | ||||
|       background-color: transparent !important; | ||||
|       color: $text-color-dark-mode; | ||||
|       border-color: $border-color-dark-mode; | ||||
|  | ||||
|       &::after { | ||||
|         filter: invert(0.8); /* arrow is a black inline png bkgd image (!) so use filter */ | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     tr:hover { | ||||
|       background-color: $bg-light-dark-mode; | ||||
|       color: $text-color-dark-mode-accent; | ||||
| @@ -250,13 +261,18 @@ $border-color-dark-mode: #47494f; | ||||
|     background-color: $bg-dark-mode !important; | ||||
|   } | ||||
|  | ||||
|   .form-control, | ||||
|   .form-control:not(.is-invalid):not(.btn), | ||||
|   input:not(.is-invalid), | ||||
|   textarea:not(.is-invalid) { | ||||
|     border-color: $border-color-dark-mode; /* we dont want to override controls that get highlighting for errors */ | ||||
|   } | ||||
|  | ||||
|   .form-control:not(.btn), | ||||
|   input, | ||||
|   select, | ||||
|   textarea { | ||||
|     background-color: $bg-dark-mode; | ||||
|     color: $text-color-dark-mode; | ||||
|     border-color: $border-color-dark-mode; | ||||
|  | ||||
|     &::placeholder { | ||||
|       color: $text-color-dark-mode; | ||||
| @@ -281,7 +297,8 @@ $border-color-dark-mode: #47494f; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .ng-dropdown-panel .ng-dropdown-panel-items .ng-option:hover { | ||||
|   .ng-dropdown-panel .ng-dropdown-panel-items .ng-option:hover, | ||||
|   .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked { | ||||
|     background-color: $bg-light-dark-mode; | ||||
|   } | ||||
|  | ||||
| @@ -325,6 +342,16 @@ $border-color-dark-mode: #47494f; | ||||
|   .progress { | ||||
|     background-color: $border-color-dark-mode; | ||||
|   } | ||||
|  | ||||
|   .alert-danger { | ||||
|     color: $text-color-dark-mode-accent; | ||||
|     background-color: darken($danger-dark-mode, 20%); | ||||
|     border-color: darken($danger-dark-mode, 20%); | ||||
|   } | ||||
|  | ||||
|   .bg-dark { | ||||
|     background-color: $bg-light-dark-mode !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| body.color-scheme-dark { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 jonaswinkler
					jonaswinkler