1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web+server): map improvements (#2498)

* feat(web+server): map improvements

* add number format double to fix mobile
This commit is contained in:
Michel Heusschen 2023-05-21 08:26:06 +02:00 committed by GitHub
parent e028cf9002
commit a7b9adc692
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 481 additions and 303 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -31,7 +31,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto, ImmichReadStream, MapMarkerResponseDto } from '@app/domain'; import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
@ -260,18 +260,6 @@ export class AssetController {
return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto); return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
} }
/**
* Get all assets that have GPS information embedded
*/
@Authenticated()
@Get('/map-marker')
getMapMarkers(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<MapMarkerResponseDto[]> {
return this.assetService.getMapMarkers(authUser, dto);
}
/** /**
* Get all asset of a device that are in the database, ID only. * Get all asset of a device that are in the database, ID only.
*/ */

View File

@ -504,17 +504,4 @@ describe('AssetService', () => {
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg'); expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
}); });
}); });
describe('get map markers', () => {
it('should get geo information of assets', async () => {
assetRepositoryMock.getAllByUserId.mockResolvedValue(_getAssets());
const markers = await sut.getMapMarkers(authStub.admin, {});
expect(markers).toHaveLength(1);
expect(markers[0].lat).toBe(_getAsset_1().exifInfo?.latitude);
expect(markers[0].lon).toBe(_getAsset_1().exifInfo?.longitude);
expect(markers[0].id).toBe(_getAsset_1().id);
});
});
}); });

View File

@ -30,8 +30,6 @@ import {
JobName, JobName,
mapAsset, mapAsset,
mapAssetWithoutExif, mapAssetWithoutExif,
MapMarkerResponseDto,
mapAssetMapMarker,
PartnerCore, PartnerCore,
} from '@app/domain'; } from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
@ -149,12 +147,6 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} }
public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise<MapMarkerResponseDto[]> {
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
return assets.map((asset) => mapAssetMapMarker(asset)).filter((marker) => marker != null) as MapMarkerResponseDto[];
}
public async getAssetByTimeBucket( public async getAssetByTimeBucket(
authUser: AuthUserDto, authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto, getAssetByTimeBucketDto: GetAssetByTimeBucketDto,

View File

@ -9,6 +9,7 @@ import { InfraModule } from '@app/infra';
import { import {
AlbumController, AlbumController,
APIKeyController, APIKeyController,
AssetController,
AuthController, AuthController,
PersonController, PersonController,
JobController, JobController,
@ -36,6 +37,7 @@ import { AppCronJobs } from './app.cron-jobs';
AppController, AppController,
AlbumController, AlbumController,
APIKeyController, APIKeyController,
AssetController,
AuthController, AuthController,
JobController, JobController,
OAuthController, OAuthController,

View File

@ -0,0 +1,20 @@
import { AssetService, AuthUserDto, MapMarkerResponseDto } from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Asset')
@Controller('asset')
@Authenticated()
@UseValidation()
export class AssetController {
constructor(private service: AssetService) {}
@Get('/map-marker')
getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(authUser, options);
}
}

View File

@ -1,5 +1,6 @@
export * from './album.controller'; export * from './album.controller';
export * from './api-key.controller'; export * from './api-key.controller';
export * from './asset.controller';
export * from './auth.controller'; export * from './auth.controller';
export * from './job.controller'; export * from './job.controller';
export * from './oauth.controller'; export * from './oauth.controller';

View File

@ -295,6 +295,50 @@
] ]
} }
}, },
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
"parameters": [
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapMarkerResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"operationId": "login", "operationId": "login",
@ -2962,67 +3006,6 @@
] ]
} }
}, },
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
"description": "Get all assets that have GPS information embedded",
"parameters": [
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapMarkerResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/{deviceId}": { "/asset/{deviceId}": {
"get": { "get": {
"operationId": "getUserAssetsByDeviceId", "operationId": "getUserAssetsByDeviceId",
@ -4579,6 +4562,27 @@
"name" "name"
] ]
}, },
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
}
},
"required": [
"id",
"lat",
"lon"
]
},
"LoginCredentialDto": { "LoginCredentialDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -5897,31 +5901,6 @@
"timeBucket" "timeBucket"
] ]
}, },
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
},
"id": {
"type": "string"
}
},
"required": [
"type",
"lat",
"lon",
"id"
]
},
"UpdateAssetDto": { "UpdateAssetDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -12,6 +12,16 @@ export interface LivePhotoSearchOptions {
type: AssetType; type: AssetType;
} }
export interface MapMarkerSearchOptions {
isFavorite?: boolean;
}
export interface MapMarker {
id: string;
lat: number;
lon: number;
}
export enum WithoutProperty { export enum WithoutProperty {
THUMBNAIL = 'thumbnail', THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video', ENCODED_VIDEO = 'encoded-video',
@ -31,4 +41,5 @@ export interface IAssetRepository {
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>; getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>; save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
} }

View File

@ -1,5 +1,5 @@
import { AssetEntity, AssetType } from '@app/infra/entities'; import { AssetEntity, AssetType } from '@app/infra/entities';
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { AssetService, IAssetRepository } from '../asset'; import { AssetService, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
@ -58,4 +58,29 @@ describe(AssetService.name, () => {
}); });
}); });
}); });
describe('get map markers', () => {
it('should get geo information of assets', async () => {
assetMock.getMapMarkers.mockResolvedValue(
[assetEntityStub.withLocation].map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
})),
);
const markers = await sut.getMapMarkers(authStub.user1, {});
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual({
id: assetEntityStub.withLocation.id,
lat: 100,
lon: 100,
});
});
});
}); });

