From 53c3c916a663c07a7e98352dd2817b75e0eea649 Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Fri, 3 Jun 2022 11:04:30 -0500
Subject: [PATCH] View assets detail and download operation (#198)

* Fixed not displaying default user profile picture

* Added buttons to close viewer and micro-interaction for navigating assets left, right

* Add additional buttons to the control bar

* Display EXIF info

* Added map to detail info

* Handle user input keyboard

* Fixed incorrect file name when downloading multiple files

* Implemented download panel
---
 server/src/api-v1/asset/asset.controller.ts   |   2 +-
 server/src/api-v1/asset/asset.service.ts      |  26 +-
 server/src/api-v1/user/user.service.ts        |   7 +-
 web/package-lock.json                         | 202 +++++++++++++++
 web/package.json                              |   4 +
 .../asset-viewer/asser-viewer-nav-bar.svelte  |  22 ++
 .../asset-viewer/asset-viewer.svelte          | 234 ++++++++++++++++++
 .../asset-viewer/detail-panel.svelte          | 164 ++++++++++++
 .../asset-viewer/download-panel.svelte        |  26 ++
 .../immich-thumbnail.svelte                   |  21 +-
 .../intersection-observer.svelte              |   0
 .../photo-viewer.svelte}                      |  25 +-
 .../shared/circle_icon_button.svelte          |  18 ++
 .../components/shared/navigation-bar.svelte   |  13 +-
 .../components/shared/side-bar-button.svelte  |   4 +-
 web/src/lib/stores/download.ts                |  13 +
 web/src/routes/__layout.svelte                |  22 +-
 web/src/routes/photos/[assetId].svelte        |  20 ++
 web/src/routes/photos/index.svelte            |  75 +-----
 19 files changed, 798 insertions(+), 100 deletions(-)
 create mode 100644 web/src/lib/components/asset-viewer/asser-viewer-nav-bar.svelte
 create mode 100644 web/src/lib/components/asset-viewer/asset-viewer.svelte
 create mode 100644 web/src/lib/components/asset-viewer/detail-panel.svelte
 create mode 100644 web/src/lib/components/asset-viewer/download-panel.svelte
 rename web/src/lib/components/{photos => asset-viewer}/immich-thumbnail.svelte (84%)
 rename web/src/lib/components/{photos => asset-viewer}/intersection-observer.svelte (100%)
 rename web/src/lib/components/{photos/photo_viewer.svelte => asset-viewer/photo-viewer.svelte} (71%)
 create mode 100644 web/src/lib/components/shared/circle_icon_button.svelte
 create mode 100644 web/src/lib/stores/download.ts
 create mode 100644 web/src/routes/photos/[assetId].svelte

diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts
index 2e9820b76d..83316abbfe 100644
--- a/server/src/api-v1/asset/asset.controller.ts
+++ b/server/src/api-v1/asset/asset.controller.ts
@@ -123,7 +123,7 @@ export class AssetController {
 
   @Get('/')
   async getAllAssets(@GetAuthUser() authUser: AuthUserDto) {
-    return await this.assetService.getAllAssetsNoPagination(authUser);
+    return await this.assetService.getAllAssets(authUser);
   }
 
   @Get('/:deviceId')
diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts
index 6237e4aa28..59a0d11ed3 100644
--- a/server/src/api-v1/asset/asset.service.ts
+++ b/server/src/api-v1/asset/asset.service.ts
@@ -61,13 +61,17 @@ export class AssetService {
     return res;
   }
 
-  public async getAllAssetsNoPagination(authUser: AuthUserDto) {
+  public async getAllAssets(authUser: AuthUserDto) {
     try {
-      return await this.assetRepository
-        .createQueryBuilder('a')
-        .where('a."userId" = :userId', { userId: authUser.id })
-        .orderBy('a."createdAt"::date', 'DESC')
-        .getMany();
+      return await this.assetRepository.find({
+        where: {
+          userId: authUser.id
+        },
+        relations: ['exifInfo'],
+        order: {
+          createdAt: 'DESC'
+        }
+      })
     } catch (e) {
       Logger.error(e, 'getAllAssets');
     }
@@ -100,8 +104,18 @@ export class AssetService {
     const asset = await this.findOne(query.did, query.aid);
 
     if (query.isThumb === 'false' || !query.isThumb) {
+      const { size } = await fileInfo(asset.originalPath);
+      res.set({
+        'Content-Type': asset.mimeType,
+        'Content-Length': size,
+      });
       file = createReadStream(asset.originalPath);
     } else {
+      const { size } = await fileInfo(asset.resizePath);
+      res.set({
+        'Content-Type': 'image/jpeg',
+        'Content-Length': size,
+      });
       file = createReadStream(asset.resizePath);
     }
 
diff --git a/server/src/api-v1/user/user.service.ts b/server/src/api-v1/user/user.service.ts
index e747b521f0..cc5954b8ba 100644
--- a/server/src/api-v1/user/user.service.ts
+++ b/server/src/api-v1/user/user.service.ts
@@ -147,15 +147,16 @@ export class UserService {
   async getUserProfileImage(userId: string, res: Res) {
     try {
       const user = await this.userRepository.findOne({ id: userId })
+
       if (!user.profileImagePath) {
-        console.log("empty return")
-        throw new BadRequestException('User does not have a profile image');
+        // throw new BadRequestException('User does not have a profile image');
+        res.status(404).send('User does not have a profile image');
+        return;
       }
 
       res.set({
         'Content-Type': 'image/jpeg',
       });
-
       const fileStream = createReadStream(user.profileImagePath)
       return new StreamableFile(fileStream);
     } catch (e) {
diff --git a/web/package-lock.json b/web/package-lock.json
index abb58313e6..3d2be5a20d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,7 +8,9 @@
 			"name": "web",
 			"version": "0.0.1",
 			"dependencies": {
+				"axios": "^0.27.2",
 				"cookie": "^0.4.2",
+				"leaflet": "^1.8.0",
 				"lodash": "^4.17.21",
 				"lodash-es": "^4.17.21",
 				"moment": "^2.29.3",
@@ -18,8 +20,10 @@
 				"@sveltejs/adapter-auto": "next",
 				"@sveltejs/adapter-node": "^1.0.0-next.73",
 				"@sveltejs/kit": "next",
+				"@types/axios": "^0.14.0",
 				"@types/bcrypt": "^5.0.0",
 				"@types/cookie": "^0.4.1",
+				"@types/leaflet": "^1.7.10",
 				"@types/lodash": "^4.14.182",
 				"@types/lodash-es": "^4.17.6",
 				"@typescript-eslint/eslint-plugin": "^5.10.1",
@@ -231,6 +235,16 @@
 				}
 			}
 		},
+		"node_modules/@types/axios": {
+			"version": "0.14.0",
+			"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz",
+			"integrity": "sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==",
+			"deprecated": "This is a stub types definition for axios (https://github.com/mzabriskie/axios). axios provides its own type definitions, so you don't need @types/axios installed!",
+			"dev": true,
+			"dependencies": {
+				"axios": "*"
+			}
+		},
 		"node_modules/@types/bcrypt": {
 			"version": "5.0.0",
 			"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.0.tgz",
@@ -246,12 +260,27 @@
 			"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==",
 			"dev": true
 		},
+		"node_modules/@types/geojson": {
+			"version": "7946.0.8",
+			"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz",
+			"integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==",
+			"dev": true
+		},
 		"node_modules/@types/json-schema": {
 			"version": "7.0.11",
 			"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
 			"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
 			"dev": true
 		},
+		"node_modules/@types/leaflet": {
+			"version": "1.7.10",
+			"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.10.tgz",
+			"integrity": "sha512-RzK5BYwYboOXXxyF01tp8g1J8UbdRvoaf+F/jCnVaWC42+QITB6wKvUklcX7jCMRWkzTnGO9NLg7A6SzrlGALA==",
+			"dev": true,
+			"dependencies": {
+				"@types/geojson": "*"
+			}
+		},
 		"node_modules/@types/lodash": {
 			"version": "4.14.182",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -599,6 +628,11 @@
 				"node": ">=8"
 			}
 		},
+		"node_modules/asynckit": {
+			"version": "0.4.0",
+			"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+			"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+		},
 		"node_modules/autoprefixer": {
 			"version": "10.4.7",
 			"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.7.tgz",
@@ -632,6 +666,15 @@
 				"postcss": "^8.1.0"
 			}
 		},
+		"node_modules/axios": {
+			"version": "0.27.2",
+			"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
+			"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
+			"dependencies": {
+				"follow-redirects": "^1.14.9",
+				"form-data": "^4.0.0"
+			}
+		},
 		"node_modules/balanced-match": {
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -802,6 +845,17 @@
 			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
 			"dev": true
 		},
+		"node_modules/combined-stream": {
+			"version": "1.0.8",
+			"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+			"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+			"dependencies": {
+				"delayed-stream": "~1.0.0"
+			},
+			"engines": {
+				"node": ">= 0.8"
+			}
+		},
 		"node_modules/concat-map": {
 			"version": "0.0.1",
 			"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -880,6 +934,14 @@
 			"integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
 			"dev": true
 		},
+		"node_modules/delayed-stream": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+			"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+			"engines": {
+				"node": ">=0.4.0"
+			}
+		},
 		"node_modules/detect-indent": {
 			"version": "6.1.0",
 			"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
@@ -1653,6 +1715,38 @@
 			"integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
 			"dev": true
 		},
+		"node_modules/follow-redirects": {
+			"version": "1.15.1",
+			"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
+			"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
+			"funding": [
+				{
+					"type": "individual",
+					"url": "https://github.com/sponsors/RubenVerborgh"
+				}
+			],
+			"engines": {
+				"node": ">=4.0"
+			},
+			"peerDependenciesMeta": {
+				"debug": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/form-data": {
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+			"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+			"dependencies": {
+				"asynckit": "^0.4.0",
+				"combined-stream": "^1.0.8",
+				"mime-types": "^2.1.12"
+			},
+			"engines": {
+				"node": ">= 6"
+			}
+		},
 		"node_modules/fraction.js": {
 			"version": "4.2.0",
 			"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@@ -1947,6 +2041,11 @@
 				"node": ">=6"
 			}
 		},
+		"node_modules/leaflet": {
+			"version": "1.8.0",
+			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.8.0.tgz",
+			"integrity": "sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA=="
+		},
 		"node_modules/levn": {
 			"version": "0.4.1",
 			"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2031,6 +2130,25 @@
 				"node": ">=8.6"
 			}
 		},
+		"node_modules/mime-db": {
+			"version": "1.52.0",
+			"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+			"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+			"engines": {
+				"node": ">= 0.6"
+			}
+		},
+		"node_modules/mime-types": {
+			"version": "2.1.35",
+			"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+			"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+			"dependencies": {
+				"mime-db": "1.52.0"
+			},
+			"engines": {
+				"node": ">= 0.6"
+			}
+		},
 		"node_modules/min-indent": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -3276,6 +3394,15 @@
 				"svelte-hmr": "^0.14.11"
 			}
 		},
+		"@types/axios": {
+			"version": "0.14.0",
+			"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz",
+			"integrity": "sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==",
+			"dev": true,
+			"requires": {
+				"axios": "*"
+			}
+		},
 		"@types/bcrypt": {
 			"version": "5.0.0",
 			"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.0.tgz",
@@ -3291,12 +3418,27 @@
 			"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==",
 			"dev": true
 		},
