Sfoglia il codice sorgente

Feature - Add upload functionality on Web (#231)

* Added file selector

* Extract metadata to upload files to the web

* Added request for uploading

* Generate jpeg/Webp thumbnail for asset uploaded without thumbnail data

* Added generating thumbnail for video and WebSocket broadcast after thumbnail is generated

* Added video length extraction

* Added Uploading Panel

* Added upload progress store and styling the uploaded asset

* Added condition to only show upload panel when there is upload in progress

* Remove asset from the upload list after successfully uploading

* Added WebSocket to listen to upload event on the web

* Added mechanism to check for existing assets before uploading on the web

* Added test workflow

* Update readme
Alex 3 anni fa
parent
commit
1e3464fe47
33 ha cambiato i file con 860 aggiunte e 221 eliminazioni
  1. 17 0
      .github/workflows/test.yml
  2. 24 27
      README.md
  3. BIN
      design/dashboard_photos.jpeg
  4. BIN
      design/web-admin.jpeg
  5. BIN
      design/web-detail.jpeg
  6. BIN
      design/web-home.jpeg
  7. 1 0
      docker/docker-compose.dev.yml
  8. 21 2
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  9. 1 1
      server/apps/immich/src/api-v1/asset/asset.module.ts
  10. 13 1
      server/apps/immich/src/api-v1/asset/asset.service.ts
  11. 25 18
      server/apps/immich/src/api-v1/communication/communication.gateway.ts
  12. 3 0
      server/apps/microservices/src/main.ts
  13. 4 0
      server/apps/microservices/src/microservices.module.ts
  14. 6 0
      server/apps/microservices/src/processors/asset-uploaded.processor.ts
  15. 25 0
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  16. 64 4
      server/apps/microservices/src/processors/thumbnail.processor.ts
  17. 1 1
      server/apps/microservices/src/processors/video-transcode.processor.ts
  18. 19 0
      server/package-lock.json
  19. 1 0
      server/package.json
  20. 166 129
      web/package-lock.json
  21. 3 0
      web/package.json
  22. 1 1
      web/src/lib/components/asset-viewer/detail-panel.svelte
  23. 18 7
      web/src/lib/components/shared/navigation-bar.svelte
  24. 191 0
      web/src/lib/components/shared/upload-panel.svelte
  25. 6 0
      web/src/lib/models/upload-asset.ts
  26. 17 21
      web/src/lib/stores/assets.ts
  27. 45 0
      web/src/lib/stores/upload.ts
  28. 30 0
      web/src/lib/stores/websocket.ts
  29. 113 0
      web/src/lib/utils/file-uploader.ts
  30. 3 3
      web/src/routes/__layout.svelte
  31. 1 0
      web/src/routes/index.svelte
  32. 37 6
      web/src/routes/photos/index.svelte
  33. 4 0
      web/tailwind.config.cjs

+ 17 - 0
.github/workflows/test.yml

@@ -0,0 +1,17 @@
+name: Test
+on:
+  pull_request:
+  push: { branches: master }
+
+jobs:
+  test-server-e2e:
+    name: Run test suite
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2
+
+      - name: Run Immich Server 2E2 Test
+        run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich_server_test

+ 24 - 27
README.md

@@ -8,7 +8,7 @@
     <img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
     <img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
   </a>
   </a>
   <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
   <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
-    <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" />
+    <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
   </a>
   </a>
   <a href="https://discord.gg/rxnyVTXGbM">
   <a href="https://discord.gg/rxnyVTXGbM">
     <img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
     <img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
@@ -25,7 +25,7 @@
 
 
 # Immich
 # Immich
 
 
-Self-hosted photo and video backup solution directly from your mobile phone.
+**High performance self-hosted photo and video backup solution.**
 
 
 ![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
 ![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
 
 
@@ -33,7 +33,7 @@ Loading ~4000 images/videos
 
 
 ## Screenshots
 ## Screenshots
 
 
-### Mobile client
+### Mobile
 <p align="left">
 <p align="left">
   <img src="design/login-screen.png" width="150" title="Login With Custom URL">
   <img src="design/login-screen.png" width="150" title="Login With Custom URL">
   <img src="design/backup-screen.png" width="150" title="Backup Setting Info">
   <img src="design/backup-screen.png" width="150" title="Backup Setting Info">
@@ -44,9 +44,10 @@ Loading ~4000 images/videos
   <img src="design/nsc6.png" width="150" title="EXIF Info">
   <img src="design/nsc6.png" width="150" title="EXIF Info">
 </p>
 </p>
 
 
-### Web client
-<p align="center">
-  <img src="design/dashboard_photos.jpeg" width="100%" title="Home Dashboard">
+### Web
+<p align="left">
+  <img src="design/web-home.jpeg"  width="49%" title="Home Dashboard">
+  <img src="design/web-detail.jpeg" width="49%" title="Detail">
 </p>
 </p>
 
 
 # Note
 # Note
@@ -55,26 +56,22 @@ Loading ~4000 images/videos
 
 
 This project is under heavy development, there will be continuous functions, features and api changes.
 This project is under heavy development, there will be continuous functions, features and api changes.
 
 
-# Features
-
-- Upload and view assets (videos/images).
-- Auto Backup.
-- Download asset to local device.
-- Multi-user supported.
-- Quick navigation with drag scroll bar.
-- Support HEIC/HEIF Backup.
-- Extract and display EXIF info.
-- Real-time render from multi-device upload event.
-- Image Tagging/Classification based on ImageNet dataset
-- Object detection based on COCO SSD.
-- Search assets based on tags and exif data (lens, make, model, orientation)
-- [Optional] Reverse geocoding using Mapbox (Generous free-tier of 100,000 search/month)
-- Show asset's location information on map (OpenStreetMap).
-- Show curated places on the search page
-- Show curated objects on the search page
-- Shared album with users on the same server
-- Selective backup - albums can be included and excluded during the backup process.
-- Web interface is available for administrative tasks (creating new users) and viewing assets on the server - additional features are coming.
+# Features 
+
+|  | Mobile | Web |
+| - | - | - |
+| Upload and view videos and photos | Yes | Yes
+| Auto backup when app is opened | Yes | N/A
+| Selective album(s) for backup | Yes | N/A
+| Download photos and videos to local device | Yes | Yes
+| Multi-user support | Yes | Yes
+| Shared Albums | Yes | No
+| Quick navigation with draggable scrollbar | Yes | Yes
+| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes
+| Metadata view (EXIF, map) | Yes | Yes
+| Search by metadata, objects and image tags | Yes | No
+| Administrative functions (user management) | No | Yes
+
 
 
 # System Requirement
 # System Requirement
 
 
@@ -97,7 +94,7 @@ You can use docker compose for development and testing out the application, ther
 3. **PostgreSQL** - Main database of the application
 3. **PostgreSQL** - Main database of the application
 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
 5. **Nginx** - Load balancing and optimized file uploading.
 5. **Nginx** - Load balancing and optimized file uploading.
-6. **TensorFlow** - Object Detection and Image Classification.
+6. **TensorFlow** - Object Detection (COCO SSD) and Image Classification (ImageNet).
 
 
 ## Step 1: Populate .env file
 ## Step 1: Populate .env file
 
 

BIN
design/dashboard_photos.jpeg


BIN
design/web-admin.jpeg


BIN
design/web-detail.jpeg


BIN
design/web-home.jpeg


+ 1 - 0
docker/docker-compose.dev.yml

@@ -60,6 +60,7 @@ services:
       - NODE_ENV=development
       - NODE_ENV=development
     depends_on:
     depends_on:
       - database
       - database
+      - immich-server
     networks:
     networks:
       - immich-network
       - immich-network
 
 

+ 21 - 2
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -15,6 +15,7 @@ import {
   Delete,
   Delete,
   Logger,
   Logger,
   Patch,
   Patch,
+  HttpCode,
 } from '@nestjs/common';
 } from '@nestjs/common';
 import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
 import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
 import { AssetService } from './asset.service';
 import { AssetService } from './asset.service';
@@ -76,6 +77,10 @@ export class AssetController {
             { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
             { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
             { jobId: savedAsset.id },
             { jobId: savedAsset.id },
           );
           );
+
+          this.wsCommunicateionGateway.server
+            .to(savedAsset.userId)
+            .emit('on_upload_success', JSON.stringify(assetWithThumbnail));
         } else {
         } else {
           await this.assetUploadedQueue.add(
           await this.assetUploadedQueue.add(
             'asset-uploaded',
             'asset-uploaded',
@@ -83,8 +88,6 @@ export class AssetController {
             { jobId: savedAsset.id },
             { jobId: savedAsset.id },
           );
           );
         }
         }
-
-        this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
       } catch (e) {
       } catch (e) {
         Logger.error(`Error receiving upload file ${e}`);
         Logger.error(`Error receiving upload file ${e}`);
       }
       }