View File

@ -1,14 +1,17 @@
import { AssetEntity, AssetType } from '@app/infra/entities'; import { AssetEntity, AssetType } from '@app/infra/entities';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
import { AssetCore } from './asset.core'; import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository'; import { IAssetRepository } from './asset.repository';
import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto } from './response-dto';
export class AssetService { export class AssetService {
private assetCore: AssetCore; private assetCore: AssetCore;
constructor( constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
) { ) {
this.assetCore = new AssetCore(assetRepository, jobRepository); this.assetCore = new AssetCore(assetRepository, jobRepository);
@ -28,4 +31,8 @@ export class AssetService {
save(asset: Partial<AssetEntity>) { save(asset: Partial<AssetEntity>) {
return this.assetCore.save(asset); return this.assetCore.save(asset);
} }
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
}
} }

View File

@ -0,0 +1,10 @@
import { toBoolean } from 'apps/immich/src/utils/transform.util';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
export class MapMarkerDto {
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
isFavorite?: boolean;
}

View File

@ -1,35 +1,12 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class MapMarkerResponseDto { export class MapMarkerResponseDto {
@ApiProperty()
id!: string; id!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) @ApiProperty({ format: 'double' })
type!: AssetType;
@ApiProperty({ type: 'number', format: 'double' })
lat!: number; lat!: number;
@ApiProperty({ type: 'number', format: 'double' }) @ApiProperty({ format: 'double' })
lon!: number; lon!: number;
} }
export function mapAssetMapMarker(entity: AssetEntity): MapMarkerResponseDto | null {
if (!entity.exifInfo) {
return null;
}
const lat = entity.exifInfo.latitude;
const lon = entity.exifInfo.longitude;
if (!lat || !lon) {
return null;
}
return {
id: entity.id,
type: entity.type,
lon,
lat,
};
}

View File

@ -9,5 +9,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
deleteAll: jest.fn(), deleteAll: jest.fn(),
save: jest.fn(), save: jest.fn(),
findLivePhotoMatch: jest.fn(), findLivePhotoMatch: jest.fn(),
getMapMarkers: jest.fn(),
}; };
}; };

View File

