From 07538299cfe5fae22f08aa281440fb6c24c02b50 Mon Sep 17 00:00:00 2001 From: David Kerr <31540828+davidakerr@users.noreply.github.com> Date: Wed, 21 Aug 2024 19:49:37 +0100 Subject: [PATCH] feat: folder view (#11880) * feat: folder view poc * fix(folder-view): ui modifications * fix(folder-view): improves utility return types * fix(folder-view): update getAssetsByOriginalPath Endpoint now only returns direct children of the path instead of all images in all subfolders. Functions renamed and scoped to "folder", endpoints renamed * fix(folder-view): improve typing * fix(folder-view): replaces css with tailwind * fix(folder-view): includes folders in main panel * feat(folder-view): folder cache implementation * fix(folder-view): can now search for absolute paths * fix(folder-view): sets default sort to alphabetical by filename * refactor/styling the browser view * double click to navigate * folder tree * use correct side bar icon * styling when selected * correct open icon * folder layout * return assetReponseDto * it's alive * update new api * more styling for folder tree * use query params and path viewer * use arrow up left for parent folder backward navigation * use arrow up left for parent folder backward navigation * encode URL * handle long folder name * refactor to the view controller * remove unused code * clear cache when logout * cleaning up * cleaning up web * clean as new * clean as new * pr feedback + show asset name * add tests * add tests * remove generated file * lint * revert docker-compose.dev file * Update server/src/services/view.service.ts Co-authored-by: Jason Rasmussen * Update server/src/services/view.service.ts Co-authored-by: Jason Rasmussen --------- Co-authored-by: Alex Tran Co-authored-by: Jason Rasmussen --- mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/view_api.dart | 114 ++++++++++++++++++ open-api/immich-openapi-specs.json | 79 ++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 20 +++ server/src/controllers/index.ts | 2 + server/src/controllers/view.controller.ts | 24 ++++ server/src/interfaces/asset.interface.ts | 2 + server/src/queries/asset.repository.sql | 110 +++++++++++++++++ server/src/repositories/asset.repository.ts | 44 +++++++ server/src/services/index.ts | 2 + server/src/services/view.service.spec.ts | 55 +++++++++ server/src/services/view.service.ts | 18 +++ .../repositories/asset.repository.mock.ts | 2 + web/package-lock.json | 2 +- .../components/folder-tree/folder-tree.svelte | 65 ++++++++++ .../layouts/user-page-layout.svelte | 4 + .../gallery-viewer/gallery-viewer.svelte | 9 ++ .../navigation-bar/navigation-bar.svelte | 2 + .../side-bar/folder-browser-sidebar.svelte | 32 +++++ .../side-bar/folder-side-bar.svelte | 8 ++ .../side-bar/side-bar.svelte | 3 + web/src/lib/constants.ts | 2 + web/src/lib/i18n/en.json | 2 + web/src/lib/stores/folders.store.ts | 69 +++++++++++ web/src/lib/utils/folder-utils.ts | 18 +++ .../[[assetId=id]]/+page.svelte | 112 +++++++++++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 41 +++++++ 28 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 mobile/openapi/lib/api/view_api.dart create mode 100644 server/src/controllers/view.controller.ts create mode 100644 server/src/services/view.service.spec.ts create mode 100644 server/src/services/view.service.ts create mode 100644 web/src/lib/components/folder-tree/folder-tree.svelte create mode 100644 web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte create mode 100644 web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte create mode 100644 web/src/lib/stores/folders.store.ts create mode 100644 web/src/lib/utils/folder-utils.ts create mode 100644 web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4e7dfb35fe..1da4463a12 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -243,6 +243,8 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | *UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | +*ViewApi* | [**getAssetsByOriginalPath**](doc//ViewApi.md#getassetsbyoriginalpath) | **GET** /view/folder | +*ViewApi* | [**getUniqueOriginalPaths**](doc//ViewApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | ## Documentation For Models diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1179bff56d..05a43c8af7 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -62,6 +62,7 @@ part 'api/timeline_api.dart'; part 'api/trash_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; +part 'api/view_api.dart'; part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/view_api.dart new file mode 100644 index 0000000000..f4489f2d1a --- /dev/null +++ b/mobile/openapi/lib/api/view_api.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ViewApi { + ViewApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'GET /view/folder' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] path (required): + Future getAssetsByOriginalPathWithHttpInfo(String path,) async { + // ignore: prefer_const_declarations + final path = r'/view/folder'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'path', path)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] path (required): + Future?> getAssetsByOriginalPath(String path,) async { + final response = await getAssetsByOriginalPathWithHttpInfo(path,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'GET /view/folder/unique-paths' operation and returns the [Response]. + Future getUniqueOriginalPathsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/view/folder/unique-paths'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getUniqueOriginalPaths() async { + final response = await getUniqueOriginalPathsWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 35fecdb1ee..02a887370a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7250,6 +7250,85 @@ "Users" ] } + }, + "/view/folder": { + "get": { + "operationId": "getAssetsByOriginalPath", + "parameters": [ + { + "name": "path", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "View" + ] + } + }, + "/view/folder/unique-paths": { + "get": { + "operationId": "getUniqueOriginalPaths", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "View" + ] + } } }, "info": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 80394ad901..9642f4c817 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3114,6 +3114,26 @@ export function getProfileImage({ id }: { ...opts })); } +export function getAssetsByOriginalPath({ path }: { + path: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/view/folder${QS.query(QS.explode({ + path + }))}`, { + ...opts + })); +} +export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: string[]; + }>("/view/folder/unique-paths", { + ...opts + })); +} export enum ReactionLevel { Album = "album", Asset = "asset" diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 3a832c1a1b..ab569d7434 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -32,6 +32,7 @@ import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; +import { ViewController } from 'src/controllers/view.controller'; export const controllers = [ APIKeyController, @@ -68,4 +69,5 @@ export const controllers = [ TrashController, UserAdminController, UserController, + ViewController, ]; diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts new file mode 100644 index 0000000000..b5e281e093 --- /dev/null +++ b/server/src/controllers/view.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { ViewService } from 'src/services/view.service'; + +@ApiTags('View') +@Controller('view') +export class ViewController { + constructor(private service: ViewService) {} + + @Get('folder/unique-paths') + @Authenticated() + getUniqueOriginalPaths(@Auth() auth: AuthDto): Promise { + return this.service.getUniqueOriginalPaths(auth); + } + + @Get('folder') + @Authenticated() + getAssetsByOriginalPath(@Auth() auth: AuthDto, @Query('path') path: string): Promise { + return this.service.getAssetsByOriginalPath(auth, path); + } +} diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 05ea84a5ae..666c6d3f7e 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -145,6 +145,8 @@ export type AssetPathEntity = Pick; + getUniqueOriginalPaths(userId: string): Promise; create(asset: AssetCreate): Promise; getByIds( ids: string[], diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index c9bd8083bb..fd5dc15c0a 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1168,6 +1168,116 @@ WHERE AND "asset"."ownerId" IN ($1) AND "asset"."updatedAt" > $2 +-- AssetRepository.getAssetsByOriginalPath +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "exifInfo"."projectionType" AS "exifInfo_projectionType", + "exifInfo"."city" AS "exifInfo_city", + "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", + "exifInfo"."state" AS "exifInfo_state", + "exifInfo"."country" AS "exifInfo_country", + "exifInfo"."make" AS "exifInfo_make", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", + "exifInfo"."fps" AS "exifInfo_fps", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" +FROM + "assets" "asset" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND ( + "asset"."originalPath" LIKE $2 + AND "asset"."originalPath" NOT LIKE $3 + ) +ORDER BY + regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC + -- AssetRepository.upsertFile INSERT INTO "asset_files" ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8e97d54817..50ed724f9f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -821,6 +821,50 @@ export class AssetRepository implements IAssetRepository { return builder.getMany(); } + async getUniqueOriginalPaths(userId: string): Promise { + const builder = this.getBuilder({ + userIds: [userId], + exifInfo: false, + withStacked: false, + isArchived: false, + isTrashed: false, + }); + + const results = await builder + .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') + .getRawMany(); + + return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { + const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); + + const builder = this.getBuilder({ + userIds: [userId], + exifInfo: true, + withStacked: false, + isArchived: false, + isTrashed: false, + }); + + const assets = await builder + .where('asset.ownerId = :userId', { userId }) + .andWhere( + new Brackets((qb) => { + qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( + 'asset.originalPath NOT LIKE :notLikePath', + { notLikePath: `%${normalizedPath}/%/%` }, + ); + }), + ) + .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') + .getMany(); + + return assets; + } + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 5a2e53927a..2cfbdb40c2 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -37,6 +37,7 @@ import { TrashService } from 'src/services/trash.service'; import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; +import { ViewService } from 'src/services/view.service'; export const services = [ APIKeyService, @@ -78,4 +79,5 @@ export const services = [ UserAdminService, UserService, VersionService, + ViewService, ]; diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts new file mode 100644 index 0000000000..3f9aa9f2f5 --- /dev/null +++ b/server/src/services/view.service.spec.ts @@ -0,0 +1,55 @@ +import { mapAsset } from 'src/dtos/asset-response.dto'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; + +import { ViewService } from 'src/services/view.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; + +import { Mocked } from 'vitest'; + +describe(ViewService.name, () => { + let sut: ViewService; + let assetMock: Mocked; + + beforeEach(() => { + assetMock = newAssetRepositoryMock(); + + sut = new ViewService(assetMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getUniqueOriginalPaths', () => { + it('should return unique original paths', async () => { + const mockPaths = ['path1', 'path2', 'path3']; + assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + + const result = await sut.getUniqueOriginalPaths(authStub.admin); + + expect(result).toEqual(mockPaths); + expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + }); + }); + + describe('getAssetsByOriginalPath', () => { + it('should return assets by original path', async () => { + const path = '/asset'; + + const asset1 = { ...assetStub.image, originalPath: '/asset/path1' }; + const asset2 = { ...assetStub.image, originalPath: '/asset/path2' }; + + const mockAssets = [asset1, asset2]; + + const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); + + assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); + + const result = await sut.getAssetsByOriginalPath(authStub.admin, path); + expect(result).toEqual(mockAssetReponseDto); + await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + }); + }); +}); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts new file mode 100644 index 0000000000..1bf9a3408c --- /dev/null +++ b/server/src/services/view.service.ts @@ -0,0 +1,18 @@ +import { Inject } from '@nestjs/common'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; + +export class ViewService { + constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} + + getUniqueOriginalPaths(auth: AuthDto): Promise { + return this.assetRepository.getUniqueOriginalPaths(auth.user.id); + } + + async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { + const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path); + + return assets.map((asset) => mapAsset(asset, { auth })); + } +} diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 9320639b93..69f07bf105 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -43,5 +43,7 @@ export const newAssetRepositoryMock = (): Mocked => { getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), + getAssetsByOriginalPath: vitest.fn(), + getUniqueOriginalPaths: vitest.fn(), }; }; diff --git a/web/package-lock.json b/web/package-lock.json index fee3148631..73682c06cb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -79,7 +79,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.14", + "@types/node": "^20.14.15", "typescript": "^5.3.3" } }, diff --git a/web/src/lib/components/folder-tree/folder-tree.svelte b/web/src/lib/components/folder-tree/folder-tree.svelte new file mode 100644 index 0000000000..7f8289ce74 --- /dev/null +++ b/web/src/lib/components/folder-tree/folder-tree.svelte @@ -0,0 +1,65 @@ + + + + + +{#if isExpanded} +
    + {#each Object.entries(content) as [subFolderName, subContent], index (index)} +
  • + +
  • + {/each} +
+{/if} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 8222007d57..495c1aae30 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -3,6 +3,7 @@ import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte'; import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte'; + import FolderSideBar from '$lib/components/shared-components/side-bar/folder-side-bar.svelte'; export let hideNavbar = false; export let showUploadButton = false; @@ -10,6 +11,7 @@ export let description: string | undefined = undefined; export let scrollbar = true; export let admin = false; + export let isFolderView = false; $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; @@ -29,6 +31,8 @@ {#if admin} + {:else if isFolderView} + {:else} {/if} diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 337b681a22..f977d91a99 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -23,6 +23,7 @@ export let disableAssetSelect = false; export let showArchiveIcon = false; export let viewport: Viewport; + export let showAssetName = false; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -121,6 +122,7 @@ class="absolute" style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i] .top}px; left: {geometry.boxes[i].left}px" + title={showAssetName ? asset.originalFileName : ''} > + {#if showAssetName} +
+ {asset.originalFileName} +
+ {/if} {/each} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 52825850f3..b8df9cbbbe 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -19,6 +19,7 @@ import AccountInfoPanel from './account-info-panel.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; + import { foldersStore } from '$lib/stores/folders.store'; export let showUploadButton = true; @@ -38,6 +39,7 @@ window.location.href = redirectUri; } resetSavedUser(); + foldersStore.clearCache(); }; diff --git a/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte new file mode 100644 index 0000000000..8e744c23aa --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte @@ -0,0 +1,32 @@ + + +
+
{$t('explorer').toUpperCase()}
+
+ {#each Object.entries(folderTree) as [folderName, content]} + + {/each} +
+
diff --git a/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte new file mode 100644 index 0000000000..ff1cd514e6 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 05ae856919..1985160b27 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -20,6 +20,7 @@ mdiTrashCanOutline, mdiToolbox, mdiToolboxOutline, + mdiFolderOutline, } from '@mdi/js'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.svelte'; @@ -104,6 +105,8 @@ + + ({ ...state, uniquePaths })); + } + } + + async function fetchAssetsByPath(path: string) { + const state = get(foldersStore); + + if (state.assets[path]) { + return; + } + + const assets = await getAssetsByOriginalPath({ path }); + if (assets) { + update((state) => ({ + ...state, + assets: { ...state.assets, [path]: assets }, + })); + } + } + + function clearCache() { + set(initialState); + } + + return { + subscribe, + fetchUniquePaths, + fetchAssetsByPath, + clearCache, + }; +} + +export const foldersStore = createFoldersStore(); diff --git a/web/src/lib/utils/folder-utils.ts b/web/src/lib/utils/folder-utils.ts new file mode 100644 index 0000000000..0305f89672 --- /dev/null +++ b/web/src/lib/utils/folder-utils.ts @@ -0,0 +1,18 @@ +export interface RecursiveObject { + [key: string]: RecursiveObject; +} + +export function buildFolderTree(paths: string[]) { + const root: RecursiveObject = {}; + for (const path of paths) { + const parts = path.split('/'); + let current = root; + for (const part of parts) { + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + } + return root; +} diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..bf914ff8f9 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,112 @@ + + + +
+ {#if data.path} + + {/if} + +
+ + + + {#each pathSegments as segment, index} + +

+ {#if index < pathSegments.length - 1} + + {/if} +

+ {/each} +
+
+ +
+ + {#if data.currentFolders.length > 0} +
+ {#each data.currentFolders as folder} + + {/each} +
+ {/if} + + +
0} + > + {#if data.pathAssets && data.pathAssets.length > 0} + + {/if} +
+
+
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..f04d7840e5 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,41 @@ +import { foldersStore } from '$lib/stores/folders.store'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + await foldersStore.fetchUniquePaths(); + const { uniquePaths } = get(foldersStore); + + let pathAssets = null; + const path = url.searchParams.get('folder'); + + if (path) { + await foldersStore.fetchAssetsByPath(path); + const { assets } = get(foldersStore); + pathAssets = assets[path] || null; + } + + const currentPath = path ? `${path}/`.replaceAll('//', '/') : ''; + + const currentFolders = (uniquePaths || []) + .filter((path) => path.startsWith(currentPath) && path !== currentPath) + .map((path) => path.replaceAll(currentPath, '').split('/')[0]) + .filter((value, index, self) => self.indexOf(value) === index); + + return { + asset, + path, + currentFolders, + pathAssets, + meta: { + title: $t('folders'), + }, + }; +}) satisfies PageLoad;