@@ -171,4 +174,20 @@ export class AssetController {
 
 
     return result;
     return result;
   }
   }
+
+  /**
+   * Check duplicated asset before uploading - for Web upload used
+   */
+  @Post('/check')
+  @HttpCode(200)
+  async checkDuplicateAsset(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string },
+  ) {
+    const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId);
+
+    return {
+      isExist: res,
+    };
+  }
 }
 }

+ 1 - 1
server/apps/immich/src/api-v1/asset/asset.module.ts

@@ -24,6 +24,6 @@ import { CommunicationModule } from '../communication/communication.module';
   ],
   ],
   controllers: [AssetController],
   controllers: [AssetController],
   providers: [AssetService, BackgroundTaskService],
   providers: [AssetService, BackgroundTaskService],
-  exports: [],
+  exports: [AssetService],
 })
 })
 export class AssetModule {}
 export class AssetModule {}

+ 13 - 1
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -1,6 +1,6 @@
 import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
 import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { IsNull, Not, Repository } from 'typeorm';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
@@ -72,6 +72,7 @@ export class AssetService {
       return await this.assetRepository.find({
       return await this.assetRepository.find({
         where: {
         where: {
           userId: authUser.id,
           userId: authUser.id,
+          resizePath: Not(IsNull()),
         },
         },
         relations: ['exifInfo'],
         relations: ['exifInfo'],
         order: {
         order: {
@@ -381,4 +382,15 @@ export class AssetService {
       [authUser.id],
       [authUser.id],
     );
     );
   }
   }
+
+  async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
+    const res = await this.assetRepository.findOne({
+      where: {
+        deviceAssetId,
+        userId: authUser.id,
+      },
+    });
+
+    return res ? true : false;
+  }
 }
 }

+ 25 - 18
server/apps/immich/src/api-v1/communication/communication.gateway.ts

@@ -6,8 +6,9 @@ import { Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
 import { UserEntity } from '@app/database/entities/user.entity';
 import { UserEntity } from '@app/database/entities/user.entity';
 import { Repository } from 'typeorm';
 import { Repository } from 'typeorm';
+import { query } from 'express';
 
 
-@WebSocketGateway()
+@WebSocketGateway({ cors: true })
 export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
 export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
   constructor(
   constructor(
     private immichJwtService: ImmichJwtService,
     private immichJwtService: ImmichJwtService,
@@ -21,27 +22,33 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
   handleDisconnect(client: Socket) {
   handleDisconnect(client: Socket) {
     client.leave(client.nsp.name);
     client.leave(client.nsp.name);
 
 
-    Logger.log(`Client ${client.id} disconnected`);
+    Logger.log(`Client ${client.id} disconnected from Websocket`, 'WebsocketConnectionEvent');
   }
   }
 
 
   async handleConnection(client: Socket, ...args: any[]) {
   async handleConnection(client: Socket, ...args: any[]) {
-    Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection');
-    const accessToken = client.handshake.headers.authorization.split(' ')[1];
-    const res = await this.immichJwtService.validateToken(accessToken);
-
-    if (!res.status) {
-      client.emit('error', 'unauthorized');
-      client.disconnect();
-      return;
-    }
+    try {
+      Logger.log(`New websocket connection: ${client.id}`, 'WebsocketConnectionEvent');
 
 
-    const user = await this.userRepository.findOne({ where: { id: res.userId } });
-    if (!user) {
-      client.emit('error', 'unauthorized');
-      client.disconnect();
-      return;
-    }
+      const accessToken = client.handshake.headers.authorization.split(' ')[1];
+
+      const res = await this.immichJwtService.validateToken(accessToken);
 
 
-    client.join(user.id);
+      if (!res.status) {
+        client.emit('error', 'unauthorized');
+        client.disconnect();
+        return;
+      }
+
+      const user = await this.userRepository.findOne({ where: { id: res.userId } });
+      if (!user) {
+        client.emit('error', 'unauthorized');
+        client.disconnect();
+        return;
+      }
+
+      client.join(user.id);
+    } catch (e) {
+      // Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection');
+    }
   }
   }
 }
 }

+ 3 - 0
server/apps/microservices/src/main.ts

@@ -1,10 +1,13 @@
 import { Logger } from '@nestjs/common';
 import { Logger } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { NestFactory } from '@nestjs/core';
+import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
 import { MicroservicesModule } from './microservices.module';
 import { MicroservicesModule } from './microservices.module';
 
 
 async function bootstrap() {
 async function bootstrap() {
   const app = await NestFactory.create(MicroservicesModule);
   const app = await NestFactory.create(MicroservicesModule);
 
 
+  app.useWebSocketAdapter(new RedisIoAdapter(app));
+
   await app.listen(3000, () => {
   await app.listen(3000, () => {
     if (process.env.NODE_ENV == 'development') {
     if (process.env.NODE_ENV == 'development') {
       Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice');
       Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice');

+ 4 - 0
server/apps/microservices/src/microservices.module.ts

@@ -11,6 +11,9 @@ import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
 import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
 import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
 import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
 import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
 import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
 import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
+import { AssetModule } from '../../immich/src/api-v1/asset/asset.module';
+import { CommunicationGateway } from '../../immich/src/api-v1/communication/communication.gateway';
+import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
 
 
 @Module({
 @Module({
   imports: [
   imports: [
@@ -56,6 +59,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         removeOnFail: false,
         removeOnFail: false,
       },
       },
     }),
     }),
+    CommunicationModule,
   ],
   ],
   controllers: [],
   controllers: [],
   providers: [
   providers: [

+ 6 - 0
server/apps/microservices/src/processors/asset-uploaded.processor.ts

@@ -46,6 +46,7 @@ export class AssetUploadedProcessor {
       await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
       await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
     } else {
     } else {
       // Generate Thumbnail -> Then generate webp, tag image and detect object
       // Generate Thumbnail -> Then generate webp, tag image and detect object
+      await this.thumbnailGeneratorQueue.add('generate-jpeg-thumbnail', { asset }, { jobId: randomUUID() });
     }
     }
 
 
     // Video Conversion
     // Video Conversion
@@ -63,5 +64,10 @@ export class AssetUploadedProcessor {
         { jobId: randomUUID() },
         { jobId: randomUUID() },
       );
       );
     }
     }
+
+    // Extract video duration if uploaded from the web
+    if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') {
+      await this.metadataExtractionQueue.add('extract-video-length', { asset }, { jobId: randomUUID() });
+    }
   }
   }
 }
 }

+ 25 - 0
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -12,6 +12,8 @@ import { Logger } from '@nestjs/common';
 import axios from 'axios';
 import axios from 'axios';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { ConfigService } from '@nestjs/config';
 import { ConfigService } from '@nestjs/config';
+import ffmpeg from 'fluent-ffmpeg';
+// import moment from 'moment';
 
 
 @Processor('metadata-extraction-queue')
 @Processor('metadata-extraction-queue')
 export class MetadataExtractionProcessor {
 export class MetadataExtractionProcessor {
@@ -129,4 +131,27 @@ export class MetadataExtractionProcessor {
       Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`);
       Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`);
     }
     }
   }
   }