@ -12,6 +12,7 @@ import {
UserEntity, UserEntity,
UserTokenEntity, UserTokenEntity,
AssetFaceEntity, AssetFaceEntity,
ExifEntity,
} from '@app/infra/entities'; } from '@app/infra/entities';
import { import {
AlbumResponseDto, AlbumResponseDto,
@ -220,6 +221,38 @@ export const assetEntityStub = {
fileModifiedAt: '2022-06-19T23:41:36.910Z', fileModifiedAt: '2022-06-19T23:41:36.910Z',
fileCreatedAt: '2022-06-19T23:41:36.910Z', fileCreatedAt: '2022-06-19T23:41:36.910Z',
} as AssetEntity), } as AssetEntity),
withLocation: Object.freeze<AssetEntity>({
id: 'asset-with-favorite-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: '2023-02-23T05:06:29.716Z',
fileCreatedAt: '2023-02-23T05:06:29.716Z',
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
type: AssetType.IMAGE,
webpPath: null,
encodedVideoPath: null,
createdAt: '2023-02-23T05:06:29.716Z',
updatedAt: '2023-02-23T05:06:29.716Z',
mimeType: null,
isFavorite: false,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
exifInfo: {
latitude: 100,
longitude: 100,
} as ExifEntity,
}),
}; };
export const albumStub = { export const albumStub = {

View File

@ -1,4 +1,11 @@
import { AssetSearchOptions, IAssetRepository, LivePhotoSearchOptions, WithoutProperty } from '@app/domain'; import {
AssetSearchOptions,
IAssetRepository,
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
WithoutProperty,
} from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
@ -21,7 +28,6 @@ export class AssetRepository implements IAssetRepository {
}, },
}); });
} }
async deleteAll(ownerId: string): Promise<void> { async deleteAll(ownerId: string): Promise<void> {
await this.repository.delete({ ownerId }); await this.repository.delete({ ownerId });
} }
@ -166,4 +172,44 @@ export class AssetRepository implements IAssetRepository {
order: { fileCreatedAt: 'DESC' }, order: { fileCreatedAt: 'DESC' },
}); });
} }
async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
const { isFavorite } = options;
const assets = await this.repository.find({
select: {
id: true,
exifInfo: {
latitude: true,
longitude: true,
},
},
where: {
ownerId,
isVisible: true,
isArchived: false,
exifInfo: {
latitude: Not(IsNull()),
longitude: Not(IsNull()),
},
isFavorite,
},
relations: {
exifInfo: true,
},
order: {
fileCreatedAt: 'DESC',
},
});
return assets.map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
}));
}
} }

View File

