diff --git a/src/backend/middlewares/AlbumMWs.ts b/src/backend/middlewares/AlbumMWs.ts new file mode 100644 index 000000000..de05f97f1 --- /dev/null +++ b/src/backend/middlewares/AlbumMWs.ts @@ -0,0 +1,63 @@ +import {NextFunction, Request, Response} from 'express'; +import {ErrorCodes, ErrorDTO} from '../../common/entities/Error'; +import {ObjectManagers} from '../model/ObjectManagers'; +import {Utils} from '../../common/Utils'; +import {Config} from '../../common/config/private/Config'; + + +export class AlbumMWs { + + + public static async listAlbums(req: Request, res: Response, next: NextFunction): Promise { + if (Config.Client.Album.enabled === false) { + return next(); + } + try { + req.resultPipe = await ObjectManagers.getInstance() + .AlbumManager.getAlbums(); + return next(); + + } catch (err) { + return next(new ErrorDTO(ErrorCodes.ALBUM_ERROR, 'Error during listing albums', err)); + } + } + + + public static async deleteAlbum(req: Request, res: Response, next: NextFunction): Promise { + if (Config.Client.Album.enabled === false) { + return next(); + } + if (!req.params.id || !Utils.isUInt32(parseInt(req.params.id, 10))) { + return next(); + } + try { + await ObjectManagers.getInstance().AlbumManager.deleteAlbum(parseInt(req.params.id, 10)); + req.resultPipe = 'ok'; + return next(); + + } catch (err) { + return next(new ErrorDTO(ErrorCodes.ALBUM_ERROR, 'Error during deleting albums', err)); + } + } + + public static async createSavedSearch(req: Request, res: Response, next: NextFunction): Promise { + if (Config.Client.Album.enabled === false) { + return next(); + } + if ((typeof req.body === 'undefined') || (typeof req.body.name !== 'string') || (typeof req.body.searchQuery !== 'object')) { + return next(new ErrorDTO(ErrorCodes.INPUT_ERROR, 'updateSharing filed is missing')); + } + try { + await ObjectManagers.getInstance().AlbumManager.addSavedSearch(req.body.name, req.body.searchQuery); + req.resultPipe = 'ok'; + return next(); + + } catch (err) { + return next(new ErrorDTO(ErrorCodes.ALBUM_ERROR, 'Error during creating saved search albums', err)); + } + } + + +} + + diff --git a/src/backend/middlewares/SharingMWs.ts b/src/backend/middlewares/SharingMWs.ts index 0ec636633..be5697d5f 100644 --- a/src/backend/middlewares/SharingMWs.ts +++ b/src/backend/middlewares/SharingMWs.ts @@ -117,6 +117,7 @@ export class SharingMWs { try { req.resultPipe = await ObjectManagers.getInstance().SharingManager.deleteSharing(sharingKey); + req.resultPipe = 'ok'; return next(); } catch (err) { return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error during deleting sharing', err)); diff --git a/src/backend/model/ObjectManagers.ts b/src/backend/model/ObjectManagers.ts index a97a9759b..85325336f 100644 --- a/src/backend/model/ObjectManagers.ts +++ b/src/backend/model/ObjectManagers.ts @@ -9,6 +9,8 @@ import {IPersonManager} from './database/interfaces/IPersonManager'; import {IVersionManager} from './database/interfaces/IVersionManager'; import {IJobManager} from './database/interfaces/IJobManager'; import {LocationManager} from './database/LocationManager'; +import {IAlbumManager} from './database/interfaces/IAlbumManager'; +import {JobManager} from './jobs/JobManager'; const LOG_TAG = '[ObjectManagers]'; @@ -25,6 +27,7 @@ export class ObjectManagers { private versionManager: IVersionManager; private jobManager: IJobManager; private locationManager: LocationManager; + private albumManager: IAlbumManager; get VersionManager(): IVersionManager { @@ -43,6 +46,14 @@ export class ObjectManagers { this.locationManager = value; } + get AlbumManager(): IAlbumManager { + return this.albumManager; + } + + set AlbumManager(value: IAlbumManager) { + this.albumManager = value; + } + get PersonManager(): IPersonManager { return this.personManager; } @@ -121,51 +132,30 @@ export class ObjectManagers { } - public static async InitCommonManagers(): Promise { - const JobManager = require('./jobs/JobManager').JobManager; - ObjectManagers.getInstance().JobManager = new JobManager(); - } - public static async InitMemoryManagers(): Promise { await ObjectManagers.reset(); - const GalleryManager = require('./database/memory/GalleryManager').GalleryManager; - const UserManager = require('./database/memory/UserManager').UserManager; - const SearchManager = require('./database/memory/SearchManager').SearchManager; - const SharingManager = require('./database/memory/SharingManager').SharingManager; - const IndexingManager = require('./database/memory/IndexingManager').IndexingManager; - const PersonManager = require('./database/memory/PersonManager').PersonManager; - const VersionManager = require('./database/memory/VersionManager').VersionManager; - ObjectManagers.getInstance().GalleryManager = new GalleryManager(); - ObjectManagers.getInstance().UserManager = new UserManager(); - ObjectManagers.getInstance().SearchManager = new SearchManager(); - ObjectManagers.getInstance().SharingManager = new SharingManager(); - ObjectManagers.getInstance().IndexingManager = new IndexingManager(); - ObjectManagers.getInstance().PersonManager = new PersonManager(); - ObjectManagers.getInstance().VersionManager = new VersionManager(); - ObjectManagers.getInstance().LocationManager = new LocationManager(); - this.InitCommonManagers(); + this.initManagers('memory'); + Logger.debug(LOG_TAG, 'Memory DB inited'); } public static async InitSQLManagers(): Promise { await ObjectManagers.reset(); await SQLConnection.init(); - const GalleryManager = require('./database/sql/GalleryManager').GalleryManager; - const UserManager = require('./database/sql/UserManager').UserManager; - const SearchManager = require('./database/sql/SearchManager').SearchManager; - const SharingManager = require('./database/sql/SharingManager').SharingManager; - const IndexingManager = require('./database/sql/IndexingManager').IndexingManager; - const PersonManager = require('./database/sql/PersonManager').PersonManager; - const VersionManager = require('./database/sql/VersionManager').VersionManager; - ObjectManagers.getInstance().GalleryManager = new GalleryManager(); - ObjectManagers.getInstance().UserManager = new UserManager(); - ObjectManagers.getInstance().SearchManager = new SearchManager(); - ObjectManagers.getInstance().SharingManager = new SharingManager(); - ObjectManagers.getInstance().IndexingManager = new IndexingManager(); - ObjectManagers.getInstance().PersonManager = new PersonManager(); - ObjectManagers.getInstance().VersionManager = new VersionManager(); - ObjectManagers.getInstance().LocationManager = new LocationManager(); - this.InitCommonManagers(); + this.initManagers('sql'); Logger.debug(LOG_TAG, 'SQL DB inited'); } + private static initManagers(type: 'memory' | 'sql'): void { + ObjectManagers.getInstance().AlbumManager = new (require(`./database/${type}/AlbumManager`).AlbumManager)(); + ObjectManagers.getInstance().GalleryManager = new (require(`./database/${type}/GalleryManager`).GalleryManager)(); + ObjectManagers.getInstance().IndexingManager = new (require(`./database/${type}/IndexingManager`).IndexingManager)(); + ObjectManagers.getInstance().PersonManager = new (require(`./database/${type}/PersonManager`).PersonManager)(); + ObjectManagers.getInstance().SearchManager = new (require(`./database/${type}/SearchManager`).SearchManager)(); + ObjectManagers.getInstance().SharingManager = new (require(`./database/${type}/SharingManager`).SharingManager)(); + ObjectManagers.getInstance().UserManager = new (require(`./database/${type}/UserManager`).UserManager)(); + ObjectManagers.getInstance().VersionManager = new (require(`./database/${type}/VersionManager`).VersionManager)(); + ObjectManagers.getInstance().JobManager = new JobManager(); + ObjectManagers.getInstance().LocationManager = new LocationManager(); + } + } diff --git a/src/backend/model/database/interfaces/IAlbumManager.ts b/src/backend/model/database/interfaces/IAlbumManager.ts new file mode 100644 index 000000000..060300b0d --- /dev/null +++ b/src/backend/model/database/interfaces/IAlbumManager.ts @@ -0,0 +1,19 @@ +import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO'; +import {AlbumBaseDTO} from '../../../../common/entities/album/AlbumBaseDTO'; + +export interface IAlbumManager { + /** + * Creates a saved search type of album + */ + addSavedSearch(name: string, searchQuery: SearchQueryDTO): Promise; + + /** + * Deletes an album + */ + deleteAlbum(id: number): Promise; + + /** + * Returns with all albums + */ + getAlbums(): Promise; +} diff --git a/src/backend/model/database/memory/AlbumManager.ts b/src/backend/model/database/memory/AlbumManager.ts new file mode 100644 index 000000000..2e58e8a09 --- /dev/null +++ b/src/backend/model/database/memory/AlbumManager.ts @@ -0,0 +1,19 @@ +import {AlbumBaseDTO} from '../../../../common/entities/album/AlbumBaseDTO'; +import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO'; +import {IAlbumManager} from '../interfaces/IAlbumManager'; + +export class AlbumManager implements IAlbumManager { + + public async addSavedSearch(name: string, searchQuery: SearchQueryDTO): Promise { + throw new Error('not supported by memory DB'); + + } + + public async deleteAlbum(id: number): Promise { + throw new Error('not supported by memory DB'); + } + + public async getAlbums(): Promise { + throw new Error('not supported by memory DB'); + } +} diff --git a/src/backend/model/database/sql/AlbumManager.ts b/src/backend/model/database/sql/AlbumManager.ts new file mode 100644 index 000000000..dab0ec673 --- /dev/null +++ b/src/backend/model/database/sql/AlbumManager.ts @@ -0,0 +1,40 @@ +import {SQLConnection} from './SQLConnection'; +import {AlbumBaseEntity} from './enitites/album/AlbumBaseEntity'; +import {AlbumBaseDTO} from '../../../../common/entities/album/AlbumBaseDTO'; +import {SavedSearchDTO} from '../../../../common/entities/album/SavedSearchDTO'; +import {ObjectManagers} from '../../ObjectManagers'; +import {ISQLSearchManager} from './ISearchManager'; +import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO'; +import {SavedSearchEntity} from './enitites/album/SavedSearchEntity'; +import { IAlbumManager } from '../interfaces/IAlbumManager'; + +export class AlbumManager implements IAlbumManager{ + private static async fillPreviewToAlbum(album: AlbumBaseDTO): Promise { + if (!(album as SavedSearchDTO).searchQuery) { + throw new Error('no search query present'); + } + album.preview = await (ObjectManagers.getInstance().SearchManager as ISQLSearchManager) + .getPreview((album as SavedSearchDTO).searchQuery); + } + + public async addSavedSearch(name: string, searchQuery: SearchQueryDTO): Promise { + const connection = await SQLConnection.getConnection(); + await connection.getRepository(SavedSearchEntity).insert({name, searchQuery}); + + } + + public async deleteAlbum(id: number): Promise { + const connection = await SQLConnection.getConnection(); + await connection.getRepository(AlbumBaseEntity).delete({id}); + } + + public async getAlbums(): Promise { + const connection = await SQLConnection.getConnection(); + const albums = await connection.getRepository(AlbumBaseEntity).find(); + for (const a of albums) { + await AlbumManager.fillPreviewToAlbum(a); + } + + return albums; + } +} diff --git a/src/backend/model/database/sql/ISearchManager.ts b/src/backend/model/database/sql/ISearchManager.ts new file mode 100644 index 000000000..0bb1ee62b --- /dev/null +++ b/src/backend/model/database/sql/ISearchManager.ts @@ -0,0 +1,17 @@ +import {SearchQueryDTO, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO'; +import {MediaDTO} from '../../../../common/entities/MediaDTO'; +import {ISearchManager} from '../interfaces/ISearchManager'; +import {AutoCompleteItem} from '../../../../common/entities/AutoCompleteItem'; +import {SearchResultDTO} from '../../../../common/entities/SearchResultDTO'; +import {PhotoDTO} from '../../../../common/entities/PhotoDTO'; + +export interface ISQLSearchManager extends ISearchManager { + autocomplete(text: string, type: SearchQueryTypes): Promise; + + search(query: SearchQueryDTO): Promise; + + getRandomPhoto(queryFilter: SearchQueryDTO): Promise; + + // "Protected" functions. only called from other Managers, not from middlewares + getPreview(query: SearchQueryDTO): Promise; +} diff --git a/src/backend/model/database/sql/SQLConnection.ts b/src/backend/model/database/sql/SQLConnection.ts index 77c5f5079..d7e9ad365 100644 --- a/src/backend/model/database/sql/SQLConnection.ts +++ b/src/backend/model/database/sql/SQLConnection.ts @@ -19,6 +19,8 @@ import {PersonEntry} from './enitites/PersonEntry'; import {Utils} from '../../../../common/Utils'; import * as path from 'path'; import {DatabaseType, ServerDataBaseConfig, SQLLogLevel} from '../../../../common/config/private/PrivateConfig'; +import {AlbumBaseEntity} from './enitites/album/AlbumBaseEntity'; +import {SavedSearchEntity} from './enitites/album/SavedSearchEntity'; export class SQLConnection { @@ -43,6 +45,8 @@ export class SQLConnection { VideoEntity, DirectoryEntity, SharingEntity, + AlbumBaseEntity, + SavedSearchEntity, VersionEntity ]; options.synchronize = false; @@ -73,6 +77,8 @@ export class SQLConnection { VideoEntity, DirectoryEntity, SharingEntity, + AlbumBaseEntity, + SavedSearchEntity, VersionEntity ]; options.synchronize = false; diff --git a/src/backend/model/database/sql/SearchManager.ts b/src/backend/model/database/sql/SearchManager.ts index d5f0f1477..7203214ed 100644 --- a/src/backend/model/database/sql/SearchManager.ts +++ b/src/backend/model/database/sql/SearchManager.ts @@ -1,5 +1,4 @@ import {AutoCompleteItem} from '../../../../common/entities/AutoCompleteItem'; -import {ISearchManager} from '../interfaces/ISearchManager'; import {SearchResultDTO} from '../../../../common/entities/SearchResultDTO'; import {SQLConnection} from './SQLConnection'; import {PhotoEntity} from './enitites/PhotoEntity'; @@ -32,8 +31,10 @@ import {Utils} from '../../../../common/Utils'; import {PhotoDTO} from '../../../../common/entities/PhotoDTO'; import {DatabaseType} from '../../../../common/config/private/PrivateConfig'; import {ISQLGalleryManager} from './IGalleryManager'; +import {ISQLSearchManager} from './ISearchManager'; +import {MediaDTO} from '../../../../common/entities/MediaDTO'; -export class SearchManager implements ISearchManager { +export class SearchManager implements ISQLSearchManager { private static autoCompleteItemsUnique(array: Array): Array { const a = array.concat(); @@ -223,6 +224,21 @@ export class SearchManager implements ISearchManager { } + public async getPreview(queryIN: SearchQueryDTO): Promise { + let query = this.flattenSameOfQueries(queryIN); + query = await this.getGPSData(query); + const connection = await SQLConnection.getConnection(); + + return await connection + .getRepository(MediaEntity) + .createQueryBuilder('media') + .innerJoinAndSelect('media.directory', 'directory') + .where(this.buildWhereQuery(query)) + .orderBy('media.metadata.creationDate', 'DESC') + .limit(1) + .getOne(); + } + /** * Returns only those part of a query tree that only contains directory related search queries */ @@ -632,4 +648,5 @@ export class SearchManager implements ISearchManager { return res; } + } diff --git a/src/backend/model/database/sql/enitites/MediaEntity.ts b/src/backend/model/database/sql/enitites/MediaEntity.ts index 33e518765..08090b582 100644 --- a/src/backend/model/database/sql/enitites/MediaEntity.ts +++ b/src/backend/model/database/sql/enitites/MediaEntity.ts @@ -82,7 +82,7 @@ export class MediaMetadataEntity implements MediaMetadata { // TODO: fix inheritance once its working in typeorm @Entity() @Unique(['name', 'directory']) -@TableInheritance({column: {type: 'varchar', name: 'type', length: 32}}) +@TableInheritance({column: {type: 'varchar', name: 'type', length: 16}}) export abstract class MediaEntity implements MediaDTO { @Index() diff --git a/src/backend/model/database/sql/enitites/album/AlbumBaseEntity.ts b/src/backend/model/database/sql/enitites/album/AlbumBaseEntity.ts new file mode 100644 index 000000000..3d1164aae --- /dev/null +++ b/src/backend/model/database/sql/enitites/album/AlbumBaseEntity.ts @@ -0,0 +1,21 @@ +import {Column, Entity, Index, PrimaryGeneratedColumn, TableInheritance} from 'typeorm'; +import {MediaEntity} from '../MediaEntity'; +import {columnCharsetCS} from '../EntityUtils'; +import {AlbumBaseDTO} from '../../../../../../common/entities/album/AlbumBaseDTO'; + +@Entity() +@TableInheritance({column: {type: 'varchar', name: 'type', length: 24}}) +export class AlbumBaseEntity implements AlbumBaseDTO { + + @Index() + @PrimaryGeneratedColumn({unsigned: true}) + id: number; + + @Index() + @Column(columnCharsetCS) + name: string; + + // not saving to database, it is only assigned when querying the DB + public preview: MediaEntity; + +} diff --git a/src/backend/model/database/sql/enitites/album/SavedSearchEntity.ts b/src/backend/model/database/sql/enitites/album/SavedSearchEntity.ts new file mode 100644 index 000000000..5e7c89d34 --- /dev/null +++ b/src/backend/model/database/sql/enitites/album/SavedSearchEntity.ts @@ -0,0 +1,23 @@ +import {ChildEntity, Column} from 'typeorm'; +import {AlbumBaseEntity} from './AlbumBaseEntity'; +import {SavedSearchDTO} from '../../../../../../common/entities/album/SavedSearchDTO'; +import {SearchQueryDTO} from '../../../../../../common/entities/SearchQueryDTO'; + +@ChildEntity() +export class SavedSearchEntity extends AlbumBaseEntity implements SavedSearchDTO { + @Column({ + type: 'text', + nullable: false, + transformer: { + // used to deserialize your data from db field value + from: (val: string) => { + return JSON.parse(val); + }, + // used to serialize your data to db field + to: (val: object) => { + return JSON.stringify(val); + } + } + }) + searchQuery: SearchQueryDTO; +} diff --git a/src/backend/routes/AlbumRouter.ts b/src/backend/routes/AlbumRouter.ts new file mode 100644 index 000000000..8e3bec0d6 --- /dev/null +++ b/src/backend/routes/AlbumRouter.ts @@ -0,0 +1,56 @@ +import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs'; +import {Express} from 'express'; +import {RenderingMWs} from '../middlewares/RenderingMWs'; +import {UserRoles} from '../../common/entities/UserDTO'; +import {VersionMWs} from '../middlewares/VersionMWs'; +import {AlbumMWs} from '../middlewares/AlbumMWs'; + +export class AlbumRouter { + public static route(app: Express): void { + + this.addListAlbums(app); + this.addAddSavedSearch(app); + this.addDeleteAlbum(app); + } + + + private static addListAlbums(app: Express): void { + app.get(['/api/albums'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.User), + VersionMWs.injectGalleryVersion, + + // specific part + AlbumMWs.listAlbums, + RenderingMWs.renderResult + ); + } + + private static addDeleteAlbum(app: Express): void { + app.delete(['/api/albums/:id'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + VersionMWs.injectGalleryVersion, + + // specific part + AlbumMWs.deleteAlbum, + RenderingMWs.renderResult + ); + } + + private static addAddSavedSearch(app: Express): void { + app.put(['/api/albums/saved-searches'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + VersionMWs.injectGalleryVersion, + + // specific part + AlbumMWs.createSavedSearch, + RenderingMWs.renderResult + ); + } + +} diff --git a/src/backend/routes/PublicRouter.ts b/src/backend/routes/PublicRouter.ts index 82b7f83cb..91db9e5b0 100644 --- a/src/backend/routes/PublicRouter.ts +++ b/src/backend/routes/PublicRouter.ts @@ -108,7 +108,7 @@ export class PublicRouter { } ); - app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/faces', '/search*'], + app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/faces', '/albums', '/search*'], AuthenticationMWs.tryAuthenticate, setLocale, renderIndex diff --git a/src/backend/routes/Router.ts b/src/backend/routes/Router.ts index 476420de8..859721d49 100644 --- a/src/backend/routes/Router.ts +++ b/src/backend/routes/Router.ts @@ -8,6 +8,7 @@ import {AdminRouter} from './admin/AdminRouter'; import {SettingsRouter} from './admin/SettingsRouter'; import {NotificationRouter} from './NotificationRouter'; import {ErrorRouter} from './ErrorRouter'; +import {AlbumRouter} from './AlbumRouter'; export class Router { @@ -15,13 +16,14 @@ export class Router { PublicRouter.route(app); - UserRouter.route(app); + AdminRouter.route(app); + AlbumRouter.route(app); GalleryRouter.route(app); + NotificationRouter.route(app); PersonRouter.route(app); - SharingRouter.route(app); - AdminRouter.route(app); SettingsRouter.route(app); - NotificationRouter.route(app); + SharingRouter.route(app); + UserRouter.route(app); ErrorRouter.route(app); } diff --git a/src/backend/routes/SharingRouter.ts b/src/backend/routes/SharingRouter.ts index 4d842b9ff..5be08647f 100644 --- a/src/backend/routes/SharingRouter.ts +++ b/src/backend/routes/SharingRouter.ts @@ -57,7 +57,7 @@ export class SharingRouter { AuthenticationMWs.authenticate, AuthenticationMWs.authorise(UserRoles.Admin), SharingMWs.deleteSharing, - RenderingMWs.renderOK + RenderingMWs.renderResult ); } diff --git a/src/common/DataStructureVersion.ts b/src/common/DataStructureVersion.ts index 477ee4e55..1df199c3c 100644 --- a/src/common/DataStructureVersion.ts +++ b/src/common/DataStructureVersion.ts @@ -1 +1 @@ -export const DataStructureVersion = 21; +export const DataStructureVersion = 22; diff --git a/src/common/config/public/ClientConfig.ts b/src/common/config/public/ClientConfig.ts index a61254fe3..f959c9ac5 100644 --- a/src/common/config/public/ClientConfig.ts +++ b/src/common/config/public/ClientConfig.ts @@ -35,6 +35,13 @@ export class ClientSearchConfig { maxDirectoryResult: number = 200; } + +@SubConfigClass() +export class ClientAlbumConfig { + @ConfigProperty() + enabled: boolean = false; +} + @SubConfigClass() export class ClientSharingConfig { @ConfigProperty() @@ -172,6 +179,8 @@ export class ClientConfig { @ConfigProperty() Sharing: ClientSharingConfig = new ClientSharingConfig(); @ConfigProperty() + Album: ClientAlbumConfig = new ClientAlbumConfig(); + @ConfigProperty() Map: ClientMapConfig = new ClientMapConfig(); @ConfigProperty() RandomPhoto: ClientRandomPhotoConfig = new ClientRandomPhotoConfig(); diff --git a/src/common/entities/Error.ts b/src/common/entities/Error.ts index 0fc322b37..978f6d385 100644 --- a/src/common/entities/Error.ts +++ b/src/common/entities/Error.ts @@ -24,6 +24,8 @@ export enum ErrorCodes { TASK_ERROR = 14, JOB_ERROR = 15, LocationLookUp_ERROR = 16, + + ALBUM_ERROR = 17, } export class ErrorDTO { diff --git a/src/common/entities/SearchQueryDTO.ts b/src/common/entities/SearchQueryDTO.ts index 0c0294c4d..a8c595c65 100644 --- a/src/common/entities/SearchQueryDTO.ts +++ b/src/common/entities/SearchQueryDTO.ts @@ -158,7 +158,7 @@ export interface TextSearch extends NegatableSearchQuery { SearchQueryTypes.caption | SearchQueryTypes.file_name | SearchQueryTypes.directory; - matchType: TextSearchQueryMatchTypes; + matchType?: TextSearchQueryMatchTypes; text: string; } diff --git a/src/common/entities/album/AlbumBaseDTO.ts b/src/common/entities/album/AlbumBaseDTO.ts new file mode 100644 index 000000000..e305d4caf --- /dev/null +++ b/src/common/entities/album/AlbumBaseDTO.ts @@ -0,0 +1,7 @@ +import {PreviewPhotoDTO} from '../PhotoDTO'; + +export interface AlbumBaseDTO { + id: number; + name: string; + preview: PreviewPhotoDTO; +} diff --git a/src/common/entities/album/SavedSearchDTO.ts b/src/common/entities/album/SavedSearchDTO.ts new file mode 100644 index 000000000..12394a0f4 --- /dev/null +++ b/src/common/entities/album/SavedSearchDTO.ts @@ -0,0 +1,11 @@ +import {AlbumBaseDTO} from './AlbumBaseDTO'; +import {PreviewPhotoDTO} from '../PhotoDTO'; +import {SearchQueryDTO} from '../SearchQueryDTO'; + +export interface SavedSearchDTO extends AlbumBaseDTO { + id: number; + name: string; + preview: PreviewPhotoDTO; + + searchQuery: SearchQueryDTO; +} diff --git a/src/frontend/app/app.module.ts b/src/frontend/app/app.module.ts index 3ddc50eab..d2d33406a 100644 --- a/src/frontend/app/app.module.ts +++ b/src/frontend/app/app.module.ts @@ -100,6 +100,10 @@ import {AppRoutingModule} from './app.routing'; import {CookieService} from 'ngx-cookie-service'; import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster'; import {icon, Marker} from 'leaflet'; +import {AlbumsComponent} from './ui/albums/albums.component'; +import {AlbumComponent} from './ui/albums/album/album.component'; +import {AlbumsService} from './ui/albums/albums.service'; +import {GallerySearchQueryBuilderComponent} from './ui/gallery/search/query-builder/query-bulder.gallery.component'; @Injectable() @@ -178,6 +182,9 @@ Marker.prototype.options.icon = iconDefault; LanguageComponent, TimeStampDatePickerComponent, TimeStampTimePickerComponent, + // Albums + AlbumsComponent, + AlbumComponent, // Gallery GalleryLightboxMediaComponent, GalleryPhotoLoadingComponent, @@ -190,6 +197,7 @@ Marker.prototype.options.icon = iconDefault; GallerySearchComponent, GallerySearchQueryEntryComponent, GallerySearchFieldComponent, + GallerySearchQueryBuilderComponent, GalleryShareComponent, GalleryNavigatorComponent, GalleryPhotoComponent, @@ -241,6 +249,7 @@ Marker.prototype.options.icon = iconDefault; NetworkService, ShareService, UserService, + AlbumsService, GalleryCacheService, GalleryService, MapService, diff --git a/src/frontend/app/app.routing.ts b/src/frontend/app/app.routing.ts index cf455d4c0..9cc402e46 100644 --- a/src/frontend/app/app.routing.ts +++ b/src/frontend/app/app.routing.ts @@ -8,6 +8,7 @@ import {QueryParams} from '../../common/QueryParams'; import {DuplicateComponent} from './ui/duplicates/duplicates.component'; import {FacesComponent} from './ui/faces/faces.component'; import {AuthGuard} from './model/network/helper/auth.guard'; +import {AlbumsComponent} from './ui/albums/albums.component'; export function galleryMatcherFunction( segments: UrlSegment[]): UrlMatchResult | null { @@ -59,6 +60,11 @@ const routes: Routes = [ component: DuplicateComponent, canActivate: [AuthGuard] }, + { + path: 'albums', + component: AlbumsComponent, + canActivate: [AuthGuard] + }, { path: 'faces', component: FacesComponent, diff --git a/src/frontend/app/ui/albums/album/album.component.css b/src/frontend/app/ui/albums/album/album.component.css new file mode 100644 index 000000000..56d40552f --- /dev/null +++ b/src/frontend/app/ui/albums/album/album.component.css @@ -0,0 +1,60 @@ +.delete { + margin: 2px; + cursor: default; +} + +.delete { + cursor: pointer; + transition: all .05s ease-in-out; + transform: scale(1.0, 1.0); +} + +.delete:hover { + transform: scale(1.4, 1.4); +} + + +a { + position: relative; +} + +.photo-container { + border: 2px solid #333; + width: 180px; + height: 180px; + background-color: #bbbbbb; +} + +.no-image { + position: absolute; + color: #7f7f7f; + font-size: 80px; + top: calc(50% - 40px); + left: calc(50% - 40px); +} + +.photo { + width: 100%; + height: 100%; + background-size: cover; + background-position: center; +} + +.info { + background-color: rgba(0, 0, 0, 0.6); + color: white; + font-size: medium; + position: absolute; + bottom: 0; + left: 0; + padding: 5px; + width: 100%; +} + +a:hover .info { + background-color: rgba(0, 0, 0, 0.8); +} + +a:hover .photo-container { + border-color: #000; +} diff --git a/src/frontend/app/ui/albums/album/album.component.html b/src/frontend/app/ui/albums/album/album.component.html new file mode 100644 index 000000000..c0692e80f --- /dev/null +++ b/src/frontend/app/ui/albums/album/album.component.html @@ -0,0 +1,28 @@ + + + +
+ +
+ + +
+ + + +
+ {{album.name}} + + +
+
+ diff --git a/src/frontend/app/ui/albums/album/album.component.ts b/src/frontend/app/ui/albums/album/album.component.ts new file mode 100644 index 000000000..2ca850cff --- /dev/null +++ b/src/frontend/app/ui/albums/album/album.component.ts @@ -0,0 +1,77 @@ +import {Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {DomSanitizer, SafeStyle} from '@angular/platform-browser'; +import {Thumbnail, ThumbnailManagerService} from '../../gallery/thumbnailManager.service'; +import {AuthenticationService} from '../../../model/network/authentication.service'; +import {AlbumsService} from '../albums.service'; +import {AlbumBaseDTO} from '../../../../../common/entities/album/AlbumBaseDTO'; +import {Media} from '../../gallery/Media'; +import {SavedSearchDTO} from '../../../../../common/entities/album/SavedSearchDTO'; +import {UserRoles} from '../../../../../common/entities/UserDTO'; + +@Component({ + selector: 'app-album', + templateUrl: './album.component.html', + styleUrls: ['./album.component.css'], + providers: [RouterLink], +}) +export class AlbumComponent implements OnInit, OnDestroy { + @Input() album: AlbumBaseDTO; + @Input() size: number; + + public thumbnail: Thumbnail = null; + + constructor(private thumbnailService: ThumbnailManagerService, + private sanitizer: DomSanitizer, + private albumService: AlbumsService, + public authenticationService: AuthenticationService) { + + } + + get IsSavedSearch(): boolean { + return this.album && !!this.AsSavedSearch.searchQuery; + } + + get AsSavedSearch(): SavedSearchDTO { + return this.album as SavedSearchDTO; + } + + get CanUpdate(): boolean { + return this.authenticationService.user.getValue().role >= UserRoles.Admin; + } + + get RouterLink(): any[] { + if (this.IsSavedSearch) { + return ['/search', JSON.stringify(this.AsSavedSearch.searchQuery)]; + } + // TODO: add "normal" albums here once they are ready, see: https://github.com/bpatrik/pigallery2/issues/301 + return null; + } + + ngOnInit(): void { + if (this.album.preview) { + this.thumbnail = this.thumbnailService.getThumbnail(new Media(this.album.preview, this.size, this.size)); + } + + } + + getSanitizedThUrl(): SafeStyle { + return this.sanitizer.bypassSecurityTrustStyle('url(' + this.thumbnail.Src + .replace(/\(/g, '%28') + .replace(/'/g, '%27') + .replace(/\)/g, '%29') + ')'); + } + + ngOnDestroy(): void { + if (this.thumbnail != null) { + this.thumbnail.destroy(); + } + } + + async deleteAlbum($event: MouseEvent): Promise { + $event.preventDefault(); + $event.stopPropagation(); + await this.albumService.deleteAlbum(this.album).catch(console.error); + } +} + diff --git a/src/frontend/app/ui/albums/albums.component.css b/src/frontend/app/ui/albums/albums.component.css new file mode 100644 index 000000000..43f801a67 --- /dev/null +++ b/src/frontend/app/ui/albums/albums.component.css @@ -0,0 +1,29 @@ +app-album { + margin: 2px; + display: inline-block; +} + +.no-item-msg { + height: 100vh; + text-align: center; +} + +.no-face-msg h2 { + color: #6c757d; +} + +.add-saved-search { + vertical-align: baseline; + position: absolute; + margin: 2px; +} + +.add-saved-search .text { + position: relative; + top: calc(50% - 40px); + text-align: center; +} + +.add-saved-search .text .oi { + font-size: 80px; +} diff --git a/src/frontend/app/ui/albums/albums.component.html b/src/frontend/app/ui/albums/albums.component.html new file mode 100644 index 000000000..ac6efa9f7 --- /dev/null +++ b/src/frontend/app/ui/albums/albums.component.html @@ -0,0 +1,74 @@ + + +
+ + + + +
+
+

:( + No albums to show. +

+
+
+
+
+ + + + + + + diff --git a/src/frontend/app/ui/albums/albums.component.ts b/src/frontend/app/ui/albums/albums.component.ts new file mode 100644 index 000000000..28e7e6da0 --- /dev/null +++ b/src/frontend/app/ui/albums/albums.component.ts @@ -0,0 +1,56 @@ +import {Component, ElementRef, OnInit, TemplateRef, ViewChild} from '@angular/core'; +import {AlbumsService} from './albums.service'; +import {BsModalService} from 'ngx-bootstrap/modal'; +import {BsModalRef} from 'ngx-bootstrap/modal/bs-modal-ref.service'; +import {SearchQueryTypes, TextSearch} from '../../../../common/entities/SearchQueryDTO'; + +@Component({ + selector: 'app-albums', + templateUrl: './albums.component.html', + styleUrls: ['./albums.component.css'] +}) +export class AlbumsComponent implements OnInit { + @ViewChild('container', {static: true}) container: ElementRef; + public size: number; + public savedSearch = { + name: '', + searchQuery: {type: SearchQueryTypes.any_text, text: ''} as TextSearch + }; + private modalRef: BsModalRef; + + constructor(public albumsService: AlbumsService, + private modalService: BsModalService) { + this.albumsService.getAlbums().catch(console.error); + } + + + ngOnInit(): void { + this.updateSize(); + } + + + public async openModal(template: TemplateRef): Promise { + this.modalRef = this.modalService.show(template, {class: 'modal-lg'}); + document.body.style.paddingRight = '0px'; + } + + public hideModal(): void { + this.modalRef.hide(); + this.modalRef = null; + } + + async saveSearch(): Promise { + await this.albumsService.addSavedSearch(this.savedSearch.name, this.savedSearch.searchQuery); + this.hideModal(); + } + + private updateSize(): void { + const size = 220 + 5; + // body - container margin + const containerWidth = this.container.nativeElement.clientWidth - 30; + this.size = (containerWidth / Math.round((containerWidth / size))) - 5; + } + + +} + diff --git a/src/frontend/app/ui/albums/albums.service.ts b/src/frontend/app/ui/albums/albums.service.ts new file mode 100644 index 000000000..ff6f75e15 --- /dev/null +++ b/src/frontend/app/ui/albums/albums.service.ts @@ -0,0 +1,31 @@ +import {Injectable} from '@angular/core'; +import {NetworkService} from '../../model/network/network.service'; +import {BehaviorSubject} from 'rxjs'; +import {AlbumBaseDTO} from '../../../../common/entities/album/AlbumBaseDTO'; +import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO'; + + +@Injectable() +export class AlbumsService { + public albums: BehaviorSubject; + + constructor(private networkService: NetworkService) { + this.albums = new BehaviorSubject(null); + } + + + public async getAlbums(): Promise { + this.albums.next((await this.networkService.getJson('/albums')) + .sort((a, b): number => a.name.localeCompare(b.name))); + } + + async deleteAlbum(album: AlbumBaseDTO): Promise { + await this.networkService.deleteJson('/albums/' + album.id); + await this.getAlbums(); + } + + async addSavedSearch(name: string, searchQuery: SearchQueryDTO): Promise { + await this.networkService.putJson('/albums/saved-searches', {name, searchQuery}); + await this.getAlbums(); + } +} diff --git a/src/frontend/app/ui/faces/face/face.component.css b/src/frontend/app/ui/faces/face/face.component.css index e01f51f36..a51921628 100644 --- a/src/frontend/app/ui/faces/face/face.component.css +++ b/src/frontend/app/ui/faces/face/face.component.css @@ -72,8 +72,3 @@ a:hover .photo-container { border-color: #000; } -.person-name { - display: inline-block; - width: 180px; - white-space: normal; -} diff --git a/src/frontend/app/ui/frame/frame.component.html b/src/frontend/app/ui/frame/frame.component.html index e567f34e6..eeca07ea5 100644 --- a/src/frontend/app/ui/frame/frame.component.html +++ b/src/frontend/app/ui/frame/frame.component.html @@ -17,6 +17,9 @@ [routerLink]="['/gallery']" [queryParams]="queryService.getParams()" [class.active]="isLinkActive('/gallery')" i18n>Gallery + diff --git a/src/frontend/app/ui/frame/frame.component.ts b/src/frontend/app/ui/frame/frame.component.ts index 8ce31c6b9..65e8dedd8 100644 --- a/src/frontend/app/ui/frame/frame.component.ts +++ b/src/frontend/app/ui/frame/frame.component.ts @@ -45,5 +45,8 @@ export class FrameComponent { this.authService.logout(); } + isAlbumsAvailable(): boolean { + return Config.Client.Album.enabled; + } } diff --git a/src/frontend/app/ui/gallery/directories/directory/directory.gallery.component.ts b/src/frontend/app/ui/gallery/directories/directory/directory.gallery.component.ts index 352325ae4..d35c0f907 100644 --- a/src/frontend/app/ui/gallery/directories/directory/directory.gallery.component.ts +++ b/src/frontend/app/ui/gallery/directories/directory/directory.gallery.component.ts @@ -59,15 +59,5 @@ export class GalleryDirectoryComponent implements OnInit, OnDestroy { } } - /* - calcSize() { - if (this.size == null || PageHelper.isScrollYVisible()) { - const size = 220 + 5; - const containerWidth = this.container.nativeElement.parentElement.parentElement.clientWidth; - this.size = containerWidth / Math.round((containerWidth / size)); - } - return Math.floor(this.size - 5); - } - */ } diff --git a/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html b/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html index 53b097284..b1408adc2 100644 --- a/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html +++ b/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html @@ -32,19 +32,12 @@
- - - - - - - + (change)="onQueryChange()" > +
diff --git a/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.ts b/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.ts index 06ea593f6..6308dd18b 100644 --- a/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.ts +++ b/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.ts @@ -20,8 +20,7 @@ import {SearchQueryParserService} from '../search/search-query-parser.service'; }) export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy { - public searchQueryDTO: SearchQueryDTO; - public rawSearchText: string; + public searchQueryDTO: SearchQueryDTO = {type: SearchQueryTypes.any_text, text: ''} as TextSearch; enabled = true; url = ''; @@ -37,7 +36,6 @@ export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy { private searchQueryParserService: SearchQueryParserService, private route: ActivatedRoute, private modalService: BsModalService) { - this.resetQuery(); this.subscription = this.route.params.subscribe((params: Params) => { if (!params[QueryParams.gallery.search.query]) { @@ -55,17 +53,8 @@ export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy { return JSON.stringify(this.searchQueryDTO); } - validateRawSearchText(): void { - try { - this.searchQueryDTO = this.searchQueryParserService.parse(this.rawSearchText); - this.url = NetworkService.buildUrl(Config.Client.publicUrl + '/api/gallery/random/' + this.HTMLSearchQuery); - } catch (e) { - console.error(e); - } - } onQueryChange(): void { - this.rawSearchText = this.searchQueryParserService.stringify(this.searchQueryDTO); this.url = NetworkService.buildUrl(Config.Client.publicUrl + '/api/gallery/random/' + this.HTMLSearchQuery); } @@ -114,9 +103,4 @@ export class RandomQueryBuilderGalleryComponent implements OnInit, OnDestroy { } - resetQuery(): void { - this.searchQueryDTO = {text: '', type: SearchQueryTypes.any_text} as TextSearch; - } - - } diff --git a/src/frontend/app/ui/gallery/search/query-builder/query-builder.gallery.component.css b/src/frontend/app/ui/gallery/search/query-builder/query-builder.gallery.component.css new file mode 100644 index 000000000..f63a984a3 --- /dev/null +++ b/src/frontend/app/ui/gallery/search/query-builder/query-builder.gallery.component.css @@ -0,0 +1,69 @@ +.autocomplete-list { + position: absolute; + left: 0; + top: 34px; + background-color: white; + width: 100%; + border: 1px solid #ccc; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + padding: 5px 0; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + z-index: 7; +} + +.insert-button { + margin-right: -15px; + display: none; + margin-top: 2px; +} + +.autocomplete-item-selected .insert-button { + display: block; +} +@media (hover: none) { + .insert-button { + display: block; + } +} + +.autocomplete-item-selected .insert-button:hover { + color: black; +} + +.autocomplete-item { + cursor: pointer; + padding-top: 2px; + padding-bottom: 2px; + font-size: 17px; +} + +.autocomplete-item { + color: #333; + padding: 0 20px; + line-height: 1.42857143; + font-weight: 400; + display: block; +} + +.autocomplete-item-selected { + background-color: #007bff; + color: #FFF; +} + + +.search-text { + z-index: 6; + width: 100%; + background: transparent; +} + +.search-hint { + left: 0; + z-index: 1; + width: 100%; + position: absolute; + margin-left: 0 !important; +} + diff --git a/src/frontend/app/ui/gallery/search/query-builder/query-builder.gallery.component.html b/src/frontend/app/ui/gallery/search/query-builder/query-builder.gallery.component.html new file mode 100644 index 000000000..2976710ab --- /dev/null +++ b/src/frontend/app/ui/gallery/search/query-builder/query-builder.gallery.component.html @@ -0,0 +1,15 @@ + + + +
+ + + diff --git a/src/frontend/app/ui/gallery/search/query-builder/query-bulder.gallery.component.ts b/src/frontend/app/ui/gallery/search/query-builder/query-bulder.gallery.component.ts new file mode 100644 index 000000000..24237a5b1 --- /dev/null +++ b/src/frontend/app/ui/gallery/search/query-builder/query-bulder.gallery.component.ts @@ -0,0 +1,82 @@ +import {Component, EventEmitter, forwardRef, Output} from '@angular/core'; +import {SearchQueryDTO, SearchQueryTypes, TextSearch} from '../../../../../../common/entities/SearchQueryDTO'; +import {ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator} from '@angular/forms'; +import {SearchQueryParserService} from '../search-query-parser.service'; + +@Component({ + selector: 'app-gallery-search-query-builder', + templateUrl: './query-builder.gallery.component.html', + styleUrls: ['./query-builder.gallery.component.css'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GallerySearchQueryBuilderComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GallerySearchQueryBuilderComponent), + multi: true + } + ] +}) +export class GallerySearchQueryBuilderComponent implements ControlValueAccessor, Validator { + public searchQueryDTO: SearchQueryDTO = {type: SearchQueryTypes.any_text, text: ''} as TextSearch; + @Output() search = new EventEmitter(); + public rawSearchText = ''; + + + constructor( + private searchQueryParserService: SearchQueryParserService) { + } + + + validateRawSearchText(): void { + try { + this.searchQueryDTO = this.searchQueryParserService.parse(this.rawSearchText); + this.onChange(); + } catch (e) { + console.error(e); + } + } + + + resetQuery(): void { + this.searchQueryDTO = ({text: '', type: SearchQueryTypes.any_text} as TextSearch); + } + + onQueryChange(): void { + this.rawSearchText = this.searchQueryParserService.stringify(this.searchQueryDTO); + this.onChange(); + } + + validate(control: FormControl): ValidationErrors { + return {required: true}; + } + + public onTouched(): void { + } + + public writeValue(obj: any): void { + this.searchQueryDTO = obj; + } + + registerOnChange(fn: (_: any) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.propagateTouch = fn; + } + + public onChange(): void { + this.propagateChange(this.searchQueryDTO); + } + + + private propagateChange = (_: any): void => { + }; + + private propagateTouch = (_: any): void => { + }; +} diff --git a/src/frontend/app/ui/gallery/search/search.gallery.component.html b/src/frontend/app/ui/gallery/search/search.gallery.component.html index 0abfb6507..1b91493d4 100644 --- a/src/frontend/app/ui/gallery/search/search.gallery.component.html +++ b/src/frontend/app/ui/gallery/search/search.gallery.component.html @@ -1,11 +1,11 @@