diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000000..24e38564f8
--- /dev/null
+++ b/.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
diff --git a/README.md b/README.md
index 5be02110b9..08485affca 100644
--- a/README.md
+++ b/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"/>
   </a>
   <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 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"/>
@@ -25,7 +25,7 @@
 
 # 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)
 
@@ -33,7 +33,7 @@ Loading ~4000 images/videos
 
 ## Screenshots
 
-### Mobile client
+### Mobile
 <p align="left">
   <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">
@@ -44,9 +44,10 @@ Loading ~4000 images/videos
   <img src="design/nsc6.png" width="150" title="EXIF Info">
 </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>
 
 # Note
@@ -55,26 +56,22 @@ Loading ~4000 images/videos
 
 This project is under heavy development, there will be continuous functions, features and api changes.
 
-# Features
+# 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
 
-- 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.
 
 # 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
 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
 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
 
diff --git a/design/dashboard_photos.jpeg b/design/dashboard_photos.jpeg
deleted file mode 100644
index c3f47c12de..0000000000
Binary files a/design/dashboard_photos.jpeg and /dev/null differ
diff --git a/design/web-admin.jpeg b/design/web-admin.jpeg
new file mode 100644
index 0000000000..d5d819ae31
Binary files /dev/null and b/design/web-admin.jpeg differ
diff --git a/design/web-detail.jpeg b/design/web-detail.jpeg
new file mode 100644
index 0000000000..e6c7c6d2e8
Binary files /dev/null and b/design/web-detail.jpeg differ
diff --git a/design/web-home.jpeg b/design/web-home.jpeg
new file mode 100644
index 0000000000..f9d16c175f
Binary files /dev/null and b/design/web-home.jpeg differ
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index 1b763ed6c4..34635f6971 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -60,6 +60,7 @@ services:
       - NODE_ENV=development
     depends_on:
       - database
+      - immich-server
     networks:
       - immich-network
 
diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts
index 75331f6d0a..e247d778d8 100644
--- a/server/apps/immich/src/api-v1/asset/asset.controller.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts
@@ -15,6 +15,7 @@ import {
   Delete,
   Logger,
   Patch,
+  HttpCode,
 } from '@nestjs/common';
 import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
 import { AssetService } from './asset.service';
@@ -76,6 +77,10 @@ export class AssetController {
             { asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
             { jobId: savedAsset.id },
           );
+
+          this.wsCommunicateionGateway.server
+            .to(savedAsset.userId)
+            .emit('on_upload_success', JSON.stringify(assetWithThumbnail));
         } else {
           await this.assetUploadedQueue.add(
             'asset-uploaded',
@@ -83,8 +88,6 @@ export class AssetController {
             { jobId: savedAsset.id },
           );
         }
-
-        this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
       } catch (e) {
         Logger.error(`Error receiving upload file ${e}`);
       }
@@ -171,4 +174,20 @@ export class AssetController {
 
     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,
+    };
+  }
 }
diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts
index bb9e0823e0..b8ef03b08d 100644
--- a/server/apps/immich/src/api-v1/asset/asset.module.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.module.ts
@@ -24,6 +24,6 @@ import { CommunicationModule } from '../communication/communication.module';
   ],
   controllers: [AssetController],
   providers: [AssetService, BackgroundTaskService],
-  exports: [],
+  exports: [AssetService],
 })
 export class AssetModule {}
diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts
index 3221c64bc5..5e7f761738 100644
--- a/server/apps/immich/src/api-v1/asset/asset.service.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.service.ts
@@ -1,6 +1,6 @@
 import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { IsNull, Not, Repository } from 'typeorm';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