+
+  @Process({ name: 'extract-video-length', concurrency: 2 })
+  async extractVideoLength(job: Job) {
+    const { asset }: { asset: AssetEntity } = job.data;
+
+    ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
+      if (!err) {
+        if (data.format.duration) {
+          const videoDurationInSecond = parseInt(data.format.duration.toString(), 0);
+
+          const hours = Math.floor(videoDurationInSecond / 3600);
+          const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
+          const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
+
+          const durationString = `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
+            seconds < 10 ? '0' + seconds.toString() : seconds
+          }.000000`;
+
+          await this.assetRepository.update({ id: asset.id }, { duration: durationString });
+        }
+      }
+    });
+  }
 }
 }

+ 64 - 4
server/apps/microservices/src/processors/thumbnail.processor.ts

@@ -1,22 +1,82 @@
-import { Process, Processor } from '@nestjs/bull';
-import { Job } from 'bull';
-import { AssetEntity } from '@app/database/entities/asset.entity';
+import { InjectQueue, Process, Processor } from '@nestjs/bull';
+import { Job, Queue } from 'bull';
+import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 import { Repository } from 'typeorm/repository/Repository';
 import { Repository } from 'typeorm/repository/Repository';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
 import sharp from 'sharp';
 import sharp from 'sharp';
+import { existsSync, mkdirSync } from 'node:fs';
+import { randomUUID } from 'node:crypto';
+import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
+import ffmpeg from 'fluent-ffmpeg';
+import { Logger } from '@nestjs/common';
 
 
 @Processor('thumbnail-generator-queue')
 @Processor('thumbnail-generator-queue')
 export class ThumbnailGeneratorProcessor {
 export class ThumbnailGeneratorProcessor {
   constructor(
   constructor(
     @InjectRepository(AssetEntity)
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
     private assetRepository: Repository<AssetEntity>,
+
+    @InjectQueue('thumbnail-generator-queue')
+    private thumbnailGeneratorQueue: Queue,
+
+    private wsCommunicateionGateway: CommunicationGateway,
   ) {}
   ) {}
 
 
   @Process('generate-jpeg-thumbnail')
   @Process('generate-jpeg-thumbnail')
   async generateJPEGThumbnail(job: Job) {
   async generateJPEGThumbnail(job: Job) {
     const { asset }: { asset: AssetEntity } = job.data;
     const { asset }: { asset: AssetEntity } = job.data;
 
 
-    console.log(asset);
+    const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
+
+    if (!existsSync(resizePath)) {
+      mkdirSync(resizePath, { recursive: true });
+    }
+
+    const temp = asset.originalPath.split('/');
+    const originalFilename = temp[temp.length - 1].split('.')[0];
+    const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
+
+    if (asset.type == AssetType.IMAGE) {
+      sharp(asset.originalPath)
+        .resize(1440, 2560, { fit: 'inside' })
+        .jpeg()
+        .toFile(jpegThumbnailPath, async (err, info) => {
+          if (!err) {
+            await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
+
+            // Update resize path to send to generate webp queue
+            asset.resizePath = jpegThumbnailPath;
+
+            await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
+            this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
+          }
+        });
+    }
+
+    if (asset.type == AssetType.VIDEO) {
+      ffmpeg(asset.originalPath)
+        .outputOptions(['-ss 00:00:01.000', '-frames:v 1'])
+        .output(jpegThumbnailPath)
+        .on('start', () => {
+          Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
+        })
+        .on('error', (error, b, c) => {
+          Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
+          // reject();
+        })
+        .on('end', async () => {
+          Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
+          await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
+
+          // Update resize path to send to generate webp queue
+          asset.resizePath = jpegThumbnailPath;
+
+          await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
+
+          this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
+        })
+        .run();
+    }
   }
   }
 
 
   @Process({ name: 'generate-webp-thumbnail', concurrency: 2 })
   @Process({ name: 'generate-webp-thumbnail', concurrency: 2 })

+ 1 - 1
server/apps/microservices/src/processors/video-transcode.processor.ts

@@ -42,7 +42,7 @@ export class VideoTranscodeProcessor {
         .outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2'])
         .outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2'])
         .output(savedEncodedPath)
         .output(savedEncodedPath)
         .on('start', () => {
         .on('start', () => {
-          Logger.log('Start Converting', 'mp4Conversion');
+          Logger.log('Start Converting Video', 'mp4Conversion');
         })
         })
         .on('error', (error, b, c) => {
         .on('error', (error, b, c) => {
           Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion');
           Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion');

+ 19 - 0
server/package-lock.json

@@ -55,6 +55,7 @@
         "@types/bull": "^3.15.7",
         "@types/bull": "^3.15.7",
         "@types/cron": "^2.0.0",
         "@types/cron": "^2.0.0",
         "@types/express": "^4.17.13",
         "@types/express": "^4.17.13",
+        "@types/fluent-ffmpeg": "^2.1.20",
         "@types/imagemin": "^8.0.0",
         "@types/imagemin": "^8.0.0",
         "@types/jest": "27.0.2",
         "@types/jest": "27.0.2",
         "@types/lodash": "^4.14.178",
         "@types/lodash": "^4.14.178",
@@ -2195,6 +2196,15 @@
         "@types/range-parser": "*"
         "@types/range-parser": "*"
       }
       }
     },
     },
+    "node_modules/@types/fluent-ffmpeg": {
+      "version": "2.1.20",
+      "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz",
+      "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/graceful-fs": {
     "node_modules/@types/graceful-fs": {
       "version": "4.1.5",
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@@ -12803,6 +12813,15 @@
         "@types/range-parser": "*"
         "@types/range-parser": "*"
       }
       }
     },
     },
+    "@types/fluent-ffmpeg": {
+      "version": "2.1.20",
+      "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz",
+      "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/graceful-fs": {
     "@types/graceful-fs": {
       "version": "4.1.5",
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",

+ 1 - 0
server/package.json

@@ -68,6 +68,7 @@
     "@types/bull": "^3.15.7",
     "@types/bull": "^3.15.7",
     "@types/cron": "^2.0.0",
     "@types/cron": "^2.0.0",
     "@types/express": "^4.17.13",
     "@types/express": "^4.17.13",
+    "@types/fluent-ffmpeg": "^2.1.20",
     "@types/imagemin": "^8.0.0",
     "@types/imagemin": "^8.0.0",
     "@types/jest": "27.0.2",
     "@types/jest": "27.0.2",
     "@types/lodash": "^4.14.178",
     "@types/lodash": "^4.14.178",

+ 166 - 129
web/package-lock.json

@@ -10,11 +10,12 @@
 			"dependencies": {
 			"dependencies": {
 				"axios": "^0.27.2",
 				"axios": "^0.27.2",
 				"cookie": "^0.4.2",
 				"cookie": "^0.4.2",
+				"exifr": "^7.1.3",
 				"leaflet": "^1.8.0",
 				"leaflet": "^1.8.0",
 				"lodash": "^4.17.21",
 				"lodash": "^4.17.21",
 				"lodash-es": "^4.17.21",
 				"lodash-es": "^4.17.21",
-				"markdown-it": "^13.0.1",
 				"moment": "^2.29.3",
 				"moment": "^2.29.3",
+				"socket.io-client": "^4.5.1",
 				"svelte-material-icons": "^2.0.2"
 				"svelte-material-icons": "^2.0.2"
 			},
 			},
 			"devDependencies": {
 			"devDependencies": {
@@ -28,7 +29,7 @@
 				"@types/leaflet": "^1.7.10",
 				"@types/leaflet": "^1.7.10",
 				"@types/lodash": "^4.14.182",
 				"@types/lodash": "^4.14.182",
 				"@types/lodash-es": "^4.17.6",
 				"@types/lodash-es": "^4.17.6",
-				"@types/markdown-it": "^12.2.3",
+				"@types/socket.io-client": "^3.0.0",
 				"@typescript-eslint/eslint-plugin": "^5.10.1",
 				"@typescript-eslint/eslint-plugin": "^5.10.1",
 				"@typescript-eslint/parser": "^5.10.1",
 				"@typescript-eslint/parser": "^5.10.1",
 				"autoprefixer": "^10.4.7",
 				"autoprefixer": "^10.4.7",
@@ -140,6 +141,11 @@
 				"node": ">= 8.0.0"
 				"node": ">= 8.0.0"
 			}
 			}
 		},
 		},
+		"node_modules/@socket.io/component-emitter": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
+			"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
+		},
 		"node_modules/@sveltejs/adapter-auto": {
 		"node_modules/@sveltejs/adapter-auto": {
 			"version": "1.0.0-next.40",
 			"version": "1.0.0-next.40",
 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz",
 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz",
@@ -293,12 +299,6 @@
 				"@types/geojson": "*"
 				"@types/geojson": "*"
 			}
 			}
 		},
 		},
