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: '© <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}