implementing duplicates frontend
This commit is contained in:
parent
849a081cba
commit
41dc64f805
@ -18,6 +18,7 @@ import {DiskMangerWorker} from '../threading/DiskMangerWorker';
|
|||||||
import {Logger} from '../../Logger';
|
import {Logger} from '../../Logger';
|
||||||
import {FaceRegionEntry} from './enitites/FaceRegionEntry';
|
import {FaceRegionEntry} from './enitites/FaceRegionEntry';
|
||||||
import {ObjectManagerRepository} from '../ObjectManagerRepository';
|
import {ObjectManagerRepository} from '../ObjectManagerRepository';
|
||||||
|
import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO';
|
||||||
|
|
||||||
const LOG_TAG = '[GalleryManager]';
|
const LOG_TAG = '[GalleryManager]';
|
||||||
|
|
||||||
@ -173,7 +174,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager {
|
|||||||
const connection = await SQLConnection.getConnection();
|
const connection = await SQLConnection.getConnection();
|
||||||
const mediaRepository = connection.getRepository(MediaEntity);
|
const mediaRepository = connection.getRepository(MediaEntity);
|
||||||
|
|
||||||
const duplicates = await mediaRepository.createQueryBuilder('media')
|
let duplicates = await mediaRepository.createQueryBuilder('media')
|
||||||
.innerJoin(query => query.from(MediaEntity, 'innerMedia')
|
.innerJoin(query => query.from(MediaEntity, 'innerMedia')
|
||||||
.select(['innerMedia.name as name', 'innerMedia.metadata.fileSize as fileSize', 'count(*)'])
|
.select(['innerMedia.name as name', 'innerMedia.metadata.fileSize as fileSize', 'count(*)'])
|
||||||
.groupBy('innerMedia.name, innerMedia.metadata.fileSize')
|
.groupBy('innerMedia.name, innerMedia.metadata.fileSize')
|
||||||
@ -181,7 +182,51 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager {
|
|||||||
'innerMedia',
|
'innerMedia',
|
||||||
'media.name=innerMedia.name AND media.metadata.fileSize = innerMedia.fileSize')
|
'media.name=innerMedia.name AND media.metadata.fileSize = innerMedia.fileSize')
|
||||||
.innerJoinAndSelect('media.directory', 'directory').getMany();
|
.innerJoinAndSelect('media.directory', 'directory').getMany();
|
||||||
return duplicates;
|
|
||||||
|
const duplicateParis: DuplicatesDTO[] = [];
|
||||||
|
let i = duplicates.length - 1;
|
||||||
|
while (i >= 0) {
|
||||||
|
const list = [duplicates[i]];
|
||||||
|
let j = i - 1;
|
||||||
|
while (j >= 0 && duplicates[i].name === duplicates[j].name && duplicates[i].metadata.fileSize === duplicates[j].metadata.fileSize) {
|
||||||
|
list.push(duplicates[j]);
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
duplicateParis.push({media: list});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
duplicates = await mediaRepository.createQueryBuilder('media')
|
||||||
|
.innerJoin(query => query.from(MediaEntity, 'innerMedia')
|
||||||
|
.select(['innerMedia.metadata.creationDate as creationDate', 'innerMedia.metadata.fileSize as fileSize', 'count(*)'])
|
||||||
|
.groupBy('innerMedia.name, innerMedia.metadata.fileSize')
|
||||||
|
.having('count(*)>1'),
|
||||||
|
'innerMedia',
|
||||||
|
'media.metadata.creationDate=innerMedia.creationDate AND media.metadata.fileSize = innerMedia.fileSize')
|
||||||
|
.innerJoinAndSelect('media.directory', 'directory').getMany();
|
||||||
|
|
||||||
|
i = duplicates.length - 1;
|
||||||
|
while (i >= 0) {
|
||||||
|
const list = [duplicates[i]];
|
||||||
|
let j = i - 1;
|
||||||
|
while (j >= 0 && duplicates[i].metadata.creationDate === duplicates[j].metadata.creationDate && duplicates[i].metadata.fileSize === duplicates[j].metadata.fileSize) {
|
||||||
|
list.push(duplicates[j]);
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
if (list.filter(paired =>
|
||||||
|
!!duplicateParis.find(dp =>
|
||||||
|
!!dp.media.find(m =>
|
||||||
|
m.id === paired.id))).length === list.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateParis.push({media: list});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return duplicateParis;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {DirectoryDTO} from '../../../common/entities/DirectoryDTO';
|
import {DirectoryDTO} from '../../../common/entities/DirectoryDTO';
|
||||||
import {IGalleryManager} from '../interfaces/IGalleryManager';
|
import {IGalleryManager} from '../interfaces/IGalleryManager';
|
||||||
import {MediaEntity} from './enitites/MediaEntity';
|
import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO';
|
||||||
|
|
||||||
export interface ISQLGalleryManager extends IGalleryManager {
|
export interface ISQLGalleryManager extends IGalleryManager {
|
||||||
listDirectory(relativeDirectoryName: string,
|
listDirectory(relativeDirectoryName: string,
|
||||||
@ -15,5 +15,5 @@ export interface ISQLGalleryManager extends IGalleryManager {
|
|||||||
|
|
||||||
countMediaSize(): Promise<number>;
|
countMediaSize(): Promise<number>;
|
||||||
|
|
||||||
getPossibleDuplicates(): Promise<MediaEntity[]>;
|
getPossibleDuplicates(): Promise<DuplicatesDTO[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export class GalleryRouter {
|
|||||||
public static route(app: Express) {
|
public static route(app: Express) {
|
||||||
|
|
||||||
this.addGetImageIcon(app);
|
this.addGetImageIcon(app);
|
||||||
|
this.addGetVideoIcon(app);
|
||||||
this.addGetImageThumbnail(app);
|
this.addGetImageThumbnail(app);
|
||||||
this.addGetVideoThumbnail(app);
|
this.addGetVideoThumbnail(app);
|
||||||
this.addGetImage(app);
|
this.addGetImage(app);
|
||||||
@ -92,6 +93,17 @@ export class GalleryRouter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static addGetVideoIcon(app: Express) {
|
||||||
|
app.get('/api/gallery/content/:mediaPath(*\.(mp4|ogg|ogv|webm))/icon',
|
||||||
|
AuthenticationMWs.authenticate,
|
||||||
|
// TODO: authorize path
|
||||||
|
GalleryMWs.loadFile,
|
||||||
|
ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Video),
|
||||||
|
RenderingMWs.renderFile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static addGetImageIcon(app: Express) {
|
private static addGetImageIcon(app: Express) {
|
||||||
app.get('/api/gallery/content/:mediaPath(*\.(jpg|jpeg|jpe|webp|png|gif|svg))/icon',
|
app.get('/api/gallery/content/:mediaPath(*\.(jpg|jpeg|jpe|webp|png|gif|svg))/icon',
|
||||||
AuthenticationMWs.authenticate,
|
AuthenticationMWs.authenticate,
|
||||||
|
|||||||
@ -79,7 +79,7 @@ export class PublicRouter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/search*'],
|
app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/search*'],
|
||||||
AuthenticationMWs.tryAuthenticate,
|
AuthenticationMWs.tryAuthenticate,
|
||||||
setLocale,
|
setLocale,
|
||||||
renderIndex
|
renderIndex
|
||||||
|
|||||||
5
common/entities/DuplicatesDTO.ts
Normal file
5
common/entities/DuplicatesDTO.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import {MediaDTO} from './MediaDTO';
|
||||||
|
|
||||||
|
export interface DuplicatesDTO {
|
||||||
|
media: MediaDTO[];
|
||||||
|
}
|
||||||
@ -1,10 +1,4 @@
|
|||||||
import {
|
import {Injectable, LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT} from '@angular/core';
|
||||||
Injectable,
|
|
||||||
LOCALE_ID,
|
|
||||||
NgModule,
|
|
||||||
TRANSLATIONS,
|
|
||||||
TRANSLATIONS_FORMAT
|
|
||||||
} from '@angular/core';
|
|
||||||
import {BrowserModule, HAMMER_GESTURE_CONFIG, HammerGestureConfig} from '@angular/platform-browser';
|
import {BrowserModule, HAMMER_GESTURE_CONFIG, HammerGestureConfig} from '@angular/platform-browser';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {AppComponent} from './app.component';
|
import {AppComponent} from './app.component';
|
||||||
@ -52,6 +46,7 @@ import {MapSettingsComponent} from './settings/map/map.settings.component';
|
|||||||
import {TooltipModule} from 'ngx-bootstrap/tooltip';
|
import {TooltipModule} from 'ngx-bootstrap/tooltip';
|
||||||
import {BsDropdownModule} from 'ngx-bootstrap/dropdown';
|
import {BsDropdownModule} from 'ngx-bootstrap/dropdown';
|
||||||
import {CollapseModule} from 'ngx-bootstrap/collapse';
|
import {CollapseModule} from 'ngx-bootstrap/collapse';
|
||||||
|
import {PopoverModule} from 'ngx-bootstrap/popover';
|
||||||
import {ThumbnailSettingsComponent} from './settings/thumbnail/thumbanil.settings.component';
|
import {ThumbnailSettingsComponent} from './settings/thumbnail/thumbanil.settings.component';
|
||||||
import {SearchSettingsComponent} from './settings/search/search.settings.component';
|
import {SearchSettingsComponent} from './settings/search/search.settings.component';
|
||||||
import {SettingsService} from './settings/settings.service';
|
import {SettingsService} from './settings/settings.service';
|
||||||
@ -75,6 +70,9 @@ import {MapService} from './gallery/map/map.service';
|
|||||||
import {MetaFileSettingsComponent} from './settings/metafiles/metafile.settings.component';
|
import {MetaFileSettingsComponent} from './settings/metafiles/metafile.settings.component';
|
||||||
import {ThumbnailLoaderService} from './gallery/thumbnailLoader.service';
|
import {ThumbnailLoaderService} from './gallery/thumbnailLoader.service';
|
||||||
import {FileSizePipe} from './pipes/FileSizePipe';
|
import {FileSizePipe} from './pipes/FileSizePipe';
|
||||||
|
import {DuplicateService} from './duplicates/duplicates.service';
|
||||||
|
import {DuplicateComponent} from './duplicates/duplicates.component';
|
||||||
|
import {DuplicatesPhotoComponent} from './duplicates/photo/photo.duplicates.component';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -124,6 +122,7 @@ export function translationsFactory(locale: string) {
|
|||||||
ToastrModule.forRoot(),
|
ToastrModule.forRoot(),
|
||||||
ModalModule.forRoot(),
|
ModalModule.forRoot(),
|
||||||
CollapseModule.forRoot(),
|
CollapseModule.forRoot(),
|
||||||
|
PopoverModule.forRoot(),
|
||||||
BsDropdownModule.forRoot(),
|
BsDropdownModule.forRoot(),
|
||||||
SlimLoadingBarModule.forRoot(),
|
SlimLoadingBarModule.forRoot(),
|
||||||
BsDatepickerModule.forRoot(),
|
BsDatepickerModule.forRoot(),
|
||||||
@ -165,6 +164,8 @@ export function translationsFactory(locale: string) {
|
|||||||
BasicSettingsComponent,
|
BasicSettingsComponent,
|
||||||
OtherSettingsComponent,
|
OtherSettingsComponent,
|
||||||
IndexingSettingsComponent,
|
IndexingSettingsComponent,
|
||||||
|
DuplicateComponent,
|
||||||
|
DuplicatesPhotoComponent,
|
||||||
StringifyRole,
|
StringifyRole,
|
||||||
IconizeSortingMethod,
|
IconizeSortingMethod,
|
||||||
StringifySortingMethod,
|
StringifySortingMethod,
|
||||||
@ -190,6 +191,7 @@ export function translationsFactory(locale: string) {
|
|||||||
SettingsService,
|
SettingsService,
|
||||||
OverlayService,
|
OverlayService,
|
||||||
QueryService,
|
QueryService,
|
||||||
|
DuplicateService,
|
||||||
{
|
{
|
||||||
provide: TRANSLATIONS,
|
provide: TRANSLATIONS,
|
||||||
useFactory: translationsFactory,
|
useFactory: translationsFactory,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {GalleryComponent} from './gallery/gallery.component';
|
|||||||
import {AdminComponent} from './admin/admin.component';
|
import {AdminComponent} from './admin/admin.component';
|
||||||
import {ShareLoginComponent} from './sharelogin/share-login.component';
|
import {ShareLoginComponent} from './sharelogin/share-login.component';
|
||||||
import {QueryParams} from '../../common/QueryParams';
|
import {QueryParams} from '../../common/QueryParams';
|
||||||
|
import {DuplicateComponent} from './duplicates/duplicates.component';
|
||||||
|
|
||||||
export function galleryMatcherFunction(
|
export function galleryMatcherFunction(
|
||||||
segments: UrlSegment[]): UrlMatchResult | null {
|
segments: UrlSegment[]): UrlMatchResult | null {
|
||||||
@ -50,6 +51,10 @@ const ROUTES: Routes = [
|
|||||||
path: 'admin',
|
path: 'admin',
|
||||||
component: AdminComponent
|
component: AdminComponent
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'duplicates',
|
||||||
|
component: DuplicateComponent
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matcher: galleryMatcherFunction,
|
matcher: galleryMatcherFunction,
|
||||||
component: GalleryComponent
|
component: GalleryComponent
|
||||||
|
|||||||
15
frontend/app/duplicates/duplicates.component.css
Normal file
15
frontend/app/duplicates/duplicates.component.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.same-data {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row{
|
||||||
|
margin: 5px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover{
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
32
frontend/app/duplicates/duplicates.component.html
Normal file
32
frontend/app/duplicates/duplicates.component.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<app-frame>
|
||||||
|
|
||||||
|
<div body class="container">
|
||||||
|
<ng-template [ngIf]="_duplicateService.duplicates.value">
|
||||||
|
<div *ngFor="let pairs of _duplicateService.duplicates.value" class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div *ngFor="let media of pairs.media"
|
||||||
|
class="row"
|
||||||
|
[routerLink]="['/gallery', getDirectoryPath(media)]"
|
||||||
|
[queryParams]="queryService.getParams()">
|
||||||
|
<app-duplicates-photo class="col-1" [media]="media"></app-duplicates-photo>
|
||||||
|
<div class="col-5">
|
||||||
|
/{{getDirectoryPath(media)}}/<span class="same-data">{{media.name}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<span class="same-data">{{media.metadata.fileSize | fileSize}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-2" [title]="media.metadata.creationDate">
|
||||||
|
{{media.metadata.creationDate | date}}
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
{{media.metadata.size.width}}x{{media.metadata.size.height}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="!_duplicateService.duplicates.value">
|
||||||
|
loading
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</app-frame>
|
||||||
22
frontend/app/duplicates/duplicates.component.ts
Normal file
22
frontend/app/duplicates/duplicates.component.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import {Component} from '@angular/core';
|
||||||
|
import {DuplicateService} from './duplicates.service';
|
||||||
|
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
||||||
|
import {Utils} from '../../../common/Utils';
|
||||||
|
import {QueryService} from '../model/query.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-duplicate',
|
||||||
|
templateUrl: './duplicates.component.html',
|
||||||
|
styleUrls: ['./duplicates.component.css']
|
||||||
|
})
|
||||||
|
export class DuplicateComponent {
|
||||||
|
constructor(public _duplicateService: DuplicateService,
|
||||||
|
public queryService: QueryService) {
|
||||||
|
this._duplicateService.getDuplicates().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDirectoryPath(media: MediaDTO) {
|
||||||
|
return Utils.concatUrls(media.directory.path, media.directory.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
frontend/app/duplicates/duplicates.service.ts
Normal file
20
frontend/app/duplicates/duplicates.service.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {NetworkService} from '../model/network/network.service';
|
||||||
|
import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO';
|
||||||
|
import {BehaviorSubject} from 'rxjs';
|
||||||
|
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DuplicateService {
|
||||||
|
|
||||||
|
public duplicates: BehaviorSubject<DuplicatesDTO[]>;
|
||||||
|
|
||||||
|
constructor(private networkService: NetworkService) {
|
||||||
|
this.duplicates = new BehaviorSubject<DuplicatesDTO[]>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDuplicates() {
|
||||||
|
this.duplicates.next(await this.networkService.getJson<DuplicatesDTO[]>('/admin/duplicates'));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
frontend/app/duplicates/photo/photo.duplicates.component.css
Normal file
13
frontend/app/duplicates/photo/photo.duplicates.component.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.icon {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-icon {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-container {
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<div class="photo-container">
|
||||||
|
<ng-template #popTemplate>
|
||||||
|
<img alt="{{media.name}}"
|
||||||
|
class="big-icon"
|
||||||
|
[src]="thumbnail.Src | fixOrientation:Orientation | async"
|
||||||
|
*ngIf="thumbnail.Available">
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<img alt="{{media.name}}"
|
||||||
|
class="icon"
|
||||||
|
[popover]="popTemplate"
|
||||||
|
triggers="mouseenter:mouseleave"
|
||||||
|
[src]="thumbnail.Src | fixOrientation:Orientation | async"
|
||||||
|
*ngIf="thumbnail.Available">
|
||||||
|
</div>
|
||||||
40
frontend/app/duplicates/photo/photo.duplicates.component.ts
Normal file
40
frontend/app/duplicates/photo/photo.duplicates.component.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
|
||||||
|
import {MediaDTO} from '../../../../common/entities/MediaDTO';
|
||||||
|
import {Media} from '../../gallery/Media';
|
||||||
|
import {IconThumbnail, Thumbnail, ThumbnailManagerService} from '../../gallery/thumbnailManager.service';
|
||||||
|
import {PhotoDTO} from '../../../../common/entities/PhotoDTO';
|
||||||
|
import {OrientationTypes} from 'ts-exif-parser';
|
||||||
|
import {MediaIcon} from '../../gallery/MediaIcon';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-duplicates-photo',
|
||||||
|
templateUrl: './photo.duplicates.component.html',
|
||||||
|
styleUrls: ['./photo.duplicates.component.css']
|
||||||
|
})
|
||||||
|
export class DuplicatesPhotoComponent implements OnInit, OnDestroy {
|
||||||
|
@Input() media: MediaDTO;
|
||||||
|
|
||||||
|
thumbnail: IconThumbnail;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(private thumbnailService: ThumbnailManagerService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
get Orientation() {
|
||||||
|
if (!this.media) {
|
||||||
|
return OrientationTypes.TOP_LEFT;
|
||||||
|
}
|
||||||
|
return (<PhotoDTO>this.media).metadata.orientation || OrientationTypes.TOP_LEFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.thumbnail = this.thumbnailService.getIcon(new MediaIcon(this.media));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.thumbnail.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@ -35,6 +35,12 @@
|
|||||||
class="dropdown-menu dropdown-menu-right"
|
class="dropdown-menu dropdown-menu-right"
|
||||||
role="menu" aria-labelledby="button-basic">
|
role="menu" aria-labelledby="button-basic">
|
||||||
<ng-content select="[navbar-menu]"></ng-content>
|
<ng-content select="[navbar-menu]"></ng-content>
|
||||||
|
<li role="menuitem" *ngIf="isAdmin()">
|
||||||
|
<a class="dropdown-item" href="#" [routerLink]="['/duplicates']">
|
||||||
|
<span class="oi oi-layers"></span>
|
||||||
|
<ng-container i18n>duplicates</ng-container>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li role="menuitem" *ngIf="isAdmin()">
|
<li role="menuitem" *ngIf="isAdmin()">
|
||||||
<a class="dropdown-item" href="#" [routerLink]="['/admin']">
|
<a class="dropdown-item" href="#" [routerLink]="['/admin']">
|
||||||
<span class="oi oi-wrench"></span>
|
<span class="oi oi-wrench"></span>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user