@ -1471,10 +1471,10 @@ export interface LogoutResponseDto {
export interface MapMarkerResponseDto { export interface MapMarkerResponseDto {
/** /**
* *
* @type {AssetTypeEnum} * @type {string}
* @memberof MapMarkerResponseDto * @memberof MapMarkerResponseDto
*/ */
'type': AssetTypeEnum; 'id': string;
/** /**
* *
* @type {number} * @type {number}
@ -1487,15 +1487,7 @@ export interface MapMarkerResponseDto {
* @memberof MapMarkerResponseDto * @memberof MapMarkerResponseDto
*/ */
'lon': number; 'lon': number;
/**
*
* @type {string}
* @memberof MapMarkerResponseDto
*/
'id': string;
} }
/** /**
* *
* @export * @export
@ -4858,14 +4850,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
}; };
}, },
/** /**
* Get all assets that have GPS information embedded *
* @param {boolean} [isFavorite] * @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getMapMarkers: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { getMapMarkers: async (isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/map-marker`; const localVarPath = `/asset/map-marker`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -4891,14 +4881,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['isFavorite'] = isFavorite; localVarQueryParameter['isFavorite'] = isFavorite;
} }
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -5471,15 +5453,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
* Get all assets that have GPS information embedded *
* @param {boolean} [isFavorite] * @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> { async getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, isArchived, skip, options); const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -5739,15 +5719,13 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath)); return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
}, },
/** /**
* Get all assets that have GPS information embedded *
* @param {boolean} [isFavorite] * @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> { getMapMarkers(isFavorite?: boolean, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
return localVarFp.getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(axios, basePath)); return localVarFp.getMapMarkers(isFavorite, options).then((request) => request(axios, basePath));
}, },
/** /**
* Get all asset of a device that are in the database, ID only. * Get all asset of a device that are in the database, ID only.
@ -6036,16 +6014,14 @@ export class AssetApi extends BaseAPI {
} }
/** /**
* Get all assets that have GPS information embedded *
* @param {boolean} [isFavorite] * @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AssetApi * @memberof AssetApi
*/ */
public getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig) { public getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).getMapMarkers(isFavorite, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View File

@ -237,7 +237,7 @@
{#if latlng} {#if latlng}
<div class="h-[360px]"> <div class="h-[360px]">
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }} {#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
<Map {latlng} zoom={14}> <Map center={latlng} zoom={14}>
<TileLayer <TileLayer
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'} urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{ options={{

View File

@ -0,0 +1,40 @@
<script lang="ts" context="module">
export interface MapSettings {
allowDarkMode: boolean;
onlyFavorites: boolean;
}
</script>
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { createEventDispatcher } from 'svelte';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
export let settings: MapSettings;
const dispatch = createEventDispatcher<{
close: void;
save: MapSettings;
}>();
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div
class="flex flex-col gap-8 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-96 max-w-lg rounded-3xl"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium self-center">
Map Settings
</h1>
<form on:submit|preventDefault={() => dispatch('save', settings)} class="flex flex-col gap-4">
<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch title="Show only favorites" bind:checked={settings.onlyFavorites} />
<div class="flex w-full gap-4 mt-4">
<Button color="gray" size="sm" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
<Button type="submit" size="sm" fullwidth>Save</Button>
</div>
</form>
</div>
</FullScreenModal>

View File

@ -3,7 +3,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher<{ clickOutside: void }>();
</script> </script>
<section <section

View File

@ -0,0 +1,39 @@
.marker-cluster {
background-clip: padding-box;
}
.asset-marker-icon {
@apply rounded-full;
object-fit: cover;
border: 1px solid rgb(69, 80, 169);
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
}
.marker-cluster div {
width: 40px;
height: 40px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
@apply rounded-full;
font-weight: bold;
background-color: rgb(236, 237, 246);
border: 1px solid rgb(69, 80, 169);
color: rgb(69, 80, 169);
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
}
.dark .marker-cluster div {
background-color: #adcbfa;
border: 1px solid black;
color: black;
}
.marker-cluster span {
line-height: 40px;
}

View File

@ -1,6 +1,6 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { createContext } from '$lib/utils/context'; import { createContext } from '$lib/utils/context';
import { MarkerClusterGroup, Marker, Icon, LeafletEvent } from 'leaflet'; import { Icon, LeafletEvent, Marker, MarkerClusterGroup } from 'leaflet';
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>(); const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
@ -10,11 +10,11 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import 'leaflet.markercluster';
import { onDestroy, onMount } from 'svelte';
import { getMapContext } from './map.svelte';
import { MapMarkerResponseDto, api } from '@api'; import { MapMarkerResponseDto, api } from '@api';
import { createEventDispatcher } from 'svelte'; import 'leaflet.markercluster';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import './asset-marker-cluster.css';
import { getMapContext } from './map.svelte';
class AssetMarker extends Marker { class AssetMarker extends Marker {
marker: MapMarkerResponseDto; marker: MapMarkerResponseDto;
@ -95,49 +95,3 @@
if (cluster) cluster.remove(); if (cluster) cluster.remove();
}); });
</script> </script>
{#if cluster}
<slot />
{/if}
<style lang="postcss">
:global(.marker-cluster) {
background-clip: padding-box;
}
:global(.asset-marker-icon) {
@apply rounded-full;
object-fit: cover;
border: 1px solid rgb(69, 80, 169);
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
}
:global(.marker-cluster div) {
width: 40px;
height: 40px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
@apply rounded-full;
font-weight: bold;
background-color: rgb(236, 237, 246);
border: 1px solid rgb(69, 80, 169);
color: rgb(69, 80, 169);
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
}
:global(.dark .marker-cluster div) {
background-color: #adcbfa;
border: 1px solid black;
color: black;
}
:global(.marker-cluster span) {
line-height: 40px;
}
</style>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { Control, type ControlPosition } from 'leaflet';
import { getMapContext } from './map.svelte';
export let position: ControlPosition | undefined = undefined;
let className: string | undefined = undefined;
export { className as class };
let control: Control;
let target: HTMLDivElement;
const map = getMapContext();
onMount(() => {
const ControlClass = Control.extend({
position,
onAdd: () => target
});
control = new ControlClass().addTo(map);
});
onDestroy(() => {
control.remove();
});
$: if (control && position) {
control.setPosition(position);
}
</script>
<div bind:this={target} class={className}>
<slot />
</div>

View File

@ -1,4 +1,5 @@
export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte';
export { default as Control } from './control.svelte';
export { default as Map } from './map.svelte'; export { default as Map } from './map.svelte';
export { default as Marker } from './marker.svelte'; export { default as Marker } from './marker.svelte';
export { default as TileLayer } from './tile-layer.svelte'; export { default as TileLayer } from './tile-layer.svelte';
export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte';

View File

@ -12,11 +12,13 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { Map, type LatLngExpression } from 'leaflet'; import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
export let latlng: LatLngExpression; export let center: LatLngExpression;
export let zoom: number; export let zoom: number;
export let options: MapOptions | undefined = undefined;
export let allowDarkMode = false;
let container: HTMLDivElement; let container: HTMLDivElement;
let map: Map; let map: Map;
@ -24,7 +26,7 @@
onMount(() => { onMount(() => {
if (browser) { if (browser) {
map = new Map(container); map = new Map(container, options);
} }
}); });
@ -32,11 +34,17 @@
if (map) map.remove(); if (map) map.remove();
}); });
$: if (map) map.setView(latlng, zoom); $: if (map) map.setView(center, zoom);
</script> </script>
<div bind:this={container} class="w-full h-full"> <div bind:this={container} class="w-full h-full" class:map-dark={allowDarkMode}>
{#if map} {#if map}
<slot /> <slot />
{/if} {/if}
</div> </div>
<style>
:global(.dark) .map-dark :global(.leaflet-layer) {
filter: invert(100%) brightness(130%) saturate(0%);
}
</style>

View File

@ -5,30 +5,16 @@
export let urlTemplate: string; export let urlTemplate: string;
export let options: TileLayerOptions | undefined = undefined; export let options: TileLayerOptions | undefined = undefined;
export let allowDarkMode = false;
let tileLayer: TileLayer; let tileLayer: TileLayer;
const map = getMapContext(); const map = getMapContext();
onMount(() => { onMount(() => {
tileLayer = new TileLayer(urlTemplate, { tileLayer = new TileLayer(urlTemplate, options).addTo(map);
className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer',
...options
}).addTo(map);
}); });
onDestroy(() => { onDestroy(() => {
if (tileLayer) tileLayer.remove(); if (tileLayer) tileLayer.remove();
}); });
</script> </script>
<style>
:global(.leaflet-layer-dynamic) {
filter: brightness(100%) contrast(100%) saturate(80%);
}
:global(.dark .leaflet-layer-dynamic) {
filter: invert(100%) brightness(130%) saturate(0%);
}
</style>

View File

@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { MapSettings } from '$lib/components/map-page/map-settings-modal.svelte';
import { persisted } from 'svelte-local-storage-store'; import { persisted } from 'svelte-local-storage-store';
const initialTheme = const initialTheme =
@ -19,3 +20,8 @@ export const locale = persisted<string | undefined>('locale', undefined, {
stringify: (obj) => obj ?? '' stringify: (obj) => obj ?? ''
} }
}); });
export const mapSettings = persisted<MapSettings>('map-settings', {
allowDarkMode: true,
onlyFavorites: false
});