+		"@types/geojson": {
+			"version": "7946.0.8",
+			"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz",
+			"integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==",
+			"dev": true
+		},
 		"@types/json-schema": {
 			"version": "7.0.11",
 			"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
 			"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
 			"dev": true
 		},
+		"@types/leaflet": {
+			"version": "1.7.10",
+			"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.10.tgz",
+			"integrity": "sha512-RzK5BYwYboOXXxyF01tp8g1J8UbdRvoaf+F/jCnVaWC42+QITB6wKvUklcX7jCMRWkzTnGO9NLg7A6SzrlGALA==",
+			"dev": true,
+			"requires": {
+				"@types/geojson": "*"
+			}
+		},
 		"@types/lodash": {
 			"version": "4.14.182",
 			"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -3521,6 +3663,11 @@
 			"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
 			"dev": true
 		},
+		"asynckit": {
+			"version": "0.4.0",
+			"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+			"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+		},
 		"autoprefixer": {
 			"version": "10.4.7",
 			"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.7.tgz",
@@ -3535,6 +3682,15 @@
 				"postcss-value-parser": "^4.2.0"
 			}
 		},
+		"axios": {
+			"version": "0.27.2",
+			"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
+			"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
+			"requires": {
+				"follow-redirects": "^1.14.9",
+				"form-data": "^4.0.0"
+			}
+		},
 		"balanced-match": {
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3644,6 +3800,14 @@
 			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
 			"dev": true
 		},
+		"combined-stream": {
+			"version": "1.0.8",
+			"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+			"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+			"requires": {
+				"delayed-stream": "~1.0.0"
+			}
+		},
 		"concat-map": {
 			"version": "0.0.1",
 			"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3699,6 +3863,11 @@
 			"integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
 			"dev": true
 		},
+		"delayed-stream": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+			"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+		},
 		"detect-indent": {
 			"version": "6.1.0",
 			"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
@@ -4192,6 +4361,21 @@
 			"integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
 			"dev": true
 		},
+		"follow-redirects": {
+			"version": "1.15.1",
+			"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
+			"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
+		},
+		"form-data": {
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+			"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+			"requires": {
+				"asynckit": "^0.4.0",
+				"combined-stream": "^1.0.8",
+				"mime-types": "^2.1.12"
+			}
+		},
 		"fraction.js": {
 			"version": "4.2.0",
 			"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@@ -4412,6 +4596,11 @@
 			"integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==",
 			"dev": true
 		},
+		"leaflet": {
+			"version": "1.8.0",
+			"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.8.0.tgz",
+			"integrity": "sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA=="
+		},
 		"levn": {
 			"version": "0.4.1",
 			"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4478,6 +4667,19 @@
 				"picomatch": "^2.3.1"
 			}
 		},
+		"mime-db": {
+			"version": "1.52.0",
+			"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+			"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+		},
+		"mime-types": {
+			"version": "2.1.35",
+			"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+			"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+			"requires": {
+				"mime-db": "1.52.0"
+			}
+		},
 		"min-indent": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
diff --git a/web/package.json b/web/package.json
index 014e2de157..6189345244 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,8 +16,10 @@
 		"@sveltejs/adapter-auto": "next",
 		"@sveltejs/adapter-node": "^1.0.0-next.73",
 		"@sveltejs/kit": "next",
+		"@types/axios": "^0.14.0",
 		"@types/bcrypt": "^5.0.0",
 		"@types/cookie": "^0.4.1",
+		"@types/leaflet": "^1.7.10",
 		"@types/lodash": "^4.14.182",
 		"@types/lodash-es": "^4.17.6",
 		"@typescript-eslint/eslint-plugin": "^5.10.1",