-		"node_modules/@types/linkify-it": {
-			"version": "3.0.2",
-			"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz",
-			"integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==",
-			"dev": true
-		},
 		"node_modules/@types/lodash": {
 		"node_modules/@types/lodash": {
 			"version": "4.14.182",
 			"version": "4.14.182",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -314,22 +314,6 @@
 				"@types/lodash": "*"
 				"@types/lodash": "*"
 			}
 			}
 		},
 		},
-		"node_modules/@types/markdown-it": {
-			"version": "12.2.3",
-			"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
-			"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
-			"dev": true,
-			"dependencies": {
-				"@types/linkify-it": "*",
-				"@types/mdurl": "*"
-			}
-		},
-		"node_modules/@types/mdurl": {
-			"version": "1.0.2",
-			"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
-			"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==",
-			"dev": true
-		},
 		"node_modules/@types/node": {
 		"node_modules/@types/node": {
 			"version": "17.0.32",
 			"version": "17.0.32",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
@@ -351,6 +335,16 @@
 				"@types/node": "*"
 				"@types/node": "*"
 			}
 			}
 		},
 		},
+		"node_modules/@types/socket.io-client": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz",
+			"integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==",
+			"deprecated": "This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed.",
+			"dev": true,
+			"dependencies": {
+				"socket.io-client": "*"
+			}
+		},
 		"node_modules/@typescript-eslint/eslint-plugin": {
 		"node_modules/@typescript-eslint/eslint-plugin": {
 			"version": "5.23.0",
 			"version": "5.23.0",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz",
@@ -650,7 +644,8 @@
 		"node_modules/argparse": {
 		"node_modules/argparse": {
 			"version": "2.0.1",
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+			"dev": true
 		},
 		},
 		"node_modules/array-union": {
 		"node_modules/array-union": {
 			"version": "2.1.0",
 			"version": "2.1.0",
@@ -933,7 +928,6 @@
 			"version": "4.3.4",
 			"version": "4.3.4",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-			"dev": true,
 			"dependencies": {
 			"dependencies": {
 				"ms": "2.1.2"
 				"ms": "2.1.2"
 			},
 			},
@@ -1043,15 +1037,24 @@
 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==",
 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==",
 			"dev": true
 			"dev": true
 		},
 		},
-		"node_modules/entities": {
-			"version": "3.0.1",
-			"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
-			"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
+		"node_modules/engine.io-client": {
+			"version": "6.2.2",
+			"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz",
+			"integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==",
+			"dependencies": {
+				"@socket.io/component-emitter": "~3.1.0",
+				"debug": "~4.3.1",
+				"engine.io-parser": "~5.0.3",
+				"ws": "~8.2.3",
+				"xmlhttprequest-ssl": "~2.0.0"
+			}
+		},
+		"node_modules/engine.io-parser": {
+			"version": "5.0.4",
+			"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz",
+			"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==",
 			"engines": {
 			"engines": {
-				"node": ">=0.12"
-			},
-			"funding": {
-				"url": "https://github.com/fb55/entities?sponsor=1"
+				"node": ">=10.0.0"
 			}
 			}
 		},
 		},
 		"node_modules/es6-promise": {
 		"node_modules/es6-promise": {
@@ -1673,6 +1676,11 @@
 				"node": ">=0.10.0"
 				"node": ">=0.10.0"
 			}
 			}
 		},
 		},
+		"node_modules/exifr": {
+			"version": "7.1.3",
+			"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+			"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
+		},
 		"node_modules/fast-deep-equal": {
 		"node_modules/fast-deep-equal": {
 			"version": "3.1.3",
 			"version": "3.1.3",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2112,14 +2120,6 @@
 				"node": ">=10"
 				"node": ">=10"
 			}
 			}
 		},
 		},
-		"node_modules/linkify-it": {
-			"version": "4.0.1",
-			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
-			"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
-			"dependencies": {
-				"uc.micro": "^1.0.1"
-			}
-		},
 		"node_modules/lodash": {
 		"node_modules/lodash": {
 			"version": "4.17.21",
 			"version": "4.17.21",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -2160,26 +2160,6 @@
 				"node": ">=12"
 				"node": ">=12"
 			}
 			}
 		},
 		},
-		"node_modules/markdown-it": {
-			"version": "13.0.1",
-			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz",
-			"integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==",
-			"dependencies": {
-				"argparse": "^2.0.1",
-				"entities": "~3.0.1",
-				"linkify-it": "^4.0.1",
-				"mdurl": "^1.0.1",
-				"uc.micro": "^1.0.5"
-			},
-			"bin": {
-				"markdown-it": "bin/markdown-it.js"
-			}
-		},
-		"node_modules/mdurl": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
-			"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
-		},
 		"node_modules/merge2": {
 		"node_modules/merge2": {
 			"version": "1.4.1",
 			"version": "1.4.1",
 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2289,8 +2269,7 @@
 		"node_modules/ms": {
 		"node_modules/ms": {
 			"version": "2.1.2",
 			"version": "2.1.2",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-			"dev": true
+			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
 		},
 		},
 		"node_modules/nanoid": {
 		"node_modules/nanoid": {
 			"version": "3.3.4",
 			"version": "3.3.4",
@@ -2820,6 +2799,32 @@
 				"node": ">=8"
 				"node": ">=8"
 			}
 			}
 		},
 		},
+		"node_modules/socket.io-client": {
+			"version": "4.5.1",
+			"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz",
+			"integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==",
+			"dependencies": {
+				"@socket.io/component-emitter": "~3.1.0",
+				"debug": "~4.3.2",
+				"engine.io-client": "~6.2.1",
+				"socket.io-parser": "~4.2.0"
+			},
+			"engines": {
+				"node": ">=10.0.0"
+			}
+		},
+		"node_modules/socket.io-parser": {
+			"version": "4.2.0",
+			"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.0.tgz",
+			"integrity": "sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==",
+			"dependencies": {
+				"@socket.io/component-emitter": "~3.1.0",
+				"debug": "~4.3.1"
+			},
+			"engines": {
+				"node": ">=10.0.0"
+			}
+		},
 		"node_modules/sorcery": {
 		"node_modules/sorcery": {
 			"version": "0.10.0",
 			"version": "0.10.0",
 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz",
 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz",
@@ -3187,11 +3192,6 @@
 				"node": ">=4.2.0"
 				"node": ">=4.2.0"
 			}
 			}
 		},
 		},
-		"node_modules/uc.micro": {
-			"version": "1.0.6",
-			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
-			"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
-		},
 		"node_modules/uri-js": {
 		"node_modules/uri-js": {
 			"version": "4.4.1",
 			"version": "4.4.1",
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3293,6 +3293,34 @@
 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
 			"dev": true
 			"dev": true
 		},
 		},
+		"node_modules/ws": {
+			"version": "8.2.3",
+			"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
+			"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
+			"engines": {
+				"node": ">=10.0.0"
+			},
+			"peerDependencies": {
+				"bufferutil": "^4.0.1",
+				"utf-8-validate": "^5.0.2"
+			},
+			"peerDependenciesMeta": {
+				"bufferutil": {
+					"optional": true
+				},
+				"utf-8-validate": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/xmlhttprequest-ssl": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
+			"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
+			"engines": {
+				"node": ">=0.4.0"
+			}
+		},
 		"node_modules/xtend": {
 		"node_modules/xtend": {
 			"version": "4.0.2",
 			"version": "4.0.2",
 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -3395,6 +3423,11 @@
 				"picomatch": "^2.2.2"
 				"picomatch": "^2.2.2"
 			}
 			}
 		},
 		},