@@ -72,6 +72,7 @@ export class AssetService {
       return await this.assetRepository.find({
         where: {
           userId: authUser.id,
+          resizePath: Not(IsNull()),
         },
         relations: ['exifInfo'],
         order: {
@@ -381,4 +382,15 @@ export class AssetService {
       [authUser.id],
     );
   }
+
+  async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
+    const res = await this.assetRepository.findOne({
+      where: {
+        deviceAssetId,
+        userId: authUser.id,
+      },
+    });
+
+    return res ? true : false;
+  }
 }
diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/apps/immich/src/api-v1/communication/communication.gateway.ts
index ae3d4e7031..a7e06bb023 100644
--- a/server/apps/immich/src/api-v1/communication/communication.gateway.ts
+++ b/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 { UserEntity } from '@app/database/entities/user.entity';
 import { Repository } from 'typeorm';
+import { query } from 'express';
 
-@WebSocketGateway()
+@WebSocketGateway({ cors: true })
 export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
   constructor(
     private immichJwtService: ImmichJwtService,
@@ -21,27 +22,33 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
   handleDisconnect(client: Socket) {
     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[]) {
-    Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection');
-    const accessToken = client.handshake.headers.authorization.split(' ')[1];
-    const res = await this.immichJwtService.validateToken(accessToken);
+    try {
+      Logger.log(`New websocket connection: ${client.id}`, 'WebsocketConnectionEvent');
 
-    if (!res.status) {
-      client.emit('error', 'unauthorized');
-      client.disconnect();
-      return;
+      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;
+      }
+
+      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');
     }
-
-    const user = await this.userRepository.findOne({ where: { id: res.userId } });
-    if (!user) {
-      client.emit('error', 'unauthorized');
-      client.disconnect();
-      return;
-    }
-
-    client.join(user.id);
   }
 }
diff --git a/server/apps/microservices/src/main.ts b/server/apps/microservices/src/main.ts
index efd6fdad7b..b921a9ca76 100644
--- a/server/apps/microservices/src/main.ts
+++ b/server/apps/microservices/src/main.ts
@@ -1,10 +1,13 @@
 import { Logger } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
+import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
 import { MicroservicesModule } from './microservices.module';
 
 async function bootstrap() {
   const app = await NestFactory.create(MicroservicesModule);
 
+  app.useWebSocketAdapter(new RedisIoAdapter(app));
+
   await app.listen(3000, () => {
     if (process.env.NODE_ENV == 'development') {
       Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice');
diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts
index 92c1ce4e75..b7258fa3f5 100644
--- a/server/apps/microservices/src/microservices.module.ts
+++ b/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 { MetadataExtractionProcessor } from './processors/metadata-extraction.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({
   imports: [
@@ -56,6 +59,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         removeOnFail: false,
       },
     }),
+    CommunicationModule,
   ],
   controllers: [],
   providers: [
diff --git a/server/apps/microservices/src/processors/asset-uploaded.processor.ts b/server/apps/microservices/src/processors/asset-uploaded.processor.ts
index c8345c5ca3..f616b83222 100644
--- a/server/apps/microservices/src/processors/asset-uploaded.processor.ts
+++ b/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() });
     } else {
       // Generate Thumbnail -> Then generate webp, tag image and detect object
+      await this.thumbnailGeneratorQueue.add('generate-jpeg-thumbnail', { asset }, { jobId: randomUUID() });
     }
 
     // Video Conversion
@@ -63,5 +64,10 @@ export class AssetUploadedProcessor {
         { 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() });
+    }
   }
 }
diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts
index 6e649e4e8a..0a8a205d4b 100644
--- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts
+++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts
@@ -12,6 +12,8 @@ import { Logger } from '@nestjs/common';
 import axios from 'axios';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { ConfigService } from '@nestjs/config';
+import ffmpeg from 'fluent-ffmpeg';
+// import moment from 'moment';
 
 @Processor('metadata-extraction-queue')
 export class MetadataExtractionProcessor {
@@ -129,4 +131,27 @@ export class MetadataExtractionProcessor {
       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 });
+        }
+      }
+    });
+  }
 }
diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts
index 3f3919daa5..55cc77c470 100644
--- a/server/apps/microservices/src/processors/thumbnail.processor.ts
+++ b/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 { InjectRepository } from '@nestjs/typeorm';
 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')
 export class ThumbnailGeneratorProcessor {
   constructor(
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
+
+    @InjectQueue('thumbnail-generator-queue')
+    private thumbnailGeneratorQueue: Queue,
+
+    private wsCommunicateionGateway: CommunicationGateway,
   ) {}
 
   @Process('generate-jpeg-thumbnail')
   async generateJPEGThumbnail(job: Job) {
     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 })
diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts
index c7f8f3b3ba..172dfbb7b2 100644
--- a/server/apps/microservices/src/processors/video-transcode.processor.ts
+++ b/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'])
         .output(savedEncodedPath)
         .on('start', () => {
-          Logger.log('Start Converting', 'mp4Conversion');
+          Logger.log('Start Converting Video', 'mp4Conversion');
         })
         .on('error', (error, b, c) => {
           Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion');
diff --git a/server/package-lock.json b/server/package-lock.json
index 663d6183d8..fe03142ab6 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -55,6 +55,7 @@
         "@types/bull": "^3.15.7",
         "@types/cron": "^2.0.0",
         "@types/express": "^4.17.13",
+        "@types/fluent-ffmpeg": "^2.1.20",
         "@types/imagemin": "^8.0.0",
         "@types/jest": "27.0.2",
         "@types/lodash": "^4.14.178",
@@ -2195,6 +2196,15 @@
         "@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": {
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@@ -12803,6 +12813,15 @@
         "@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": {
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
diff --git a/server/package.json b/server/package.json
index eb2d5b3ef2..6333be06ee 100644
--- a/server/package.json
+++ b/server/package.json
@@ -68,6 +68,7 @@
     "@types/bull": "^3.15.7",
     "@types/cron": "^2.0.0",
     "@types/express": "^4.17.13",
+    "@types/fluent-ffmpeg": "^2.1.20",
     "@types/imagemin": "^8.0.0",
     "@types/jest": "27.0.2",
     "@types/lodash": "^4.14.178",
diff --git a/web/package-lock.json b/web/package-lock.json
index 89a4d7d3c2..a6869a5bd9 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,11 +10,12 @@
 			"dependencies": {
 				"axios": "^0.27.2",
 				"cookie": "^0.4.2",
+				"exifr": "^7.1.3",
 				"leaflet": "^1.8.0",
 				"lodash": "^4.17.21",
 				"lodash-es": "^4.17.21",
-				"markdown-it": "^13.0.1",
 				"moment": "^2.29.3",
+				"socket.io-client": "^4.5.1",
 				"svelte-material-icons": "^2.0.2"
 			},
 			"devDependencies": {
@@ -28,7 +29,7 @@
 				"@types/leaflet": "^1.7.10",
 				"@types/lodash": "^4.14.182",
 				"@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/parser": "^5.10.1",
 				"autoprefixer": "^10.4.7",
@@ -140,6 +141,11 @@
 				"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": {
 			"version": "1.0.0-next.40",
 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz",
@@ -293,12 +299,6 @@
 				"@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": {
 			"version": "4.14.182",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -314,22 +314,6 @@
 				"@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": {
 			"version": "17.0.32",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
@@ -351,6 +335,16 @@
 				"@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": {
 			"version": "5.23.0",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz",
@@ -650,7 +644,8 @@
 		"node_modules/argparse": {
 			"version": "2.0.1",
 			"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": {
 			"version": "2.1.0",
@@ -933,7 +928,6 @@
 			"version": "4.3.4",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-			"dev": true,
 			"dependencies": {
 				"ms": "2.1.2"
 			},
@@ -1043,15 +1037,24 @@
 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==",
 			"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": {
-				"node": ">=0.12"
-			},
-			"funding": {
-				"url": "https://github.com/fb55/entities?sponsor=1"
+				"node": ">=10.0.0"
 			}
 		},
 		"node_modules/es6-promise": {
@@ -1673,6 +1676,11 @@
 				"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": {
 			"version": "3.1.3",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2112,14 +2120,6 @@
 				"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": {
 			"version": "4.17.21",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -2160,26 +2160,6 @@
 				"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": {
 			"version": "1.4.1",
 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2289,8 +2269,7 @@
 		"node_modules/ms": {
 			"version": "2.1.2",
 			"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": {
 			"version": "3.3.4",
@@ -2820,6 +2799,32 @@
 				"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": {
 			"version": "0.10.0",
 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz",
@@ -3187,11 +3192,6 @@
 				"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": {
 			"version": "4.4.1",
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3293,6 +3293,34 @@
 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
 			"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": {
 			"version": "4.0.2",
 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -3395,6 +3423,11 @@
 				"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": {
 			"version": "1.0.0-next.40",
 			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.40.tgz",
@@ -3525,12 +3558,6 @@
 				"@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": {
 			"version": "4.14.182",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -3546,22 +3573,6 @@
 				"@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": {
 			"version": "17.0.32",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz",
@@ -3583,6 +3594,15 @@
 				"@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": {
 			"version": "5.23.0",
 			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.23.0.tgz",
@@ -3762,7 +3782,8 @@
 		"argparse": {
 			"version": "2.0.1",
 			"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": {
 			"version": "2.1.0",
@@ -3947,7 +3968,6 @@
 			"version": "4.3.4",
 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
 			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-			"dev": true,
 			"requires": {
 				"ms": "2.1.2"
 			}
@@ -4028,10 +4048,22 @@
 			"integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==",
 			"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": {
 			"version": "3.3.1",
@@ -4399,6 +4431,11 @@
 			"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
 			"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": {
 			"version": "3.1.3",
 			"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==",
 			"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": {
 			"version": "4.17.21",
 			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -4771,23 +4800,6 @@
 				"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": {
 			"version": "1.4.1",
 			"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4867,8 +4879,7 @@
 		"ms": {
 			"version": "2.1.2",
 			"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": {
 			"version": "3.3.4",
@@ -5199,6 +5210,26 @@
 			"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
 			"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": {
 			"version": "0.10.0",
 			"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz",
@@ -5436,11 +5467,6 @@
 			"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
 			"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": {
 			"version": "4.4.1",
 			"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -5506,6 +5532,17 @@
 			"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
 			"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": {
 			"version": "4.0.2",
 			"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/web/package.json b/web/package.json
index 15030595c8..4a4ea786a0 100644
--- a/web/package.json
+++ b/web/package.json
@@ -23,6 +23,7 @@
 		"@types/leaflet": "^1.7.10",
 		"@types/lodash": "^4.14.182",
 		"@types/lodash-es": "^4.17.6",
+		"@types/socket.io-client": "^3.0.0",
 		"@typescript-eslint/eslint-plugin": "^5.10.1",
 		"@typescript-eslint/parser": "^5.10.1",
 		"autoprefixer": "^10.4.7",
@@ -43,10 +44,12 @@
 	"dependencies": {
 		"axios": "^0.27.2",
 		"cookie": "^0.4.2",
+		"exifr": "^7.1.3",
 		"leaflet": "^1.8.0",
 		"lodash": "^4.17.21",
 		"lodash-es": "^4.17.21",
 		"moment": "^2.29.3",
+		"socket.io-client": "^4.5.1",
 		"svelte-material-icons": "^2.0.2"
 	}
 }
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index b4b1d24607..95ce78ac98 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -91,7 +91,7 @@
 			<Close size="24" color="#232323" />
 		</button>
 
-		<p class="text-black text-lg">Info</p>
+		<p class="text-immich-fg text-lg">Info</p>
 	</div>
 
 	<div class="px-4 py-4">
diff --git a/web/src/lib/components/shared/navigation-bar.svelte b/web/src/lib/components/shared/navigation-bar.svelte
index a9fcebf6f7..7333957faa 100644
--- a/web/src/lib/components/shared/navigation-bar.svelte
+++ b/web/src/lib/components/shared/navigation-bar.svelte
@@ -2,16 +2,19 @@
 	import { goto } from '$app/navigation';
 	import { page } from '$app/stores';
 	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 { serverEndpoint } from '../../constants';
+	import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
 	import { clickOutside } from './click-outside';
 
 	export let user: ImmichUser;
 
 	let shouldShowAccountInfo = false;
 	let shouldShowProfileImage = false;
+
+	const dispatch = createEventDispatcher();
 	let shouldShowAccountInfoPanel = false;
 	onMount(async () => {
 		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' });
@@ -41,7 +44,7 @@
 </script>
 
 <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">
 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
 			<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
@@ -49,13 +52,21 @@
 		<div class="flex-1 ml-24">
 			<input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" />
 		</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}
 				<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'
 					}`}
 					on:click={navigateToAdmin}>Administration</button
diff --git a/web/src/lib/components/shared/upload-panel.svelte b/web/src/lib/components/shared/upload-panel.svelte
new file mode 100644
index 0000000000..aef213430f
--- /dev/null
+++ b/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>
diff --git a/web/src/lib/models/upload-asset.ts b/web/src/lib/models/upload-asset.ts
new file mode 100644
index 0000000000..6079251b63
--- /dev/null
+++ b/web/src/lib/models/upload-asset.ts
@@ -0,0 +1,6 @@
+export type UploadAsset = {
+	id: string;
+	file: File;
+	progress: number;
+	fileExtension: string;
+};
diff --git a/web/src/lib/stores/assets.ts b/web/src/lib/stores/assets.ts
index f43295f60d..19dcea16d8 100644
--- a/web/src/lib/stores/assets.ts
+++ b/web/src/lib/stores/assets.ts
@@ -1,33 +1,29 @@
 import { writable, derived } from 'svelte/store';
 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 moment from 'moment';
 
 export const assets = writable<ImmichAsset[]>([]);
 
 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) => {
-  return $assetsGroupByDate.flat();
-})
+	return $assetsGroupByDate.flat();
+});
 
 export const getAssetsInfo = async (accessToken: string) => {
-  const res = await getRequest('asset', accessToken);
-
-  assets.set(res);
-
-}
-
+	const res = await getRequest('asset', accessToken);
+	assets.set(res);
+};
diff --git a/web/src/lib/stores/upload.ts b/web/src/lib/stores/upload.ts
new file mode 100644
index 0000000000..1ed576cd64
--- /dev/null
+++ b/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();
diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts
new file mode 100644
index 0000000000..d37f220922
--- /dev/null
+++ b/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);
+	});
+};
diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts
new file mode 100644
index 0000000000..792048adce
--- /dev/null
+++ b/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);
+	}
+}
diff --git a/web/src/routes/__layout.svelte b/web/src/routes/__layout.svelte
index 5468e49856..3e39e5b294 100644
--- a/web/src/routes/__layout.svelte
+++ b/web/src/routes/__layout.svelte
@@ -22,8 +22,8 @@
 	import { blur } from 'svelte/transition';
 
 	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 shouldShowAnnouncement: boolean;
@@ -36,7 +36,7 @@
 		<div transition:blur={{ duration: 250 }}>
 			<slot />
 			<DownloadPanel />
-
+			<UploadPanel />
 			{#if shouldShowAnnouncement}
 				<AnnouncementBox {localVersion} {remoteVersion} on:close={() => (shouldShowAnnouncement = false)} />
 			{/if}
diff --git a/web/src/routes/index.svelte b/web/src/routes/index.svelte
index 25fd6b01b3..d173d3c4d9 100644
--- a/web/src/routes/index.svelte
+++ b/web/src/routes/index.svelte
@@ -35,6 +35,7 @@
 <script lang="ts">
 	import { serverEndpoint } from '$lib/constants';
 	import { goto } from '$app/navigation';
+	import { onMount } from 'svelte';
 
 	export let isAdminUserExist: boolean;
 
diff --git a/web/src/routes/photos/index.svelte b/web/src/routes/photos/index.svelte
index 4303490023..2a143553b4 100644
--- a/web/src/routes/photos/index.svelte
+++ b/web/src/routes/photos/index.svelte
@@ -41,6 +41,8 @@
 	import AssetViewer from '../../lib/components/asset-viewer/asset-viewer.svelte';
 	import DownloadPanel from '../../lib/components/asset-viewer/download-panel.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;
 	let selectedAction: AppSideBarSelection;
@@ -64,6 +66,8 @@
 
 		if ($session.user) {
 			await getAssetsInfo($session.user.accessToken);
+
+			openWebsocketConnection($session.user.accessToken);
 		}
 	});
 
@@ -79,7 +83,34 @@
 		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId);
 		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
 		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>
 
@@ -88,10 +119,10 @@
 </svelte:head>
 
 <section>
-	<NavigationBar {user} />
+	<NavigationBar {user} on:uploadClicked={uploadClickedHandler} />
 </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 -->
 	<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
 		<SideBarButton
@@ -111,7 +142,7 @@
 
 	<!-- Main Section -->
 	<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">
 				{#each $assetsGroupByDate as assetsInDateGroup, groupIndex}
 					<!-- Asset Group By Date -->
@@ -121,7 +152,7 @@
 						on:mouseleave={() => (isMouseOverGroup = false)}
 					>
 						<!-- 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}
 								<div
 									in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
@@ -136,7 +167,7 @@
 						</p>
 
 						<!-- Image grid -->
-						<div class="flex flex-wrap gap-1">
+						<div class="flex flex-wrap gap-[2px]">
 							{#each assetsInDateGroup as asset}
 								<ImmichThumbnail
 									{asset}
diff --git a/web/tailwind.config.cjs b/web/tailwind.config.cjs
index 01a65ae1c3..a27e2fd61f 100644
--- a/web/tailwind.config.cjs
+++ b/web/tailwind.config.cjs
@@ -5,6 +5,10 @@ module.exports = {
 			colors: {
 				'immich-primary': '#4250af',
 				'immich-bg': '#f6f8fe',
+				'immich-fg': 'black',
+
+				// 'immich-bg': '#121212',
+				// 'immich-fg': '#D0D0D0',
 			},
 			fontFamily: {
 				'immich-title': ['Snowburst One', 'cursive'],