@@ -38,7 +40,9 @@
 	},
 	"type": "module",
 	"dependencies": {
+		"axios": "^0.27.2",
 		"cookie": "^0.4.2",
+		"leaflet": "^1.8.0",
 		"lodash": "^4.17.21",
 		"lodash-es": "^4.17.21",
 		"moment": "^2.29.3",
diff --git a/web/src/lib/components/asset-viewer/asser-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asser-viewer-nav-bar.svelte
new file mode 100644
index 0000000000..ac1ce747be
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/asser-viewer-nav-bar.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+	import { createEventDispatcher } from 'svelte';
+
+	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
+	import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
+	import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
+	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
+	import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
+	import CircleIconButton from '../shared/circle_icon_button.svelte';
+	const dispatch = createEventDispatcher();
+</script>
+
+<div class="h-16 bg-black/5 flex justify-between place-items-center px-3 transition-transform duration-200 z-[9999]">
+	<div>
+		<CircleIconButton logo={ArrowLeft} on:click={() => dispatch('goBack')} />
+	</div>
+	<div class="text-white flex gap-2">
+		<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
+		<!-- <CircleIconButton logo={DotsVertical} on:click={() => console.log('Options')} /> -->
+		<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
+	</div>
+</div>
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
new file mode 100644
index 0000000000..c19df36132
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -0,0 +1,234 @@
+<script lang="ts">
+	import { createEventDispatcher, onDestroy, onMount } from 'svelte';
+	import { fly, slide } from 'svelte/transition';
+	import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
+	import { flattenAssetGroupByDate } from '$lib/stores/assets';
+	import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
+	import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
+	import { AssetType, type ImmichAsset, type ImmichExif } from '../../models/immich-asset';
+	import PhotoViewer from './photo-viewer.svelte';
+	import DetailPanel from './detail-panel.svelte';
+	import { session } from '$app/stores';
+	import { serverEndpoint } from '../../constants';
+	import axios from 'axios';
+	import { downloadAssets } from '$lib/stores/download';
+
+	const dispatch = createEventDispatcher();
+
+	export let selectedAsset: ImmichAsset;
+	export let selectedIndex: number;
+
+	let viewDeviceId: string;
+	let viewAssetId: string;
+
+	let halfLeftHover = false;
+	let halfRightHover = false;
+	let isShowDetail = false;
+
+	onMount(() => {
+		viewAssetId = selectedAsset.id;
+		viewDeviceId = selectedAsset.deviceId;
+		pushState(viewAssetId);
+
+		document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
+	});
+
+	onDestroy(() => {
+		document.removeEventListener('keydown', (b) => {
+			console.log('destroyed', b);
+		});
+	});
+
+	const handleKeyboardPress = (key: string) => {
+		switch (key) {
+			case 'Escape':
+				closeViewer();
+				return;
+			case 'i':
+				isShowDetail = !isShowDetail;
+				return;
+			case 'ArrowLeft':
+				navigateAssetBackward();
+				return;
+			case 'ArrowRight':
+				navigateAssetForward();
+				return;
+		}
+	};
+
+	const closeViewer = () => {
+		history.pushState(null, '', `/photos`);
+		dispatch('close');
+	};
+
+	const navigateAssetForward = () => {
+		const nextAsset = $flattenAssetGroupByDate[selectedIndex + 1];
+		viewDeviceId = nextAsset.deviceId;
+		viewAssetId = nextAsset.id;
+
+		selectedIndex = selectedIndex + 1;
+		selectedAsset = $flattenAssetGroupByDate[selectedIndex];
+		pushState(viewAssetId);
+	};
+
+	const navigateAssetBackward = () => {
+		const lastAsset = $flattenAssetGroupByDate[selectedIndex - 1];
+		viewDeviceId = lastAsset.deviceId;
+		viewAssetId = lastAsset.id;
+
+		selectedIndex = selectedIndex - 1;
+		selectedAsset = $flattenAssetGroupByDate[selectedIndex];
+		pushState(viewAssetId);
+	};
+
+	const pushState = (assetId: string) => {
+		// add a URL to the browser's history
+		// changes the current URL in the address bar but doesn't perform any SvelteKit navigation
+		history.pushState(null, '', `/photos/${assetId}`);
+	};
+
+	const showDetailInfoHandler = () => {
+		isShowDetail = !isShowDetail;
+	};
+
+	const downloadFile = async () => {
+		if ($session.user) {
+			const url = `${serverEndpoint}/asset/download?aid=${selectedAsset.deviceAssetId}&did=${selectedAsset.deviceId}&isThumb=false`;
+
+			try {
+				const imageName = selectedAsset.exifInfo?.imageName ? selectedAsset.exifInfo?.imageName : selectedAsset.id;
+				const imageExtension = selectedAsset.originalPath.split('.')[1];
+				const imageFileName = imageName + '.' + imageExtension;
+
+				// If assets is already download -> return;
+				if ($downloadAssets[imageFileName]) {
+					return;
+				}
+				$downloadAssets[imageFileName] = 0;
+
+				const res = await axios.get(url, {
+					responseType: 'blob',
+					headers: {
+						Authorization: 'Bearer ' + $session.user.accessToken,
+					},
+					onDownloadProgress: (progressEvent) => {
+						if (progressEvent.lengthComputable) {
+							const total = progressEvent.total;
+							const current = progressEvent.loaded;
+							let percentCompleted = Math.floor((current / total) * 100);
+
+							$downloadAssets[imageFileName] = percentCompleted;
+						}
+					},
+				});
+
+				if (res.status === 200) {
+					const fileUrl = URL.createObjectURL(new Blob([res.data]));
+					const anchor = document.createElement('a');
+					anchor.href = fileUrl;
+					anchor.download = imageFileName;
+
+					document.body.appendChild(anchor);
+					anchor.click();
+					document.body.removeChild(anchor);
+
+					URL.revokeObjectURL(fileUrl);
+
+					// Remove item from download list
+					setTimeout(() => {
+						const copy = $downloadAssets;
+						delete copy[imageFileName];
+						$downloadAssets = copy;
+					}, 2000);
+				}
+			} catch (e) {
+				console.log('Error downloading file ', e);
+			}
+		}
+	};
+</script>
+
+<section
+	id="immich-asset-viewer"
+	class="absolute h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4  "
+>
+	<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
+		<AsserViewerNavBar on:goBack={closeViewer} on:showDetail={showDetailInfoHandler} on:download={downloadFile} />
+	</div>
+
+	<div
+		class="row-start-2 row-span-end col-start-1- col-span-full z-[1000] flex place-items-center hover:cursor-pointer w-3/4"
+		on:mouseenter={() => {
+			halfLeftHover = true;
+			halfRightHover = false;
+		}}
+		on:mouseleave={() => {
+			halfLeftHover = false;
+		}}
+		on:click={navigateAssetBackward}
+	>
+		<button
+			class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700  text-gray-500 mx-4"
+			class:navigation-button-hover={halfLeftHover}
+			on:click={navigateAssetBackward}
+		>
+			<ChevronLeft size="36" />
+		</button>
+	</div>
+
+	<div class="row-start-1 row-span-full col-start-1 col-span-4">
+		{#key selectedIndex}
+			{#if viewAssetId && viewDeviceId}
+				{#if selectedAsset.type == AssetType.IMAGE}
+					<PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} />
+				{:else}
+					<div
+						class="w-full h-full bg-immich-primary/10 flex flex-col place-items-center place-content-center "
+						on:click={closeViewer}
+					>
+						<h1 class="animate-pulse font-bold text-4xl">Video viewer is under construction</h1>
+					</div>
+				{/if}
+			{/if}
+		{/key}
+	</div>
+
+	<div
+		class="row-start-2 row-span-full col-start-3 col-span-2 z-[1000] flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end"
+		on:click={navigateAssetForward}
+		on:mouseenter={() => {
+			halfLeftHover = false;
+			halfRightHover = true;
+		}}
+		on:mouseleave={() => {
+			halfRightHover = false;
+		}}
+	>
+		<button
+			class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
+			class:navigation-button-hover={halfRightHover}
+			on:click={navigateAssetForward}
+		>
+			<ChevronRight size="36" />
+		</button>
+	</div>
+
+	{#if isShowDetail}
+		<div
+			transition:fly={{ duration: 150 }}
+			id="detail-panel"
+			class="bg-immich-bg w-[360px] row-span-full transition-all "
+			translate="yes"
+		>
+			<DetailPanel asset={selectedAsset} on:close={() => (isShowDetail = false)} />
+		</div>
+	{/if}
+</section>
+
+<style>
+	.navigation-button-hover {
+		background-color: rgb(107 114 128 / var(--tw-bg-opacity));
+		color: rgb(55 65 81 / var(--tw-text-opacity));
+		transition: all 150ms;
+	}
+</style>
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
new file mode 100644
index 0000000000..f607f9f98d
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -0,0 +1,164 @@
+<script lang="ts">
+	import Close from 'svelte-material-icons/Close.svelte';
+	import Calendar from 'svelte-material-icons/Calendar.svelte';
+	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
+	import CameraIris from 'svelte-material-icons/CameraIris.svelte';
+	import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
+	import moment from 'moment';
+	import type { ImmichAsset } from '../../models/immich-asset';
+	import { createEventDispatcher, onMount } from 'svelte';
+	import { browser } from '$app/env';
+
+	// Map Property
+	let map: any;
+	let leaflet: any;
+	let marker: any;
+
+	export let asset: ImmichAsset;
+	$: {
+		// Redraw map
+		if (map && leaflet) {
+			map.removeLayer(marker);
+			map.setView([asset.exifInfo?.latitude || 0, asset.exifInfo?.longitude || 0], 17);
+			marker = leaflet.marker([asset.exifInfo?.latitude || 0, asset.exifInfo?.longitude || 0]);
+			map.addLayer(marker);
+		}
+	}
+
+	onMount(async () => {
+		if (browser) {
+			// @ts-ignore
+			leaflet = await import('leaflet');
+			map = leaflet.map('map').setView([asset.exifInfo?.latitude || 0, asset.exifInfo?.longitude || 0], 17);
+
+			leaflet
+				.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+					attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
+				})
+				.addTo(map);
+
+			marker = leaflet.marker([asset.exifInfo?.latitude || 0, asset.exifInfo?.longitude || 0]);
+
+			map.addLayer(marker);
+		}
+	});
+
+	const dispatch = createEventDispatcher();
+	const getHumanReadableString = (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`;
+		}
+	};
+</script>
+
+<section class="p-2">
+	<div class="flex place-items-center gap-2">
+		<button
+			class="rounded-full p-3 flex place-items-center place-content-center hover:bg-gray-200 transition-colors"
+			on:click={() => dispatch('close')}
+		>
+			<Close size="24" color="#232323" />
+		</button>
+
+		<p class="text-black text-lg">Info</p>
+	</div>
+
+	<div class="px-4 py-4">
+		<p class="text-sm pb-4">DETAILS</p>
+
+		{#if asset.exifInfo?.dateTimeOriginal}
+			<div class="flex gap-4 py-4">
+				<div>
+					<Calendar size="24" />
+				</div>
+
+				<div>
+					<p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD')}</p>
+					<div class="flex gap-2 text-sm">
+						<p>
+							{moment(
+								asset.exifInfo.dateTimeOriginal
+									.toString()
+									.slice(0, asset.exifInfo.dateTimeOriginal.toString().length - 1),
+							).format('ddd, hh:mm A')}
+						</p>
+						<p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p>
+					</div>
+				</div>
+			</div>{/if}
+
+		{#if asset.exifInfo?.fileSizeInByte}
+			<div class="flex gap-4 py-4">
+				<div><ImageOutline size="24" /></div>
+
+				<div>
+					<p>{`${asset.exifInfo.imageName}.${asset.originalPath.split('.')[1]}` || ''}</p>
+					<div class="flex text-sm gap-2">
+						<p>{((asset.exifInfo.exifImageHeight * asset.exifInfo.exifImageWidth) / 1_000_000).toFixed(0)}MP</p>
+						<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
+						<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
+					</div>
+				</div>
+			</div>
+		{/if}
+
+		{#if asset.exifInfo?.fNumber}
+			<div class="flex gap-4 py-4">
+				<div><CameraIris size="24" /></div>
+
+				<div>
+					<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
+					<div class="flex text-sm gap-2">
+						<p>{`f/${asset.exifInfo.fNumber}` || ''}</p>
+						<p>{`1/${1 / asset.exifInfo.exposureTime}` || ''}</p>
+						<p>{`${asset.exifInfo.focalLength}mm` || ''}</p>
+						<p>{`ISO${asset.exifInfo.iso}` || ''}</p>
+					</div>
+				</div>
+			</div>
+		{/if}
+
+		{#if asset.exifInfo?.city}
+			<div class="flex gap-4 py-4">
+				<div><MapMarkerOutline size="24" /></div>
+
+				<div>
+					<p>{asset.exifInfo.city}</p>
+					<div class="flex text-sm gap-2">
+						<p>{asset.exifInfo.state},</p>
+						<p>{asset.exifInfo.country}</p>
+					</div>
+				</div>
+			</div>
+		{/if}
+	</div>
+</section>
+
+<div class={`${asset.exifInfo?.latitude ? 'visible' : 'hidden'}`}>
+	<div class="h-[360px] w-full" id="map" />
+</div>
+
+<style>
+	@import 'https://unpkg.com/leaflet@1.7.1/dist/leaflet.css';
+</style>
diff --git a/web/src/lib/components/asset-viewer/download-panel.svelte b/web/src/lib/components/asset-viewer/download-panel.svelte
new file mode 100644
index 0000000000..b18a704e33
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/download-panel.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+	import { downloadAssets, isDownloading } from '$lib/stores/download';
+	import { fly, slide } from 'svelte/transition';
+</script>
+
+{#if $isDownloading}
+	<div
+		transition:fly={{ x: -100, duration: 350 }}
+		class="w-[315px] max-h-[270px] bg-immich-bg border rounded-2xl shadow-sm absolute bottom-10 left-2 p-4 z-[10000] text-sm"
+	>
+		<p class="text-gray-500 text-xs mb-2">DOWNLOADING</p>
+		<div class="max-h-[200px] my-2 overflow-y-auto mb-2 flex flex-col text-sm">
+			{#each Object.keys($downloadAssets) as fileName}
+				<div class="mb-2" transition:slide>
+					<p class="font-medium text-xs truncate">■ {fileName}</p>
+					<div class="flex flex-row-reverse place-items-center gap-5">
+						<p><span class="text-immich-primary font-medium">{$downloadAssets[fileName]}</span>/100</p>
+						<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700">
+							<div class="bg-immich-primary h-[7px] rounded-full" style={`width: ${$downloadAssets[fileName]}%`} />
+						</div>
+					</div>
+				</div>
+			{/each}
+		</div>
+	</div>
+{/if}
diff --git a/web/src/lib/components/photos/immich-thumbnail.svelte b/web/src/lib/components/asset-viewer/immich-thumbnail.svelte
similarity index 84%
rename from web/src/lib/components/photos/immich-thumbnail.svelte
rename to web/src/lib/components/asset-viewer/immich-thumbnail.svelte
index 9cdfd0a633..6b7e357cc4 100644
--- a/web/src/lib/components/photos/immich-thumbnail.svelte
+++ b/web/src/lib/components/asset-viewer/immich-thumbnail.svelte
@@ -4,8 +4,7 @@
 	import { createEventDispatcher, onDestroy } from 'svelte';
 	import { fade } from 'svelte/transition';
 	import { serverEndpoint } from '../../constants';
-
-	import IntersectionObserver from '$lib/components/photos/intersection-observer.svelte';
+	import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
 	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
 	import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
 
@@ -57,6 +56,7 @@
 			return videoData;
 		}
 	};
+
 	const parseVideoDuration = (duration: string) => {
 		const timePart = duration.split(':');
 		const hours = timePart[0];
@@ -71,11 +71,21 @@
 	};
 
 	onDestroy(() => URL.revokeObjectURL(imageContent));
+
+	const getSize = () => {
+		if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
+			return 'w-[176px] h-[235px]';
+		} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
+			return 'w-[313px] h-[235px]';
+		} else {
+			return 'w-[235px] h-[235px]';
+		}
+	};
 </script>
 
 <IntersectionObserver once={true} let:intersecting>
 	<div
-		class="h-[200px] w-[200px] bg-gray-100 relative hover:cursor-pointer"
+		class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`}
 		on:mouseenter={() => (mouseOver = true)}
 		on:mouseleave={() => (mouseOver = false)}
 		on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })}