+		"@socket.io/component-emitter": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
+			"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
+		},
 		"@sveltejs/adapter-auto": {
 		"@sveltejs/adapter-auto": {
 			"version": "1.0.0-next.40",
 			"version": "1.0.0-next.40",
 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz",
 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz",
@@ -3525,12 +3558,6 @@
 				"@types/geojson": "*"
 				"@types/geojson": "*"
 			}
 			}
 		},
 		},
-		"@types/linkify-it": {
-			"version": "3.0.2",
-			"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz",
-			"integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==",
-			"dev": true
-		},
 		"@types/lodash": {
 		"@types/lodash": {
 			"version": "4.14.182",
 			"version": "4.14.182",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -3546,22 +3573,6 @@
 				"@types/lodash": "*"
 				"@types/lodash": "*"
 			}
 			}
 		},
 		},
-		"@types/markdown-it": {
-			"version": "12.2.3",
-			"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
-			"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
-			"dev": true,
-			"requires": {
-				"@types/linkify-it": "*",
-				"@types/mdurl": "*"
-			}
-		},
-		"@types/mdurl": {
-			"version": "1.0.2",
-			"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
-			"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==",
-			"dev": true
-		},
 		"@types/node": {
 		"@types/node": {
 			"version": "17.0.32",
 			"version": "17.0.32",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
@@ -3583,6 +3594,15 @@
 				"@types/node": "*"
 				"@types/node": "*"
 			}
 			}
 		},
 		},
+		"@types/socket.io-client": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz",
+			"integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==",
+			"dev": true,
+			"requires": {
+				"socket.io-client": "*"
+			}
+		},
 		"@typescript-eslint/eslint-plugin": {
 		"@typescript-eslint/eslint-plugin": {
 			"version": "5.23.0",
 			"version": "5.23.0",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz",
@@ -3762,7 +3782,8 @@
 		"argparse": {
 		"argparse": {
 			"version": "2.0.1",
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
 			"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+			"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+			"dev": true
 		},
 		},
 		"array-union": {
 		"array-union": {
 			"version": "2.1.0",
 			"version": "2.1.0",
@@ -3947,7 +3968,6 @@
 			"version": "4.3.4",
 			"version": "4.3.4",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-			"dev": true,
 			"requires": {
 			"requires": {
 				"ms": "2.1.2"
 				"ms": "2.1.2"
 			}
 			}
@@ -4028,10 +4048,22 @@
 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==",
 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==",
 			"dev": true
 			"dev": true
 		},
 		},
-		"entities": {
-			"version": "3.0.1",
-			"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
-			"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="
+		"engine.io-client": {
+			"version": "6.2.2",
+			"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz",
+			"integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==",
+			"requires": {
+				"@socket.io/component-emitter": "~3.1.0",
+				"debug": "~4.3.1",
+				"engine.io-parser": "~5.0.3",
+				"ws": "~8.2.3",
+				"xmlhttprequest-ssl": "~2.0.0"
+			}
+		},
+		"engine.io-parser": {
+			"version": "5.0.4",
+			"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz",
+			"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg=="
 		},
 		},
 		"es6-promise": {
 		"es6-promise": {
 			"version": "3.3.1",
 			"version": "3.3.1",
@@ -4399,6 +4431,11 @@
 			"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
 			"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
 			"dev": true
 			"dev": true
 		},
 		},
+		"exifr": {
+			"version": "7.1.3",
+			"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+			"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
+		},
 		"fast-deep-equal": {
 		"fast-deep-equal": {
 			"version": "3.1.3",
 			"version": "3.1.3",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4729,14 +4766,6 @@
 			"integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==",
 			"integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==",
 			"dev": true
 			"dev": true
 		},
 		},
-		"linkify-it": {
-			"version": "4.0.1",
-			"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
-			"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
-			"requires": {
-				"uc.micro": "^1.0.1"
-			}
-		},
 		"lodash": {
 		"lodash": {
 			"version": "4.17.21",
 			"version": "4.17.21",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -4771,23 +4800,6 @@
 				"sourcemap-codec": "^1.4.8"
 				"sourcemap-codec": "^1.4.8"
 			}
 			}
 		},
 		},