View File

@ -2,22 +2,15 @@ import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api, user } }) => { export const load = (async ({ locals: { user } }) => {
if (!user) { if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN); throw redirect(302, AppRoute.AUTH_LOGIN);
} }
try {
const { data: mapMarkers } = await api.assetApi.getMapMarkers();
return { return {
user, user,
mapMarkers,
meta: { meta: {
title: 'Map' title: 'Map'
} }
}; };
} catch (e) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View File

@ -1,27 +1,43 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from '../map/$types';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte'; import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { import {
assetInteractionStore, assetInteractionStore,
isViewingAssetStoreState, isViewingAssetStoreState,
viewingAssetStoreState viewingAssetStoreState
} from '$lib/stores/asset-interaction.store'; } from '$lib/stores/asset-interaction.store';
import { mapSettings } from '$lib/stores/preferences.store';
import { MapMarkerResponseDto, api } from '@api';
import { onDestroy, onMount } from 'svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import type { PageData } from './$types';
export let data: PageData; export let data: PageData;
let initialMapCenter: [number, number] = [48, 11]; let mapMarkersPromise: Promise<MapMarkerResponseDto[]>;
let abortController = new AbortController();
$: {
if (data.mapMarkers.length) {
let firstMarker = data.mapMarkers[0];
initialMapCenter = [firstMarker.lat, firstMarker.lon];
}
}
let viewingAssets: string[] = []; let viewingAssets: string[] = [];
let viewingAssetCursor = 0; let viewingAssetCursor = 0;
let showSettingsModal = false;
onMount(() => {
mapMarkersPromise = loadMapMarkers();
});
onDestroy(() => {
abortController.abort();
assetInteractionStore.clearMultiselect();
assetInteractionStore.setIsViewingAsset(false);
});
async function loadMapMarkers() {
const { data } = await api.assetApi.getMapMarkers($mapSettings.onlyFavorites || undefined, {
signal: abortController.signal
});
return data;
}
function onViewAssets(assets: string[]) { function onViewAssets(assets: string[]) {
assetInteractionStore.setViewingAssetId(assets[0]); assetInteractionStore.setViewingAssetId(assets[0]);
@ -40,16 +56,34 @@
assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]); assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]);
} }
} }
function getMapCenter(mapMarkers: MapMarkerResponseDto[]): [number, number] {
const marker = mapMarkers[0];
if (marker) {
return [marker.lat, marker.lon];
}
return [48, 11];
}
</script> </script>
<UserPageLayout user={data.user} title={data.meta.title}> <UserPageLayout user={data.user} title={data.meta.title}>
<div slot="buttons" /> <div class="h-full w-full isolate">
{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster, Control }}
<div class="h-full w-full relative z-0"> {#await mapMarkersPromise then mapMarkers}
{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster }} <Map
<Map latlng={initialMapCenter} zoom={7}> center={getMapCenter(mapMarkers)}
zoom={7}
allowDarkMode={$mapSettings.allowDarkMode}
options={{
maxBounds: [
[-90, -180],
[90, 180]
],
minZoom: 3
}}
>
<TileLayer <TileLayer
allowDarkMode={true}
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'} urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{ options={{
attribution: attribution:
@ -57,11 +91,21 @@
}} }}
/> />
<AssetMarkerCluster <AssetMarkerCluster
markers={data.mapMarkers} markers={mapMarkers}
on:view={(event) => onViewAssets(event.detail.assets)} on:view={(event) => onViewAssets(event.detail.assets)}
/> />
<Control>
<button
class="flex justify-center items-center bg-white text-black/70 w-8 h-8 font-bold rounded-sm border-2 border-black/20 hover:bg-gray-50 focus:bg-gray-50"
title="Open map settings"
on:click={() => (showSettingsModal = true)}
>
<Cog size="100%" class="p-1" />
</button>
</Control>
</Map> </Map>
{/await} {/await}
{/await}
</div> </div>
</UserPageLayout> </UserPageLayout>
@ -78,3 +122,20 @@
/> />
{/if} {/if}
</Portal> </Portal>
{#if showSettingsModal}
<MapSettingsModal
settings={{ ...$mapSettings }}
on:close={() => (showSettingsModal = false)}
on:save={async ({ detail }) => {
const shouldUpdate = detail.onlyFavorites !== $mapSettings.onlyFavorites;
showSettingsModal = false;
$mapSettings = detail;
if (shouldUpdate) {
const markers = await loadMapMarkers();
mapMarkersPromise = Promise.resolve(markers);
}
}}
/>
{/if}