@@ -104,13 +114,12 @@
 
 		{#if intersecting}
 			{#await loadImageData()}
-				<div class="bg-immich-primary/10 h-[200px] w-[200px] flex place-items-center place-content-center">...</div>
+				<div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div>
 			{:then imageData}
 				<img
-					in:fade={{ duration: 200 }}
 					src={imageData}
 					alt={asset.id}
-					class="object-cover h-[200px] w-[200px] transition-all duration-100 z-0"
+					class={`object-cover ${getSize()} transition-all duration-100 z-0`}
 					loading="lazy"
 				/>
 			{/await}
diff --git a/web/src/lib/components/photos/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte
similarity index 100%
rename from web/src/lib/components/photos/intersection-observer.svelte
rename to web/src/lib/components/asset-viewer/intersection-observer.svelte
diff --git a/web/src/lib/components/photos/photo_viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
similarity index 71%
rename from web/src/lib/components/photos/photo_viewer.svelte
rename to web/src/lib/components/asset-viewer/photo-viewer.svelte
index b4a7d03985..3a148947b3 100644
--- a/web/src/lib/components/photos/photo_viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -3,12 +3,13 @@
 	import { serverEndpoint } from '$lib/constants';
 	import { fade } from 'svelte/transition';
 
-	import type { ImmichAsset } from '$lib/models/immich-asset';
+	import type { ImmichAsset, ImmichExif } from '$lib/models/immich-asset';
 	import { createEventDispatcher, onMount } from 'svelte';
 	import LoadingSpinner from '../shared/loading-spinner.svelte';
 
 	export let assetId: string;
 	export let deviceId: string;
+
 	let assetInfo: ImmichAsset;
 
 	const dispatch = createEventDispatcher();
@@ -41,22 +42,18 @@
 	};
 </script>
 
-<div on:click={() => dispatch('close')} class="h-screen">
+<div transition:fade={{ duration: 150 }} class="flex place-items-center place-content-center h-full select-none">
 	{#if assetInfo}
 		{#await loadAssetData()}
-			<div class="flex place-items-center place-content-center h-full">
-				<LoadingSpinner />
-			</div>
+			<LoadingSpinner />
 		{:then assetData}
-			<div class="flex place-items-center place-content-center h-full">
-				<img
-					in:fade={{ duration: 200 }}
-					src={assetData}
-					alt={assetId}
-					class="object-cover h-full transition-all duration-100 z-0"
-					loading="lazy"
-				/>
-			</div>
+			<img
+				transition:fade={{ duration: 150 }}
+				src={assetData}
+				alt={assetId}
+				class="object-contain h-full transition-all"
+				loading="lazy"
+			/>
 		{/await}
 	{/if}
 </div>
diff --git a/web/src/lib/components/shared/circle_icon_button.svelte b/web/src/lib/components/shared/circle_icon_button.svelte
new file mode 100644
index 0000000000..0753211703
--- /dev/null
+++ b/web/src/lib/components/shared/circle_icon_button.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+	import { createEventDispatcher } from 'svelte';
+
+	export let logo: any;
+	export let backgroundColor: string = '';
+	export let logoColor: string = '';
+
+	const dispatch = createEventDispatcher();
+</script>
+
+<button
+	class="rounded-full p-3 flex place-items-center place-content-center text-gray-50 hover:bg-gray-800"
+	class:background-color={backgroundColor}
+	class:color={logoColor}
+	on:click={() => dispatch('click')}
+>
+	<svelte:component this={logo} size="24" />
+</button>
diff --git a/web/src/lib/components/shared/navigation-bar.svelte b/web/src/lib/components/shared/navigation-bar.svelte
index 8e3cf47798..af253c7c05 100644
--- a/web/src/lib/components/shared/navigation-bar.svelte
+++ b/web/src/lib/components/shared/navigation-bar.svelte
@@ -1,4 +1,5 @@
 <script lang="ts">
+	import { goto } from '$app/navigation';
 	import { page } from '$app/stores';
 	import type { ImmichUser } from '$lib/models/immich-user';
 	import { onMount } from 'svelte';
@@ -11,13 +12,19 @@
 	let shouldShowProfileImage = false;
 
 	onMount(async () => {
-		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`);
+		const res = await fetch(`${serverEndpoint}/user/profile-image/${user.id}`, { method: 'GET' });
 
 		if (res.status == 200) shouldShowProfileImage = true;
 	});
+
 	const getFirstLetter = (text?: string) => {
 		return text?.charAt(0).toUpperCase();
 	};
+
+	const navigateToAdmin = () => {
+		console.log('Navigating to admin page');
+		goto('/admin');
+	};
 </script>
 
 <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm">
@@ -33,11 +40,11 @@
 			<!-- <div>Upload</div> -->
 
 			{#if user.isAdmin}
-				<a
+				<button
 					class={`hover:text-immich-primary font-medium ${
 						$page.url.pathname == '/admin' && 'text-immich-primary underline'
 					}`}
-					href="/admin">Administration</a
+					on:click={navigateToAdmin}>Administration</button
 				>
 			{/if}
 
diff --git a/web/src/lib/components/shared/side-bar-button.svelte b/web/src/lib/components/shared/side-bar-button.svelte
index 7d36a25880..eeb02a2232 100644
--- a/web/src/lib/components/shared/side-bar-button.svelte
+++ b/web/src/lib/components/shared/side-bar-button.svelte
@@ -18,8 +18,8 @@
 
 <div
 	on:click={onButtonClicked}
-	class={`flex gap-4 place-items-center pl-5 py-3 rounded-tr-xl rounded-br-xl hover:bg-gray-200 hover:text-immich-primary hover:cursor-pointer
-    ${isSelected && 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/50'}
+	class={`flex gap-4 place-items-center pl-5 py-3 rounded-tr-full rounded-br-full hover:bg-gray-200 hover:text-immich-primary hover:cursor-pointer
+    ${isSelected && 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/25'}
   `}
 >
 	<svelte:component this={logo} size="24" />
diff --git a/web/src/lib/stores/download.ts b/web/src/lib/stores/download.ts
new file mode 100644
index 0000000000..e87d2a8703
--- /dev/null
+++ b/web/src/lib/stores/download.ts
@@ -0,0 +1,13 @@
+import { writable, derived } from 'svelte/store';
+
+export const downloadAssets = writable<Record<string, number>>({});
+
+
+export const isDownloading = derived(downloadAssets, ($downloadAssets) => {
+  if (Object.keys($downloadAssets).length == 0) {
+    return false;
+  }
+
+  return true;
+})
+
diff --git a/web/src/routes/__layout.svelte b/web/src/routes/__layout.svelte
index 13c5700990..28c9692ed0 100644
--- a/web/src/routes/__layout.svelte
+++ b/web/src/routes/__layout.svelte
@@ -1,9 +1,20 @@
+<script context="module" lang="ts">
+	import type { Load } from '@sveltejs/kit';
+
+	export const load: Load = async ({ url }) => ({ props: { url } });
+</script>
+
 <script lang="ts">
+	import '../app.css';
+
+	import { fly, slide, blur } from 'svelte/transition';
+	import { quintOut } from 'svelte/easing';
 	import { getRequest } from '$lib/api';
 	import { onDestroy } from 'svelte';
-	import '../app.css';
-	import { serverEndpoint } from '../lib/constants';
+	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
+	import { serverEndpoint } from '$lib/constants';
 
+	export let url: string;
 	let endpoint = serverEndpoint;
 	let isServerOk = true;
 
@@ -18,7 +29,12 @@
 </script>
 
 <main>
-	<slot />
+	{#key url}
+		<div transition:blur={{ duration: 250 }}>
+			<slot />
+			<DownloadPanel />
+		</div>
+	{/key}
 </main>
 
 <footer
diff --git a/web/src/routes/photos/[assetId].svelte b/web/src/routes/photos/[assetId].svelte
new file mode 100644
index 0000000000..bee566632d
--- /dev/null
+++ b/web/src/routes/photos/[assetId].svelte
@@ -0,0 +1,20 @@
+<script context="module" lang="ts">
+	export const prerender = false;
+
+	import type { Load } from '@sveltejs/kit';
+
+	export const load: Load = async ({ session }) => {
+		console.log('navigating to unknown paage');
+		if (!session.user) {
+			return {
+				status: 302,
+				redirect: '/auth/login',
+			};
+		}
+
+		return {
+			status: 302,
+			redirect: '/photos',
+		};
+	};
+</script>
diff --git a/web/src/routes/photos/index.svelte b/web/src/routes/photos/index.svelte
index 327a29c6c7..534db1b2a1 100644
--- a/web/src/routes/photos/index.svelte
+++ b/web/src/routes/photos/index.svelte
@@ -27,20 +27,18 @@
 	import NavigationBar from '../../lib/components/shared/navigation-bar.svelte';
 	import SideBarButton from '$lib/components/shared/side-bar-button.svelte';
 	import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
-	import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
-	import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
+
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 	import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
 	import { onMount } from 'svelte';
-	import { fade, fly } from 'svelte/transition';
+	import { fly } from 'svelte/transition';
 	import { session } from '$app/stores';
 	import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
-	import ImmichThumbnail from '../../lib/components/photos/immich-thumbnail.svelte';
+	import ImmichThumbnail from '../../lib/components/asset-viewer/immich-thumbnail.svelte';
 	import moment from 'moment';
-	import PhotoViewer from '../../lib/components/photos/photo_viewer.svelte';
 	import type { ImmichAsset } from '../../lib/models/immich-asset';
-	import { AssetType } from '../../lib/models/immich-asset';
-	import LoadingSpinner from '../../lib/components/shared/loading-spinner.svelte';
+	import AssetViewer from '../../lib/components/asset-viewer/asset-viewer.svelte';
+	import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte';
 
 	export let user: ImmichUser;
 	let selectedAction: AppSideBarSelection;
@@ -52,8 +50,6 @@
 	}
 
 	let isShowAsset = false;
-	let viewDeviceId: string = '';
-	let viewAssetId: string = '';
 	let currentViewAssetIndex = 0;
 	let currentSelectedAsset: ImmichAsset;
 
@@ -78,30 +74,10 @@
 	const viewAssetHandler = (event: CustomEvent) => {
 		const { assetId, deviceId }: { assetId: string; deviceId: string } = event.detail;
 
-		viewDeviceId = deviceId;
-		viewAssetId = assetId;
-
 		currentViewAssetIndex = $flattenAssetGroupByDate.findIndex((a) => a.id == assetId);
 		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
 		isShowAsset = true;
-	};
-
-	const navigateAssetForward = () => {
-		const nextAsset = $flattenAssetGroupByDate[currentViewAssetIndex + 1];
-		viewDeviceId = nextAsset.deviceId;
-		viewAssetId = nextAsset.id;
-
-		currentViewAssetIndex = currentViewAssetIndex + 1;
-		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
-	};
-
-	const navigateAssetBackward = () => {
-		const lastAsset = $flattenAssetGroupByDate[currentViewAssetIndex - 1];
-		viewDeviceId = lastAsset.deviceId;
-		viewAssetId = lastAsset.id;
-
-		currentViewAssetIndex = currentViewAssetIndex - 1;
-		currentSelectedAsset = $flattenAssetGroupByDate[currentViewAssetIndex];
+		// pushState(assetId);
 	};
 </script>
 
@@ -137,7 +113,7 @@
 						on:mouseleave={() => (isMouseOverGroup = false)}
 					>
 						<!-- Date group title -->
-						<p class="font-medium text-sm text-immich-primary mb-2 flex place-items-center h-6">
+						<p class="font-medium text-sm text-black mb-2 flex place-items-center h-6">
 							{#if selectedGroupThumbnail === groupIndex && isMouseOverGroup}
 								<div
 									in:fly={{ x: -24, duration: 200, opacity: 0.5 }}
@@ -152,7 +128,7 @@
 						</p>
 
 						<!-- Image grid -->
-						<div class="flex flex-wrap gap-2">
+						<div class="flex flex-wrap gap-1">
 							{#each assetsInDateGroup as asset}
 								<ImmichThumbnail
 									{asset}
@@ -171,34 +147,9 @@
 
 <!-- Overlay Asset Viewer -->
 {#if isShowAsset}
-	<section
-		class="absolute w-screen h-screen top-0 overflow-y-hidden bg-black z-[9999] flex justify-between place-items-center"
-	>
-		<button
-			class="rounded-full p-4 hover:bg-gray-500 hover:text-gray-700  text-gray-500 mx-4"
-			on:click={navigateAssetBackward}
-		>
-			<ChevronLeft size="48" />
-		</button>
-
-		{#key currentViewAssetIndex}
-			{#if currentSelectedAsset.type == AssetType.IMAGE}
-				<PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={() => (isShowAsset = false)} />
-			{:else}
-				<div
-					class="w-full h-full bg-immich-primary/10 flex flex-col place-items-center place-content-center "
-					on:click={() => (isShowAsset = false)}
-				>
-					<h1 class="animate-pulse font-bold text-4xl">Video viewer is under construction</h1>
-				</div>
-			{/if}
-		{/key}
-
-		<button
-			class="rounded-full p-4 hover:bg-gray-500 hover:text-gray-700 bg-black text-gray-500 mx-4"
-			on:click={navigateAssetForward}
-		>
-			<ChevronRight size="48" />
-		</button>
-	</section>
+	<AssetViewer
+		selectedAsset={currentSelectedAsset}
+		selectedIndex={currentViewAssetIndex}
+		on:close={() => (isShowAsset = false)}
+	/>
 {/if}