-		"markdown-it": {
-			"version": "13.0.1",
-			"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz",
-			"integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==",
-			"requires": {
-				"argparse": "^2.0.1",
-				"entities": "~3.0.1",
-				"linkify-it": "^4.0.1",
-				"mdurl": "^1.0.1",
-				"uc.micro": "^1.0.5"
-			}
-		},
-		"mdurl": {
-			"version": "1.0.1",
-			"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
-			"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
-		},
 		"merge2": {
 		"merge2": {
 			"version": "1.4.1",
 			"version": "1.4.1",
 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4867,8 +4879,7 @@
 		"ms": {
 		"ms": {
 			"version": "2.1.2",
 			"version": "2.1.2",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-			"dev": true
+			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
 		},
 		},
 		"nanoid": {
 		"nanoid": {
 			"version": "3.3.4",
 			"version": "3.3.4",
@@ -5199,6 +5210,26 @@
 			"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
 			"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
 			"dev": true
 			"dev": true
 		},
 		},
+		"socket.io-client": {
+			"version": "4.5.1",
+			"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz",
+			"integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==",
+			"requires": {
+				"@socket.io/component-emitter": "~3.1.0",
+				"debug": "~4.3.2",
+				"engine.io-client": "~6.2.1",
+				"socket.io-parser": "~4.2.0"
+			}
+		},
+		"socket.io-parser": {
+			"version": "4.2.0",
+			"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.0.tgz",
+			"integrity": "sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==",
+			"requires": {
+				"@socket.io/component-emitter": "~3.1.0",
+				"debug": "~4.3.1"
+			}
+		},
 		"sorcery": {
 		"sorcery": {
 			"version": "0.10.0",
 			"version": "0.10.0",
 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz",
 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz",
@@ -5436,11 +5467,6 @@
 			"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
 			"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
 			"dev": true
 			"dev": true
 		},
 		},
-		"uc.micro": {
-			"version": "1.0.6",
-			"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
-			"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
-		},
 		"uri-js": {
 		"uri-js": {
 			"version": "4.4.1",
 			"version": "4.4.1",
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -5506,6 +5532,17 @@
 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
 			"dev": true
 			"dev": true
 		},
 		},
+		"ws": {
+			"version": "8.2.3",
+			"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
+			"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
+			"requires": {}
+		},
+		"xmlhttprequest-ssl": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
+			"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="
+		},
 		"xtend": {
 		"xtend": {
 			"version": "4.0.2",
 			"version": "4.0.2",
 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

+ 3 - 0
web/package.json

@@ -23,6 +23,7 @@
 		"@types/leaflet": "^1.7.10",
 		"@types/leaflet": "^1.7.10",
 		"@types/lodash": "^4.14.182",
 		"@types/lodash": "^4.14.182",
 		"@types/lodash-es": "^4.17.6",
 		"@types/lodash-es": "^4.17.6",
+		"@types/socket.io-client": "^3.0.0",
 		"@typescript-eslint/eslint-plugin": "^5.10.1",
 		"@typescript-eslint/eslint-plugin": "^5.10.1",
 		"@typescript-eslint/parser": "^5.10.1",
 		"@typescript-eslint/parser": "^5.10.1",
 		"autoprefixer": "^10.4.7",
 		"autoprefixer": "^10.4.7",
@@ -43,10 +44,12 @@
 	"dependencies": {
 	"dependencies": {
 		"axios": "^0.27.2",
 		"axios": "^0.27.2",
 		"cookie": "^0.4.2",
 		"cookie": "^0.4.2",
+		"exifr": "^7.1.3",
 		"leaflet": "^1.8.0",
 		"leaflet": "^1.8.0",
 		"lodash": "^4.17.21",
 		"lodash": "^4.17.21",
 		"lodash-es": "^4.17.21",
 		"lodash-es": "^4.17.21",
 		"moment": "^2.29.3",
 		"moment": "^2.29.3",
+		"socket.io-client": "^4.5.1",
 		"svelte-material-icons": "^2.0.2"
 		"svelte-material-icons": "^2.0.2"
 	}
 	}
 }
 }

+ 1 - 1
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -91,7 +91,7 @@
 			<Close size="24" color="#232323" />
 			<Close size="24" color="#232323" />
 		</button>
 		</button>
 
 
-		<p class="text-black text-lg">Info</p>
+		<p class="text-immich-fg text-lg">Info</p>
 	</div>
 	</div>
 
 
 	<div class="px-4 py-4">
 	<div class="px-4 py-4">

+ 18 - 7
web/src/lib/components/shared/navigation-bar.svelte

@@ -2,16 +2,19 @@
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
 	import { page } from '$app/stores';
 	import { page } from '$app/stores';
 	import type { ImmichUser } from '$lib/models/immich-user';
 	import type { ImmichUser } from '$lib/models/immich-user';
-	import { onMount } from 'svelte';
-	import { fade } from 'svelte/transition';
+	import { createEventDispatcher, onMount } from 'svelte';
+	import { fade, fly, slide } from 'svelte/transition';
 	import { postRequest } from '../../api';
 	import { postRequest } from '../../api';
 	import { serverEndpoint } from '../../constants';
 	import { serverEndpoint } from '../../constants';
+	import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
 	import { clickOutside } from './click-outside';
 	import { clickOutside } from './click-outside';
 
 
 	export let user: ImmichUser;
 	export let user: ImmichUser;
 
 
 	let shouldShowAccountInfo = false;
 	let shouldShowAccountInfo = false;
 	let shouldShowProfileImage = false;
 	let shouldShowProfileImage = false;
+
+	const dispatch = createEventDispatcher();
 	let shouldShowAccountInfoPanel = false;
 	let shouldShowAccountInfoPanel = false;
 	onMount(async () => {
 	onMount(async () => {
 		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' });
 		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' });
@@ -41,7 +44,7 @@
 </script>
 </script>
 
 
 <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm">
 <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm">
-	<div class="flex border place-items-center px-6 py-2 ">
+	<div class="flex border-b place-items-center px-6 py-2 ">
 		<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
 		<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
 			<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
 			<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
@@ -49,13 +52,21 @@
 		<div class="flex-1 ml-24">
 		<div class="flex-1 ml-24">
 			<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" />
 			<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" />
 		</div>
 		</div>
-
-		<section class="flex gap-6 place-items-center">
-			<!-- <div>Upload</div> -->
+		<section class="flex gap-4 place-items-center">
+			{#if $page.url.pathname !== '/admin'}
+				<button
+					in:fly={{ x: 50, duration: 250 }}
+					on:click={() => dispatch('uploadClicked')}
+					class="flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium"
+				>
+					<TrayArrowUp size="20" />
+					<span> Upload </span>
+				</button>
+			{/if}
 
 
 			{#if user.isAdmin}
 			{#if user.isAdmin}
 				<button
 				<button
-					class={`hover:text-immich-primary font-medium ${
+					class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
 						$page.url.pathname == '/admin' && 'text-immich-primary underline'
 						$page.url.pathname == '/admin' && 'text-immich-primary underline'
 					}`}
 					}`}
 					on:click={navigateToAdmin}>Administration</button
 					on:click={navigateToAdmin}>Administration</button

+ 191 - 0
web/src/lib/components/shared/upload-panel.svelte

@@ -0,0 +1,191 @@
+<script lang="ts">
+	import { quartInOut } from 'svelte/easing';
+	import { scale, fade } from 'svelte/transition';
+	import { uploadAssetsStore } from '$lib/stores/upload';
+	import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
+	import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
+	import type { UploadAsset } from '$lib/models/upload-asset';
+
+	let showDetail = true;
+
+	let uploadLength = 0;
+
+	const showUploadImageThumbnail = async (a: UploadAsset) => {
+		const extension = a.fileExtension.toLowerCase();
+
+		if (extension == 'jpeg' || extension == 'jpg' || extension == 'png') {
+			try {
+				const imgData = await a.file.arrayBuffer();
+				const arrayBufferView = new Uint8Array(imgData);
+				const blob = new Blob([arrayBufferView], { type: 'image/jpeg' });
+				const urlCreator = window.URL || window.webkitURL;
+				const imageUrl = urlCreator.createObjectURL(blob);
+				const img: any = document.getElementById(`${a.id}`);
+				img.src = imageUrl;
+			} catch (e) {}
+		}
+	};
+
+	function getSizeInHumanReadableFormat(sizeInByte: number) {
+		const pepibyte = 1.126 * Math.pow(10, 15);
+		const tebibyte = 1.1 * Math.pow(10, 12);
+		const gibibyte = 1.074 * Math.pow(10, 9);
+		const mebibyte = 1.049 * Math.pow(10, 6);
+		const kibibyte = 1024;
+		// Pebibyte
+		if (sizeInByte >= pepibyte) {
+			// Pe
+			return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
+		} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
+			// Te
+			return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
+		} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
+			// Gi
+			return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
+		} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
+			// Mega
+			return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
+		} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
+			// Kibi
+			return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
+		} else {
+			return `${sizeInByte}B`;
+		}
+	}
+
+	// Reactive action to get thumbnail image of upload asset whenever there is a new one added to the list
+	$: {
+		if ($uploadAssetsStore.length != uploadLength) {
+			$uploadAssetsStore.map((asset) => {
+				showUploadImageThumbnail(asset);
+			});
+
+			uploadLength = $uploadAssetsStore.length;
+		}
+	}
+
+	$: {
+		if (showDetail) {
+			$uploadAssetsStore.map((asset) => {
+				showUploadImageThumbnail(asset);
+			});
+		}
+	}
+
+	let isUploading = false;
+
+	uploadAssetsStore.isUploading.subscribe((value) => (isUploading = value));
+</script>
+
+{#if isUploading}
+	<div
+		in:fade={{ duration: 250 }}
+		out:fade={{ duration: 250, delay: 1000 }}
+		class="absolute right-6 bottom-6 z-[10000]"
+	>
+		{#if showDetail}
+			<div
+				in:scale={{ duration: 250, easing: quartInOut }}
+				class="bg-gray-200 p-4 text-sm w-[300px] rounded-lg shadow-sm border "
+			>
+				<div class="flex justify-between place-item-center mb-4">
+					<p class="text-xs text-gray-500">UPLOADING {$uploadAssetsStore.length}</p>
+					<button
+						on:click={() => (showDetail = false)}
+						class="w-[20px] h-[20px] bg-gray-50 rounded-full flex place-items-center place-content-center transition-colors hover:bg-gray-100"
+					>
+						<WindowMinimize />
+					</button>
+				</div>
+
+				<div id="upload-item-list" class="max-h-[400px] overflow-y-auto pr-2 rounded-lg">
+					{#each $uploadAssetsStore as uploadAsset}
+						<div
+							in:fade={{ duration: 250 }}
+							out:fade={{ duration: 100 }}
+							class="text-xs mt-3 rounded-lg bg-immich-bg grid grid-cols-[70px_auto] gap-2 h-[70px]"
+						>
+							<div class="relative">
+								<img
+									in:fade={{ duration: 250 }}
+									id={`${uploadAsset.id}`}
+									src="/immich-logo.svg"
+									alt=""
+									class="h-[70px] w-[70px] object-cover rounded-tl-lg rounded-bl-lg "
+								/>
+
+								<div class="bottom-0 left-0 absolute w-full h-[25px] bg-immich-primary/30">
+									<p
+										class="absolute bottom-1 right-1 object-right-bottom text-gray-50/95 font-semibold stroke-immich-primary uppercase"
+									>
+										.{uploadAsset.fileExtension}
+									</p>
+								</div>
+							</div>
+
+							<div class="p-2 pr-4 flex flex-col justify-between">
+								<input
+									disabled
+									class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
+									value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${uploadAsset.file.name}`}
+								/>
+
+								<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">
+									<div
+										class="bg-immich-primary h-[15px] rounded-md transition-all"
+										style={`width: ${uploadAsset.progress}%`}
+									/>
+									<p class="absolute h-full w-full text-center top-0 text-[10px] ">{uploadAsset.progress}/100</p>
+								</div>
+							</div>
+						</div>
+					{/each}
+				</div>
+			</div>
+		{:else}
+			<div class="rounded-full">
+				<button
+					in:scale={{ duration: 250, easing: quartInOut }}
+					on:click={() => (showDetail = true)}
+					class="absolute -top-4 -left-4 text-xs rounded-full w-10 h-10 p-5 flex place-items-center place-content-center bg-immich-primary text-gray-200"
+				>
+					{$uploadAssetsStore.length}
+				</button>
+				<button
+					in:scale={{ duration: 250, easing: quartInOut }}
+					on:click={() => (showDetail = true)}
+					class="bg-gray-300 p-5 rounded-full w-16 h-16 flex place-items-center place-content-center text-sm shadow-lg "
+				>
+					<div class="animate-pulse">
+						<CloudUploadOutline size="30" color="#4250af" />
+					</div>
+				</button>
+			</div>
+		{/if}
+	</div>
+{/if}
+
+<style>
+	/* width */
+	#upload-item-list::-webkit-scrollbar {
+		width: 5px;
+	}
+
+	/* Track */
+	#upload-item-list::-webkit-scrollbar-track {
+		background: #f1f1f1;
+		border-radius: 16px;
+	}
+
+	/* Handle */
+	#upload-item-list::-webkit-scrollbar-thumb {
+		background: #4250af68;
+		border-radius: 16px;
+	}
+
+	/* Handle on hover */
+	#upload-item-list::-webkit-scrollbar-thumb:hover {
+		background: #4250afad;
+		border-radius: 16px;
+	}
+</style>

+ 6 - 0
web/src/lib/models/upload-asset.ts

@@ -0,0 +1,6 @@
+export type UploadAsset = {
+	id: string;
+	file: File;
+	progress: number;
+	fileExtension: string;
+};

+ 17 - 21
web/src/lib/stores/assets.ts

@@ -1,33 +1,29 @@
 import { writable, derived } from 'svelte/store';
 import { writable, derived } from 'svelte/store';
 import { getRequest } from '$lib/api';
 import { getRequest } from '$lib/api';
-import type { ImmichAsset } from '$lib/models/immich-asset'
+import type { ImmichAsset } from '$lib/models/immich-asset';
 import lodash from 'lodash-es';
 import lodash from 'lodash-es';
 import moment from 'moment';
 import moment from 'moment';
 
 
 export const assets = writable<ImmichAsset[]>([]);
 export const assets = writable<ImmichAsset[]>([]);
 
 
 export const assetsGroupByDate = derived(assets, ($assets) => {
 export const assetsGroupByDate = derived(assets, ($assets) => {
-
-  try {
-    return lodash.chain($assets)
-      .groupBy((a) => moment(a.createdAt).format('ddd, MMM DD'))
-      .sortBy((group) => $assets.indexOf(group[0]))
-      .value();
-  } catch (e) {
-    console.log("error deriving state assets", e)
-    return []
-  }
-
-})
+	try {
+		return lodash
+			.chain($assets)
+			.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD'))
+			.sortBy((group) => $assets.indexOf(group[0]))
+			.value();
+	} catch (e) {
+		console.log('error deriving state assets', e);
+		return [];
+	}
+});
 
 
 export const flattenAssetGroupByDate = derived(assetsGroupByDate, ($assetsGroupByDate) => {
 export const flattenAssetGroupByDate = derived(assetsGroupByDate, ($assetsGroupByDate) => {
-  return $assetsGroupByDate.flat();
-})
+	return $assetsGroupByDate.flat();
+});
 
 
 export const getAssetsInfo = async (accessToken: string) => {
 export const getAssetsInfo = async (accessToken: string) => {
-  const res = await getRequest('asset', accessToken);
-
-  assets.set(res);
-
-}
-
+	const res = await getRequest('asset', accessToken);
+	assets.set(res);
+};

+ 45 - 0
web/src/lib/stores/upload.ts

@@ -0,0 +1,45 @@
+import { writable, derived } from 'svelte/store';
+import type { UploadAsset } from '../models/upload-asset';
+
+function createUploadStore() {
+	const uploadAssets = writable<Array<UploadAsset>>([]);
+
+	const { subscribe } = uploadAssets;
+
+	const isUploading = derived(uploadAssets, ($uploadAssets) => {
+		return $uploadAssets.length > 0 ? true : false;
+	});
+
+	const addNewUploadAsset = (newAsset: UploadAsset) => {
+		uploadAssets.update((currentSet) => [...currentSet, newAsset]);
+	};
+
+	const updateProgress = (id: string, progress: number) => {
+		uploadAssets.update((uploadingAssets) => {
+			return uploadingAssets.map((asset) => {
+				if (asset.id == id) {
+					return {
+						...asset,
+						progress: progress,
+					};
+				}
+
+				return asset;
+			});
+		});
+	};
+
+	const removeUploadAsset = (id: string) => {
+		uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
+	};
+
+	return {
+		subscribe,
+		isUploading,
+		addNewUploadAsset,
+		updateProgress,
+		removeUploadAsset,
+	};
+}
+
+export const uploadAssetsStore = createUploadStore();

+ 30 - 0
web/src/lib/stores/websocket.ts

@@ -0,0 +1,30 @@
+import { Socket, io } from 'socket.io-client';
+import { serverEndpoint } from '../constants';
+import type { ImmichAsset } from '../models/immich-asset';
+import { assets } from './assets';
+
+export const openWebsocketConnection = (accessToken: string) => {
+	const websocket = io(serverEndpoint, {
+		transports: ['polling'],
+		reconnection: true,
+		forceNew: true,
+		autoConnect: true,
+		extraHeaders: {
+			Authorization: 'Bearer ' + accessToken,
+		},
+	});
+
+	listenToEvent(websocket);
+};
+
+const listenToEvent = (socket: Socket) => {
+	socket.on('on_upload_success', (data) => {
+		const newUploadedAsset: ImmichAsset = JSON.parse(data);
+
+		assets.update((assets) => [...assets, newUploadedAsset]);
+	});
+
+	socket.on('error', (e) => {
+		console.log('Websocket Error', e);
+	});
+};

+ 113 - 0
web/src/lib/utils/file-uploader.ts

@@ -0,0 +1,113 @@
+import * as exifr from 'exifr';
+import { serverEndpoint } from '../constants';
+import { uploadAssetsStore } from '$lib/stores/upload';
+import type { UploadAsset } from '../models/upload-asset';
+
+export async function fileUploader(asset: File, accessToken: string) {
+	const assetType = asset.type.split('/')[0].toUpperCase();
+	const temp = asset.name.split('.');
+	const fileExtension = temp[temp.length - 1];
+	const formData = new FormData();
+
+	try {
+		let exifData = null;
+
+		if (assetType !== 'VIDEO') {
+			exifData = await exifr.parse(asset);
+		}
+
+		const createdAt =
+			exifData && exifData.DateTimeOriginal != null
+				? new Date(exifData.DateTimeOriginal).toISOString()
+				: new Date(asset.lastModified).toISOString();
+
+		const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified;
+
+		// Create and add Unique ID of asset on the device
+		formData.append('deviceAssetId', deviceAssetId);
+
+		// Get device id - for web -> use WEB
+		formData.append('deviceId', 'WEB');
+
+		// Get asset type
+		formData.append('assetType', assetType);
+
+		// Get Asset Created Date
+		formData.append('createdAt', createdAt);
+
+		// Get Asset Modified At
+		formData.append('modifiedAt', new Date(asset.lastModified).toISOString());
+
+		// Set Asset is Favorite to false
+		formData.append('isFavorite', 'false');
+
+		// Get asset duration
+		formData.append('duration', '0:00:00.000000');
+
+		// Get asset file extension
+		formData.append('fileExtension', '.' + fileExtension);
+
+		// Get asset binary data.
+		formData.append('assetData', asset);
+
+		// Check if asset upload on server before performing upload
+		const res = await fetch(serverEndpoint + '/asset/check', {
+			method: 'POST',
+			body: JSON.stringify({ deviceAssetId }),
+			headers: {
+				Authorization: 'Bearer ' + accessToken,
+				'Content-Type': 'application/json',
+			},
+		});
+
+		if (res.status === 200) {
+			const { isExist } = await res.json();
+
+			if (isExist) {
+				return;
+			}
+		}
+
+		const request = new XMLHttpRequest();
+
+		request.upload.onloadstart = () => {
+			const newUploadAsset: UploadAsset = {
+				id: deviceAssetId,
+				file: asset,
+				progress: 0,
+				fileExtension: fileExtension,
+			};
+
+			uploadAssetsStore.addNewUploadAsset(newUploadAsset);
+		};
+
+		request.upload.onload = () => {
+			setTimeout(() => {
+				uploadAssetsStore.removeUploadAsset(deviceAssetId);
+			}, 2500);
+		};
+
+		// listen for `error` event
+		request.upload.onerror = () => {
+			uploadAssetsStore.removeUploadAsset(deviceAssetId);
+		};
+
+		// listen for `abort` event
+		request.upload.onabort = () => {
+			uploadAssetsStore.removeUploadAsset(deviceAssetId);
+		};
+
+		// listen for `progress` event
+		request.upload.onprogress = (event) => {
+			const percentComplete = Math.floor((event.loaded / event.total) * 100);
+			uploadAssetsStore.updateProgress(deviceAssetId, percentComplete);
+		};
+
+		request.open('POST', `${serverEndpoint}/asset/upload`);
+		request.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+
+		request.send(formData);
+	} catch (e) {
+		console.log('error uploading file ', e);
+	}
+}

+ 3 - 3
web/src/routes/__layout.svelte

@@ -22,8 +22,8 @@
 	import { blur } from 'svelte/transition';
 	import { blur } from 'svelte/transition';
 
 
 	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
 	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
-	import FullScreenModal from '../lib/components/shared/full-screen-modal.svelte';
-	import AnnouncementBox from '../lib/components/shared/announcement-box.svelte';
+	import AnnouncementBox from '$lib/components/shared/announcement-box.svelte';
+	import UploadPanel from '$lib/components/shared/upload-panel.svelte';
 
 
 	export let url: string;
 	export let url: string;
 	export let shouldShowAnnouncement: boolean;
 	export let shouldShowAnnouncement: boolean;
@@ -36,7 +36,7 @@
 		<div transition:blur={{ duration: 250 }}>
 		<div transition:blur={{ duration: 250 }}>
 			<slot />
 			<slot />
 			<DownloadPanel />
 			<DownloadPanel />
-
+			<UploadPanel />
 			{#if shouldShowAnnouncement}
 			{#if shouldShowAnnouncement}
 				<AnnouncementBox {localVersion} {remoteVersion} on:close={() => (shouldShowAnnouncement = false)} />
 				<AnnouncementBox {localVersion} {remoteVersion} on:close={() => (shouldShowAnnouncement = false)} />
 			{/if}
 			{/if}

+ 1 - 0
web/src/routes/index.svelte

@@ -35,6 +35,7 @@
 <script lang="ts">
 <script lang="ts">
 	import { serverEndpoint } from '$lib/constants';
 	import { serverEndpoint } from '$lib/constants';
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
+	import { onMount } from 'svelte';
 
 
 	export let isAdminUserExist: boolean;
 	export let isAdminUserExist: boolean;
 
 

+ 37 - 6
web/src/routes/photos/index.svelte

@@ -41,6 +41,8 @@
 	import AssetViewer from '../../lib/components/asset-viewer/asset-viewer.svelte';
 	import AssetViewer from '../../lib/components/asset-viewer/asset-viewer.svelte';
 	import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte';
 	import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte';
 	import StatusBox from '../../lib/components/shared/status-box.svelte';
 	import StatusBox from '../../lib/components/shared/status-box.svelte';
+	import { fileUploader } from '../../lib/utils/file-uploader';
+	import { openWebsocketConnection } from '../../lib/stores/websocket';
 
 
 	export let user: ImmichUser;
 	export let user: ImmichUser;
 	let selectedAction: AppSideBarSelection;
 	let selectedAction: AppSideBarSelection;
@@ -64,6 +66,8 @@
 
 
 		if ($session.user) {
 		if ($session.user) {
 			await getAssetsInfo($session.user.accessToken);
 			await getAssetsInfo($session.user.accessToken);
+
+			openWebsocketConnection($session.user.accessToken);
 		}
 		}
 	});
 	});
 
 
