You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(web): enable websocket (#3765)
* send store event to page * fix format * add new asset to existing bucket * format * debouncing * format * load bucket * feedback * feat: listen to deletes and auto-subscribe on all asset grid pages * feat: auto refresh on person thumbnail * chore: skip upload event for now * fix: person thumbnail event * fix merge * update handleAssetDeletion with websocket communication * update info box on mount * fix test * fix test * feat: event for trash asset --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		| @@ -7,6 +7,7 @@ import { | |||||||
|   faceStub, |   faceStub, | ||||||
|   newAccessRepositoryMock, |   newAccessRepositoryMock, | ||||||
|   newAssetRepositoryMock, |   newAssetRepositoryMock, | ||||||
|  |   newCommunicationRepositoryMock, | ||||||
|   newCryptoRepositoryMock, |   newCryptoRepositoryMock, | ||||||
|   newJobRepositoryMock, |   newJobRepositoryMock, | ||||||
|   newStorageRepositoryMock, |   newStorageRepositoryMock, | ||||||
| @@ -14,6 +15,7 @@ import { | |||||||
| } from '@test'; | } from '@test'; | ||||||
| import { when } from 'jest-when'; | import { when } from 'jest-when'; | ||||||
| import { Readable } from 'stream'; | import { Readable } from 'stream'; | ||||||
|  | import { ICommunicationRepository } from '../communication'; | ||||||
| import { ICryptoRepository } from '../crypto'; | import { ICryptoRepository } from '../crypto'; | ||||||
| import { IJobRepository, JobItem, JobName } from '../job'; | import { IJobRepository, JobItem, JobName } from '../job'; | ||||||
| import { IStorageRepository } from '../storage'; | import { IStorageRepository } from '../storage'; | ||||||
| @@ -153,6 +155,7 @@ describe(AssetService.name, () => { | |||||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; |   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||||
|   let jobMock: jest.Mocked<IJobRepository>; |   let jobMock: jest.Mocked<IJobRepository>; | ||||||
|   let storageMock: jest.Mocked<IStorageRepository>; |   let storageMock: jest.Mocked<IStorageRepository>; | ||||||
|  |   let communicationMock: jest.Mocked<ICommunicationRepository>; | ||||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; |   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||||
|  |  | ||||||
|   it('should work', () => { |   it('should work', () => { | ||||||
| @@ -162,11 +165,12 @@ describe(AssetService.name, () => { | |||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     accessMock = newAccessRepositoryMock(); |     accessMock = newAccessRepositoryMock(); | ||||||
|     assetMock = newAssetRepositoryMock(); |     assetMock = newAssetRepositoryMock(); | ||||||
|  |     communicationMock = newCommunicationRepositoryMock(); | ||||||
|     cryptoMock = newCryptoRepositoryMock(); |     cryptoMock = newCryptoRepositoryMock(); | ||||||
|     jobMock = newJobRepositoryMock(); |     jobMock = newJobRepositoryMock(); | ||||||
|     storageMock = newStorageRepositoryMock(); |     storageMock = newStorageRepositoryMock(); | ||||||
|     configMock = newSystemConfigRepositoryMock(); |     configMock = newSystemConfigRepositoryMock(); | ||||||
|     sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock); |     sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock); | ||||||
|  |  | ||||||
|     when(assetMock.getById) |     when(assetMock.getById) | ||||||
|       .calledWith(assetStub.livePhotoStillAsset.id) |       .calledWith(assetStub.livePhotoStillAsset.id) | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ import { extname } from 'path'; | |||||||
| import sanitize from 'sanitize-filename'; | import sanitize from 'sanitize-filename'; | ||||||
| import { AccessCore, IAccessRepository, Permission } from '../access'; | import { AccessCore, IAccessRepository, Permission } from '../access'; | ||||||
| import { AuthUserDto } from '../auth'; | import { AuthUserDto } from '../auth'; | ||||||
|  | import { CommunicationEvent, ICommunicationRepository } from '../communication'; | ||||||
| import { ICryptoRepository } from '../crypto'; | import { ICryptoRepository } from '../crypto'; | ||||||
| import { mimeTypes } from '../domain.constant'; | import { mimeTypes } from '../domain.constant'; | ||||||
| import { HumanReadableSize, usePagination } from '../domain.util'; | import { HumanReadableSize, usePagination } from '../domain.util'; | ||||||
| @@ -72,6 +73,7 @@ export class AssetService { | |||||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, |     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, |     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||||
|     @Inject(IStorageRepository) private storageRepository: IStorageRepository, |     @Inject(IStorageRepository) private storageRepository: IStorageRepository, | ||||||
|  |     @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, | ||||||
|   ) { |   ) { | ||||||
|     this.access = new AccessCore(accessRepository); |     this.access = new AccessCore(accessRepository); | ||||||
|     this.storageCore = new StorageCore(storageRepository); |     this.storageCore = new StorageCore(storageRepository); | ||||||
| @@ -362,6 +364,7 @@ export class AssetService { | |||||||
|  |  | ||||||
|     await this.assetRepository.remove(asset); |     await this.assetRepository.remove(asset); | ||||||
|     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); |     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); | ||||||
|  |     this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id); | ||||||
|  |  | ||||||
|     // TODO refactor this to use cascades |     // TODO refactor this to use cascades | ||||||
|     if (asset.livePhotoVideoId) { |     if (asset.livePhotoVideoId) { | ||||||
| @@ -392,6 +395,7 @@ export class AssetService { | |||||||
|     } else { |     } else { | ||||||
|       await this.assetRepository.softDeleteAll(ids); |       await this.assetRepository.softDeleteAll(ids); | ||||||
|       await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids } }); |       await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids } }); | ||||||
|  |       this.communicationRepository.send(CommunicationEvent.ASSET_TRASH, authUser.id, ids); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,6 +2,10 @@ export const ICommunicationRepository = 'ICommunicationRepository'; | |||||||
|  |  | ||||||
| export enum CommunicationEvent { | export enum CommunicationEvent { | ||||||
|   UPLOAD_SUCCESS = 'on_upload_success', |   UPLOAD_SUCCESS = 'on_upload_success', | ||||||
|  |   ASSET_DELETE = 'on_asset_delete', | ||||||
|  |   ASSET_TRASH = 'on_asset_trash', | ||||||
|  |   PERSON_THUMBNAIL = 'on_person_thumbnail', | ||||||
|  |   SERVER_VERSION = 'on_server_version', | ||||||
|   CONFIG_UPDATE = 'on_config_update', |   CONFIG_UPDATE = 'on_config_update', | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,10 +6,12 @@ import { | |||||||
|   newAssetRepositoryMock, |   newAssetRepositoryMock, | ||||||
|   newCommunicationRepositoryMock, |   newCommunicationRepositoryMock, | ||||||
|   newJobRepositoryMock, |   newJobRepositoryMock, | ||||||
|  |   newPersonRepositoryMock, | ||||||
|   newSystemConfigRepositoryMock, |   newSystemConfigRepositoryMock, | ||||||
| } from '@test'; | } from '@test'; | ||||||
| import { IAssetRepository } from '../asset'; | import { IAssetRepository } from '../asset'; | ||||||
| import { ICommunicationRepository } from '../communication'; | import { ICommunicationRepository } from '../communication'; | ||||||
|  | import { IPersonRepository } from '../person'; | ||||||
| import { ISystemConfigRepository } from '../system-config'; | import { ISystemConfigRepository } from '../system-config'; | ||||||
| import { SystemConfigCore } from '../system-config/system-config.core'; | import { SystemConfigCore } from '../system-config/system-config.core'; | ||||||
| import { JobCommand, JobName, QueueName } from './job.constants'; | import { JobCommand, JobName, QueueName } from './job.constants'; | ||||||
| @@ -30,13 +32,15 @@ describe(JobService.name, () => { | |||||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; |   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||||
|   let communicationMock: jest.Mocked<ICommunicationRepository>; |   let communicationMock: jest.Mocked<ICommunicationRepository>; | ||||||
|   let jobMock: jest.Mocked<IJobRepository>; |   let jobMock: jest.Mocked<IJobRepository>; | ||||||
|  |   let personMock: jest.Mocked<IPersonRepository>; | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     assetMock = newAssetRepositoryMock(); |     assetMock = newAssetRepositoryMock(); | ||||||
|     configMock = newSystemConfigRepositoryMock(); |     configMock = newSystemConfigRepositoryMock(); | ||||||
|     communicationMock = newCommunicationRepositoryMock(); |     communicationMock = newCommunicationRepositoryMock(); | ||||||
|     jobMock = newJobRepositoryMock(); |     jobMock = newJobRepositoryMock(); | ||||||
|     sut = new JobService(assetMock, communicationMock, jobMock, configMock); |     personMock = newPersonRepositoryMock(); | ||||||
|  |     sut = new JobService(assetMock, communicationMock, jobMock, configMock, personMock); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   it('should work', () => { |   it('should work', () => { | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import { AssetType } from '@app/infra/entities'; | |||||||
| import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; | import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; | ||||||
| import { IAssetRepository, mapAsset } from '../asset'; | import { IAssetRepository, mapAsset } from '../asset'; | ||||||
| import { CommunicationEvent, ICommunicationRepository } from '../communication'; | import { CommunicationEvent, ICommunicationRepository } from '../communication'; | ||||||
|  | import { IPersonRepository } from '../person'; | ||||||
| import { FeatureFlag, ISystemConfigRepository } from '../system-config'; | import { FeatureFlag, ISystemConfigRepository } from '../system-config'; | ||||||
| import { SystemConfigCore } from '../system-config/system-config.core'; | import { SystemConfigCore } from '../system-config/system-config.core'; | ||||||
| import { JobCommand, JobName, QueueName } from './job.constants'; | import { JobCommand, JobName, QueueName } from './job.constants'; | ||||||
| @@ -18,6 +19,7 @@ export class JobService { | |||||||
|     @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, |     @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, | ||||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, |     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, |     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||||
|  |     @Inject(IPersonRepository) private personRepository: IPersonRepository, | ||||||
|   ) { |   ) { | ||||||
|     this.configCore = new SystemConfigCore(configRepository); |     this.configCore = new SystemConfigCore(configRepository); | ||||||
|   } |   } | ||||||
| @@ -172,15 +174,20 @@ export class JobService { | |||||||
|         } |         } | ||||||
|         break; |         break; | ||||||
|  |  | ||||||
|  |       case JobName.GENERATE_PERSON_THUMBNAIL: | ||||||
|  |         const { id } = item.data; | ||||||
|  |         const person = await this.personRepository.getById(id); | ||||||
|  |         if (person) { | ||||||
|  |           this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id); | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |  | ||||||
|       case JobName.GENERATE_JPEG_THUMBNAIL: { |       case JobName.GENERATE_JPEG_THUMBNAIL: { | ||||||
|         await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data }); |         await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data }); | ||||||
|         await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data }); |         await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data }); | ||||||
|         await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data }); |         await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data }); | ||||||
|         await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data }); |         await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data }); | ||||||
|         await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data }); |         await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data }); | ||||||
|         if (item.data.source !== 'upload') { |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const [asset] = await this.assetRepository.getByIds([item.data.id]); |         const [asset] = await this.assetRepository.getByIds([item.data.id]); | ||||||
|         if (asset) { |         if (asset) { | ||||||
| @@ -189,10 +196,20 @@ export class JobService { | |||||||
|           } else if (asset.livePhotoVideoId) { |           } else if (asset.livePhotoVideoId) { | ||||||
|             await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); |             await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); | ||||||
|           } |           } | ||||||
|           this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); |  | ||||||
|         } |         } | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       case JobName.GENERATE_WEBP_THUMBNAIL: { | ||||||
|  |         if (item.data.source !== 'upload') { | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const [asset] = await this.assetRepository.getByIds([item.data.id]); | ||||||
|  |         if (asset) { | ||||||
|  |           this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET` |     // In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET` | ||||||
|   | |||||||
| @@ -1,34 +0,0 @@ | |||||||
| import { AuthService } from '@app/domain'; |  | ||||||
| import { Logger } from '@nestjs/common'; |  | ||||||
| import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; |  | ||||||
| import { Server, Socket } from 'socket.io'; |  | ||||||
|  |  | ||||||
| @WebSocketGateway({ cors: true }) |  | ||||||
| export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect { |  | ||||||
|   private logger = new Logger(CommunicationGateway.name); |  | ||||||
|  |  | ||||||
|   constructor(private authService: AuthService) {} |  | ||||||
|  |  | ||||||
|   @WebSocketServer() server!: Server; |  | ||||||
|  |  | ||||||
|   handleDisconnect(client: Socket) { |  | ||||||
|     client.leave(client.nsp.name); |  | ||||||
|     this.logger.log(`Client ${client.id} disconnected from Websocket`); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   async handleConnection(client: Socket) { |  | ||||||
|     try { |  | ||||||
|       this.logger.log(`New websocket connection: ${client.id}`); |  | ||||||
|       const user = await this.authService.validate(client.request.headers, {}); |  | ||||||
|       if (user) { |  | ||||||
|         client.join(user.id); |  | ||||||
|       } else { |  | ||||||
|         client.emit('error', 'unauthorized'); |  | ||||||
|         client.disconnect(); |  | ||||||
|       } |  | ||||||
|     } catch (e) { |  | ||||||
|       client.emit('error', 'unauthorized'); |  | ||||||
|       client.disconnect(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -27,7 +27,6 @@ import { BullModule } from '@nestjs/bullmq'; | |||||||
| import { Global, Module, Provider } from '@nestjs/common'; | import { Global, Module, Provider } from '@nestjs/common'; | ||||||
| import { ConfigModule } from '@nestjs/config'; | import { ConfigModule } from '@nestjs/config'; | ||||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | import { TypeOrmModule } from '@nestjs/typeorm'; | ||||||
| import { CommunicationGateway } from './communication.gateway'; |  | ||||||
| import { databaseConfig } from './database.config'; | import { databaseConfig } from './database.config'; | ||||||
| import { databaseEntities } from './entities'; | import { databaseEntities } from './entities'; | ||||||
| import { bullConfig, bullQueues } from './infra.config'; | import { bullConfig, bullQueues } from './infra.config'; | ||||||
| @@ -90,7 +89,7 @@ const providers: Provider[] = [ | |||||||
|     BullModule.forRoot(bullConfig), |     BullModule.forRoot(bullConfig), | ||||||
|     BullModule.registerQueue(...bullQueues), |     BullModule.registerQueue(...bullQueues), | ||||||
|   ], |   ], | ||||||
|   providers: [...providers, CommunicationGateway], |   providers: [...providers], | ||||||
|   exports: [...providers, BullModule], |   exports: [...providers, BullModule], | ||||||
| }) | }) | ||||||
| export class InfraModule {} | export class InfraModule {} | ||||||
|   | |||||||
| @@ -1,16 +1,43 @@ | |||||||
| import { CommunicationEvent } from '@app/domain'; | import { AuthService, CommunicationEvent, ICommunicationRepository, serverVersion } from '@app/domain'; | ||||||
| import { Injectable } from '@nestjs/common'; | import { Logger } from '@nestjs/common'; | ||||||
| import { CommunicationGateway } from '../communication.gateway'; | import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; | ||||||
|  | import { Server, Socket } from 'socket.io'; | ||||||
|  |  | ||||||
| @Injectable() | @WebSocketGateway({ cors: true }) | ||||||
| export class CommunicationRepository { | export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository { | ||||||
|   constructor(private ws: CommunicationGateway) {} |   private logger = new Logger(CommunicationRepository.name); | ||||||
|  |  | ||||||
|  |   constructor(private authService: AuthService) {} | ||||||
|  |  | ||||||
|  |   @WebSocketServer() server!: Server; | ||||||
|  |  | ||||||
|  |   async handleConnection(client: Socket) { | ||||||
|  |     try { | ||||||
|  |       this.logger.log(`New websocket connection: ${client.id}`); | ||||||
|  |       const user = await this.authService.validate(client.request.headers, {}); | ||||||
|  |       if (user) { | ||||||
|  |         client.join(user.id); | ||||||
|  |         this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion); | ||||||
|  |       } else { | ||||||
|  |         client.emit('error', 'unauthorized'); | ||||||
|  |         client.disconnect(); | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       client.emit('error', 'unauthorized'); | ||||||
|  |       client.disconnect(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   handleDisconnect(client: Socket) { | ||||||
|  |     client.leave(client.nsp.name); | ||||||
|  |     this.logger.log(`Client ${client.id} disconnected from Websocket`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   send(event: CommunicationEvent, userId: string, data: any) { |   send(event: CommunicationEvent, userId: string, data: any) { | ||||||
|     this.ws.server.to(userId).emit(event, JSON.stringify(data)); |     this.server.to(userId).emit(event, JSON.stringify(data)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   broadcast(event: CommunicationEvent, data: any) { |   broadcast(event: CommunicationEvent, data: any) { | ||||||
|     this.ws.server.emit(event, data); |     this.server.emit(event, data); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ | |||||||
|   import type { AssetStore } from '$lib/stores/assets.store'; |   import type { AssetStore } from '$lib/stores/assets.store'; | ||||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; |   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||||
|   import type { Viewport } from '$lib/stores/assets.store'; |   import type { Viewport } from '$lib/stores/assets.store'; | ||||||
|  |   import { flip } from 'svelte/animate'; | ||||||
|  |  | ||||||
|   export let assets: AssetResponseDto[]; |   export let assets: AssetResponseDto[]; | ||||||
|   export let bucketDate: string; |   export let bucketDate: string; | ||||||
| @@ -176,6 +177,7 @@ | |||||||
|           <div |           <div | ||||||
|             class="absolute" |             class="absolute" | ||||||
|             style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px" |             style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px" | ||||||
|  |             animate:flip={{ duration: 350 }} | ||||||
|           > |           > | ||||||
|             <Thumbnail |             <Thumbnail | ||||||
|               {asset} |               {asset} | ||||||
|   | |||||||
| @@ -44,6 +44,7 @@ | |||||||
|   onMount(async () => { |   onMount(async () => { | ||||||
|     showSkeleton = false; |     showSkeleton = false; | ||||||
|     document.addEventListener('keydown', onKeyboardPress); |     document.addEventListener('keydown', onKeyboardPress); | ||||||
|  |     assetStore.connect(); | ||||||
|     await assetStore.init(viewport); |     await assetStore.init(viewport); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -55,6 +56,8 @@ | |||||||
|     if ($showAssetViewer) { |     if ($showAssetViewer) { | ||||||
|       $showAssetViewer = false; |       $showAssetViewer = false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     assetStore.disconnect(); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const handleKeyboardPress = (event: KeyboardEvent) => { |   const handleKeyboardPress = (event: KeyboardEvent) => { | ||||||
|   | |||||||
| @@ -1,52 +1,40 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|  |   import { browser } from '$app/environment'; | ||||||
|  |   import { locale } from '$lib/stores/preferences.store'; | ||||||
|  |   import { websocketStore } from '$lib/stores/websocket'; | ||||||
|  |   import { ServerInfoResponseDto, api } from '@api'; | ||||||
|   import { onDestroy, onMount } from 'svelte'; |   import { onDestroy, onMount } from 'svelte'; | ||||||
|   import Cloud from 'svelte-material-icons/Cloud.svelte'; |   import Cloud from 'svelte-material-icons/Cloud.svelte'; | ||||||
|   import Dns from 'svelte-material-icons/Dns.svelte'; |   import Dns from 'svelte-material-icons/Dns.svelte'; | ||||||
|   import LoadingSpinner from './loading-spinner.svelte'; |  | ||||||
|   import { api, ServerInfoResponseDto } from '@api'; |  | ||||||
|   import { asByteUnitString } from '../../utils/byte-units'; |   import { asByteUnitString } from '../../utils/byte-units'; | ||||||
|   import { locale } from '$lib/stores/preferences.store'; |   import LoadingSpinner from './loading-spinner.svelte'; | ||||||
|  |  | ||||||
|  |   const { serverVersion, connected } = websocketStore; | ||||||
|  |  | ||||||
|   let isServerOk = true; |  | ||||||
|   let serverVersion = ''; |  | ||||||
|   let serverInfo: ServerInfoResponseDto; |   let serverInfo: ServerInfoResponseDto; | ||||||
|   let pingServerInterval: NodeJS.Timer; |  | ||||||
|  |   $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null; | ||||||
|  |   $: usedPercentage = Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100); | ||||||
|  |  | ||||||
|   onMount(async () => { |   onMount(async () => { | ||||||
|     try { |     await refresh(); | ||||||
|       const { data: version } = await api.serverInfoApi.getServerVersion(); |  | ||||||
|  |  | ||||||
|       serverVersion = `v${version.major}.${version.minor}.${version.patch}`; |  | ||||||
|  |  | ||||||
|       const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo(); |  | ||||||
|       serverInfo = serverInfoRes; |  | ||||||
|       getStorageUsagePercentage(); |  | ||||||
|     } catch (e) { |  | ||||||
|       console.log('Error [StatusBox] [onMount]'); |  | ||||||
|       isServerOk = false; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pingServerInterval = setInterval(async () => { |  | ||||||
|       try { |  | ||||||
|         const { data: pingReponse } = await api.serverInfoApi.pingServer(); |  | ||||||
|  |  | ||||||
|         if (pingReponse.res === 'pong') isServerOk = true; |  | ||||||
|         else isServerOk = false; |  | ||||||
|  |  | ||||||
|         const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo(); |  | ||||||
|         serverInfo = serverInfoRes; |  | ||||||
|       } catch (e) { |  | ||||||
|         console.log('Error [StatusBox] [pingServerInterval]', e); |  | ||||||
|         isServerOk = false; |  | ||||||
|       } |  | ||||||
|     }, 10000); |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   onDestroy(() => clearInterval(pingServerInterval)); |   const refresh = async () => { | ||||||
|  |     try { | ||||||
|   const getStorageUsagePercentage = () => { |       const { data } = await api.serverInfoApi.getServerInfo(); | ||||||
|     return Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100); |       serverInfo = data; | ||||||
|  |     } catch (e) { | ||||||
|  |       console.log('Error [StatusBox] [onMount]'); | ||||||
|  |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   let interval: number; | ||||||
|  |   if (browser) { | ||||||
|  |     interval = window.setInterval(() => refresh(), 10_000); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onDestroy(() => clearInterval(interval)); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <div class="dark:text-immich-dark-fg"> | <div class="dark:text-immich-dark-fg"> | ||||||
| @@ -61,7 +49,7 @@ | |||||||
|           <!-- style={`width: ${$downloadAssets[fileName]}%`} --> |           <!-- style={`width: ${$downloadAssets[fileName]}%`} --> | ||||||
|           <div |           <div | ||||||
|             class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary" |             class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary" | ||||||
|             style="width: {getStorageUsagePercentage()}%" |             style="width: {usedPercentage}%" | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|         <p class="text-xs"> |         <p class="text-xs"> | ||||||
| @@ -88,7 +76,7 @@ | |||||||
|       <div class="mt-2 flex justify-between justify-items-center"> |       <div class="mt-2 flex justify-between justify-items-center"> | ||||||
|         <p>Status</p> |         <p>Status</p> | ||||||
|  |  | ||||||
|         {#if isServerOk} |         {#if $connected} | ||||||
|           <p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p> |           <p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p> | ||||||
|         {:else} |         {:else} | ||||||
|           <p class="font-medium text-red-500">Offline</p> |           <p class="font-medium text-red-500">Offline</p> | ||||||
| @@ -97,20 +85,18 @@ | |||||||
|  |  | ||||||
|       <div class="mt-2 flex justify-between justify-items-center"> |       <div class="mt-2 flex justify-between justify-items-center"> | ||||||
|         <p>Version</p> |         <p>Version</p> | ||||||
|  |         {#if $connected && version} | ||||||
|           <a |           <a | ||||||
|             href="https://github.com/immich-app/immich/releases" |             href="https://github.com/immich-app/immich/releases" | ||||||
|             class="font-medium text-immich-primary dark:text-immich-dark-primary" |             class="font-medium text-immich-primary dark:text-immich-dark-primary" | ||||||
|             target="_blank" |             target="_blank" | ||||||
|           > |           > | ||||||
|           {serverVersion} |             {version} | ||||||
|           </a> |           </a> | ||||||
|  |         {:else} | ||||||
|  |           <p class="font-medium text-red-500">Unknown</p> | ||||||
|  |         {/if} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   <!-- <div> |  | ||||||
| 		<hr class="ml-5 my-4" /> |  | ||||||
| 	</div> |  | ||||||
| 	<button class="text-xs ml-5 underline hover:cursor-pointer text-immich-primary" on:click={() => goto('/changelog')} |  | ||||||
| 		>Changelog</button |  | ||||||
| 	> --> |  | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -36,15 +36,12 @@ | |||||||
|     in:fade={{ duration: 250 }} |     in:fade={{ duration: 250 }} | ||||||
|     out:fade={{ duration: 250 }} |     out:fade={{ duration: 250 }} | ||||||
|     on:outroend={() => { |     on:outroend={() => { | ||||||
|       const errorInfo = |  | ||||||
|         $errorCounter > 0 |  | ||||||
|           ? `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}` |  | ||||||
|           : 'Upload success'; |  | ||||||
|       const type = $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info; |  | ||||||
|  |  | ||||||
|       notificationController.show({ |       notificationController.show({ | ||||||
|         message: `${errorInfo}, refresh the page to see new upload assets`, |         message: | ||||||
|         type, |           ($errorCounter > 0 | ||||||
|  |             ? `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}` | ||||||
|  |             : 'Upload success') + ', refresh the page to see new upload assets.', | ||||||
|  |         type: $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info, | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       if ($duplicateCounter > 0) { |       if ($duplicateCounter > 0) { | ||||||
|   | |||||||
| @@ -1,6 +1,9 @@ | |||||||
| import { api, AssetApiGetTimeBucketsRequest, AssetResponseDto } from '@api'; | import { api, AssetApiGetTimeBucketsRequest, AssetResponseDto } from '@api'; | ||||||
| import { writable } from 'svelte/store'; | import { throttle } from 'lodash-es'; | ||||||
|  | import { DateTime } from 'luxon'; | ||||||
|  | import { Unsubscriber, writable } from 'svelte/store'; | ||||||
| import { handleError } from '../utils/handle-error'; | import { handleError } from '../utils/handle-error'; | ||||||
|  | import { websocketStore } from './websocket'; | ||||||
|  |  | ||||||
| export enum BucketPosition { | export enum BucketPosition { | ||||||
|   Above = 'above', |   Above = 'above', | ||||||
| @@ -34,11 +37,33 @@ export class AssetBucket { | |||||||
|   position!: BucketPosition; |   position!: BucketPosition; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const isMismatched = (option: boolean | undefined, value: boolean): boolean => | ||||||
|  |   option === undefined ? false : option !== value; | ||||||
|  |  | ||||||
| const THUMBNAIL_HEIGHT = 235; | const THUMBNAIL_HEIGHT = 235; | ||||||
|  |  | ||||||
|  | interface AddAsset { | ||||||
|  |   type: 'add'; | ||||||
|  |   value: AssetResponseDto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface DeleteAsset { | ||||||
|  |   type: 'delete'; | ||||||
|  |   value: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface TrashAsset { | ||||||
|  |   type: 'trash'; | ||||||
|  |   value: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type PendingChange = AddAsset | DeleteAsset | TrashAsset; | ||||||
|  |  | ||||||
| export class AssetStore { | export class AssetStore { | ||||||
|   private store$ = writable(this); |   private store$ = writable(this); | ||||||
|   private assetToBucket: Record<string, AssetLookup> = {}; |   private assetToBucket: Record<string, AssetLookup> = {}; | ||||||
|  |   private pendingChanges: PendingChange[] = []; | ||||||
|  |   private unsubscribers: Unsubscriber[] = []; | ||||||
|  |  | ||||||
|   initialized = false; |   initialized = false; | ||||||
|   timelineHeight = 0; |   timelineHeight = 0; | ||||||
| @@ -52,6 +77,63 @@ export class AssetStore { | |||||||
|  |  | ||||||
|   subscribe = this.store$.subscribe; |   subscribe = this.store$.subscribe; | ||||||
|  |  | ||||||
|  |   connect() { | ||||||
|  |     this.unsubscribers.push( | ||||||
|  |       websocketStore.onUploadSuccess.subscribe((value) => { | ||||||
|  |         if (value) { | ||||||
|  |           this.pendingChanges.push({ type: 'add', value }); | ||||||
|  |           this.processPendingChanges(); | ||||||
|  |         } | ||||||
|  |       }), | ||||||
|  |  | ||||||
|  |       websocketStore.onAssetTrash.subscribe((ids) => { | ||||||
|  |         console.log('onAssetTrash', ids); | ||||||
|  |         if (ids) { | ||||||
|  |           for (const id of ids) { | ||||||
|  |             this.pendingChanges.push({ type: 'trash', value: id }); | ||||||
|  |           } | ||||||
|  |           this.processPendingChanges(); | ||||||
|  |         } | ||||||
|  |       }), | ||||||
|  |  | ||||||
|  |       websocketStore.onAssetDelete.subscribe((value) => { | ||||||
|  |         if (value) { | ||||||
|  |           this.pendingChanges.push({ type: 'delete', value }); | ||||||
|  |           this.processPendingChanges(); | ||||||
|  |         } | ||||||
|  |       }), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   disconnect() { | ||||||
|  |     for (const unsubscribe of this.unsubscribers) { | ||||||
|  |       unsubscribe(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   processPendingChanges = throttle(() => { | ||||||
|  |     for (const { type, value } of this.pendingChanges) { | ||||||
|  |       switch (type) { | ||||||
|  |         case 'add': | ||||||
|  |           this.addAsset(value); | ||||||
|  |           break; | ||||||
|  |  | ||||||
|  |         case 'trash': | ||||||
|  |           if (!this.options.isTrashed) { | ||||||
|  |             this.removeAsset(value); | ||||||
|  |           } | ||||||
|  |           break; | ||||||
|  |  | ||||||
|  |         case 'delete': | ||||||
|  |           this.removeAsset(value); | ||||||
|  |           break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.pendingChanges = []; | ||||||
|  |     this.emit(true); | ||||||
|  |   }, 10_000); | ||||||
|  |  | ||||||
|   async init(viewport: Viewport) { |   async init(viewport: Viewport) { | ||||||
|     this.initialized = false; |     this.initialized = false; | ||||||
|     this.timelineHeight = 0; |     this.timelineHeight = 0; | ||||||
| @@ -168,6 +250,46 @@ export class AssetStore { | |||||||
|     return scrollTimeline ? delta : 0; |     return scrollTimeline ? delta : 0; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   private addAsset(asset: AssetResponseDto): void { | ||||||
|  |     if ( | ||||||
|  |       this.assetToBucket[asset.id] || | ||||||
|  |       this.options.userId || | ||||||
|  |       this.options.personId || | ||||||
|  |       this.options.albumId || | ||||||
|  |       isMismatched(this.options.isArchived, asset.isArchived) || | ||||||
|  |       isMismatched(this.options.isFavorite, asset.isFavorite) | ||||||
|  |     ) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const timeBucket = DateTime.fromISO(asset.fileCreatedAt).toUTC().startOf('month').toString(); | ||||||
|  |     let bucket = this.getBucketByDate(timeBucket); | ||||||
|  |  | ||||||
|  |     if (!bucket) { | ||||||
|  |       bucket = { | ||||||
|  |         bucketDate: timeBucket, | ||||||
|  |         bucketHeight: THUMBNAIL_HEIGHT, | ||||||
|  |         assets: [], | ||||||
|  |         cancelToken: null, | ||||||
|  |         position: BucketPosition.Unknown, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       this.buckets.push(bucket); | ||||||
|  |       this.buckets = this.buckets.sort((a, b) => { | ||||||
|  |         const aDate = DateTime.fromISO(a.bucketDate).toUTC(); | ||||||
|  |         const bDate = DateTime.fromISO(b.bucketDate).toUTC(); | ||||||
|  |         return bDate.diff(aDate).milliseconds; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     bucket.assets.push(asset); | ||||||
|  |     bucket.assets.sort((a, b) => { | ||||||
|  |       const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC(); | ||||||
|  |       const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); | ||||||
|  |       return bDate.diff(aDate).milliseconds; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getBucketByDate(bucketDate: string): AssetBucket | null { |   getBucketByDate(bucketDate: string): AssetBucket | null { | ||||||
|     return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; |     return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,10 +1,19 @@ | |||||||
| import { io, Socket } from 'socket.io-client'; | import type { AssetResponseDto, ServerVersionResponseDto } from '@api'; | ||||||
|  | import { io } from 'socket.io-client'; | ||||||
|  | import { writable } from 'svelte/store'; | ||||||
|  |  | ||||||
| let websocket: Socket; | export const websocketStore = { | ||||||
|  |   onUploadSuccess: writable<AssetResponseDto>(), | ||||||
|  |   onAssetDelete: writable<string>(), | ||||||
|  |   onAssetTrash: writable<string[]>(), | ||||||
|  |   onPersonThumbnail: writable<string>(), | ||||||
|  |   serverVersion: writable<ServerVersionResponseDto>(), | ||||||
|  |   connected: writable<boolean>(false), | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const openWebsocketConnection = () => { | export const openWebsocketConnection = () => { | ||||||
|   try { |   try { | ||||||
|     websocket = io('', { |     const websocket = io('', { | ||||||
|       path: '/api/socket.io', |       path: '/api/socket.io', | ||||||
|       transports: ['polling'], |       transports: ['polling'], | ||||||
|       reconnection: true, |       reconnection: true, | ||||||
| @@ -12,21 +21,18 @@ export const openWebsocketConnection = () => { | |||||||
|       autoConnect: true, |       autoConnect: true, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     listenToEvent(websocket); |     websocket | ||||||
|  |       .on('connect', () => websocketStore.connected.set(true)) | ||||||
|  |       .on('disconnect', () => websocketStore.connected.set(false)) | ||||||
|  |       // .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(JSON.parse(data) as AssetResponseDto)) | ||||||
|  |       .on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(JSON.parse(data) as string)) | ||||||
|  |       .on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(JSON.parse(data) as string[])) | ||||||
|  |       .on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(JSON.parse(data) as string)) | ||||||
|  |       .on('on_server_version', (data) => websocketStore.serverVersion.set(JSON.parse(data) as ServerVersionResponseDto)) | ||||||
|  |       .on('error', (e) => console.log('Websocket Error', e)); | ||||||
|  |  | ||||||
|  |     return () => websocket?.close(); | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     console.log('Cannot connect to websocket ', e); |     console.log('Cannot connect to websocket ', e); | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const listenToEvent = (socket: Socket) => { |  | ||||||
|   //TODO: if we are not using this, we should probably remove it? |  | ||||||
|   socket.on('on_upload_success', () => undefined); |  | ||||||
|  |  | ||||||
|   socket.on('error', (e) => { |  | ||||||
|     console.log('Websocket Error', e); |  | ||||||
|   }); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const closeWebsocketConnection = () => { |  | ||||||
|   websocket?.close(); |  | ||||||
| }; |  | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ | |||||||
|   import { AppRoute } from '$lib/constants'; |   import { AppRoute } from '$lib/constants'; | ||||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; |   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||||
|   import { AssetStore } from '$lib/stores/assets.store'; |   import { AssetStore } from '$lib/stores/assets.store'; | ||||||
|  |   import { websocketStore } from '$lib/stores/websocket'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { AssetResponseDto, PersonResponseDto, TimeBucketSize, api } from '@api'; |   import { AssetResponseDto, PersonResponseDto, TimeBucketSize, api } from '@api'; | ||||||
|   import { onMount } from 'svelte'; |   import { onMount } from 'svelte'; | ||||||
| @@ -54,6 +55,7 @@ | |||||||
|   }); |   }); | ||||||
|   const assetInteractionStore = createAssetInteractionStore(); |   const assetInteractionStore = createAssetInteractionStore(); | ||||||
|   const { selectedAssets, isMultiSelectState } = assetInteractionStore; |   const { selectedAssets, isMultiSelectState } = assetInteractionStore; | ||||||
|  |   const { onPersonThumbnail } = websocketStore; | ||||||
|  |  | ||||||
|   let viewMode: ViewMode = ViewMode.VIEW_ASSETS; |   let viewMode: ViewMode = ViewMode.VIEW_ASSETS; | ||||||
|   let isEditingName = false; |   let isEditingName = false; | ||||||
| @@ -65,12 +67,15 @@ | |||||||
|   let potentialMergePeople: PersonResponseDto[] = []; |   let potentialMergePeople: PersonResponseDto[] = []; | ||||||
|  |  | ||||||
|   let personName = ''; |   let personName = ''; | ||||||
|  |   let thumbnailData = api.getPeopleThumbnailUrl(data.person.id); | ||||||
|  |  | ||||||
|   let name: string = data.person.name; |   let name: string = data.person.name; | ||||||
|   let suggestedPeople: PersonResponseDto[] = []; |   let suggestedPeople: PersonResponseDto[] = []; | ||||||
|  |  | ||||||
|   $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived); |   $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived); | ||||||
|   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); |   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); | ||||||
|  |   $: $onPersonThumbnail === data.person.id && | ||||||
|  |     (thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`); | ||||||
|  |  | ||||||
|   $: { |   $: { | ||||||
|     suggestedPeople = !name |     suggestedPeople = !name | ||||||
| @@ -141,14 +146,8 @@ | |||||||
|  |  | ||||||
|     await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { featureFaceAssetId: asset.id } }); |     await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { featureFaceAssetId: asset.id } }); | ||||||
|  |  | ||||||
|     // TODO: Replace by Websocket in the future |     notificationController.show({ message: 'Feature photo updated', type: NotificationType.Info }); | ||||||
|     notificationController.show({ |  | ||||||
|       message: 'Feature photo updated, refresh page to see changes', |  | ||||||
|       type: NotificationType.Info, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     assetInteractionStore.clearMultiselect(); |     assetInteractionStore.clearMultiselect(); | ||||||
|     // scroll to top |  | ||||||
|  |  | ||||||
|     viewMode = ViewMode.VIEW_ASSETS; |     viewMode = ViewMode.VIEW_ASSETS; | ||||||
|   }; |   }; | ||||||
| @@ -376,7 +375,7 @@ | |||||||
|                 <ImageThumbnail |                 <ImageThumbnail | ||||||
|                   circle |                   circle | ||||||
|                   shadow |                   shadow | ||||||
|                   url={api.getPeopleThumbnailUrl(data.person.id)} |                   url={thumbnailData} | ||||||
|                   altText={data.person.name} |                   altText={data.person.name} | ||||||
|                   widthStyle="3.375rem" |                   widthStyle="3.375rem" | ||||||
|                   heightStyle="3.375rem" |                   heightStyle="3.375rem" | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ | |||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; |   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; | ||||||
|   import { api } from '@api'; |   import { api } from '@api'; | ||||||
|  |   import { openWebsocketConnection } from '$lib/stores/websocket'; | ||||||
|  |  | ||||||
|   let showNavigationLoadingBar = false; |   let showNavigationLoadingBar = false; | ||||||
|   export let data: LayoutData; |   export let data: LayoutData; | ||||||
| @@ -36,6 +37,8 @@ | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   onMount(async () => { |   onMount(async () => { | ||||||
|  |     openWebsocketConnection(); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       await loadConfig(); |       await loadConfig(); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user