@@ -79,7 +83,34 @@
 		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId);
 		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId);
 		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
 		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
 		isShowAsset = true;
 		isShowAsset = true;
-		// pushState(assetId);
+	};
+
+	const uploadClickedHandler = async () => {
+		if ($session.user) {
+			try {
+				let fileSelector = document.createElement('input');
+
+				fileSelector.type = 'file';
+				fileSelector.multiple = true;
+				fileSelector.accept = 'image/*,video/*,.heic,.heif';
+
+				fileSelector.onchange = async (e: any) => {
+					const files = Array.from<File>(e.target.files);
+
+					const acceptedFile = files.filter(
+						(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image',
+					);
+
+					for (const asset of acceptedFile) {
+						await fileUploader(asset, $session.user!.accessToken);
+					}
+				};
+
+				fileSelector.click();
+			} catch (e) {
+				console.log('Error seelcting file', e);
+			}
+		}
 	};
 	};
 </script>
 </script>
 
 
@@ -88,10 +119,10 @@
 </svelte:head>
 </svelte:head>
 
 
 <section>
 <section>
-	<NavigationBar {user} />
+	<NavigationBar {user} on:uploadClicked={uploadClickedHandler} />
 </section>
 </section>
 
 
-<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
+<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
 	<!-- Sidebar -->
 	<!-- Sidebar -->
 	<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
 	<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
 		<SideBarButton
 		<SideBarButton
@@ -111,7 +142,7 @@
 
 
 	<!-- Main Section -->
 	<!-- Main Section -->
 	<section class="overflow-y-auto relative">
 	<section class="overflow-y-auto relative">
-		<section id="assets-content" class="relative pt-8 pl-4">
+		<section id="assets-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
 			<section id="image-grid" class="flex flex-wrap gap-14">
 			<section id="image-grid" class="flex flex-wrap gap-14">
 				{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
 				{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
 					<!-- Asset Group By Date -->
 					<!-- Asset Group By Date -->
@@ -121,7 +152,7 @@
 						on:mouseleave={() => (isMouseOverGroup = false)}
 						on:mouseleave={() => (isMouseOverGroup = false)}
 					>
 					>
 						<!-- Date group title -->
 						<!-- Date group title -->
-						<p class="font-medium text-sm text-black mb-2 flex place-items-center h-6">
+						<p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6">
 							{#if selectedGroupThumbnail === groupIndex && isMouseOverGroup}
 							{#if selectedGroupThumbnail === groupIndex && isMouseOverGroup}
 								<div
 								<div
 									in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
 									in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
@@ -136,7 +167,7 @@
 						</p>
 						</p>
 
 
 						<!-- Image grid -->
 						<!-- Image grid -->
-						<div class="flex flex-wrap gap-1">
+						<div class="flex flex-wrap gap-[2px]">
 							{#each assetsInDateGroup as asset}
 							{#each assetsInDateGroup as asset}
 								<ImmichThumbnail
 								<ImmichThumbnail
 									{asset}
 									{asset}

+ 4 - 0
web/tailwind.config.cjs

@@ -5,6 +5,10 @@ module.exports = {
 			colors: {
 			colors: {
 				'immich-primary': '#4250af',
 				'immich-primary': '#4250af',
 				'immich-bg': '#f6f8fe',
 				'immich-bg': '#f6f8fe',
+				'immich-fg': 'black',
+
+				// 'immich-bg': '#121212',
+				// 'immich-fg': '#D0D0D0',
 			},
 			},
 			fontFamily: {
 			fontFamily: {
 				'immich-title': ['Snowburst One', 'cursive'],
 				'immich-title': ['Snowburst One', 'cursive'],