0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-11 02:23:09 -05: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 501 additions and 364 deletions

View file

@ -1041,12 +1041,10 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getMapMarkers**
> List<MapMarkerResponseDto> getMapMarkers(isFavorite, isArchived, skip)
> List<MapMarkerResponseDto> getMapMarkers(isFavorite)
Get all assets that have GPS information embedded
### Example
```dart
import 'package:openapi/api.dart';
@ -1067,11 +1065,9 @@ import 'package:openapi/api.dart';
final api_instance = AssetApi();
final isFavorite = true; // bool |
final isArchived = true; // bool |
final skip = 8.14; // num |
try {
final result = api_instance.getMapMarkers(isFavorite, isArchived, skip);
final result = api_instance.getMapMarkers(isFavorite);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getMapMarkers: $e\n');
@ -1083,8 +1079,6 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**isFavorite** | **bool**| | [optional]
**isArchived** | **bool**| | [optional]
**skip** | **num**| | [optional]
### Return type

View file

@ -8,10 +8,9 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) | |
**id** | **String** | |
**lat** | **double** | |
**lon** | **double** | |
**id** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -979,18 +979,11 @@ class AssetApi {
return null;
}
/// Get all assets that have GPS information embedded
///
/// Note: This method returns the HTTP [Response].
///
/// Performs an HTTP 'GET /asset/map-marker' operation and returns the [Response].
/// Parameters:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [num] skip:
Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, bool? isArchived, num? skip, }) async {
Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/map-marker';
@ -1004,12 +997,6 @@ class AssetApi {
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (skip != null) {
queryParams.addAll(_queryParams('', 'skip', skip));
}
const contentTypes = <String>[];
@ -1025,17 +1012,11 @@ class AssetApi {
);
}
/// Get all assets that have GPS information embedded
///
/// Parameters:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [num] skip:
Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, bool? isArchived, num? skip, }) async {
final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, isArchived: isArchived, skip: skip, );
Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, }) async {
final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View file

@ -13,44 +13,38 @@ part of openapi.api;
class MapMarkerResponseDto {
/// Returns a new [MapMarkerResponseDto] instance.
MapMarkerResponseDto({
required this.type,
required this.id,
required this.lat,
required this.lon,
required this.id,
});
AssetTypeEnum type;
String id;
double lat;
double lon;
String id;
@override
bool operator ==(Object other) => identical(this, other) || other is MapMarkerResponseDto &&
other.type == type &&
other.id == id &&
other.lat == lat &&
other.lon == lon &&
other.id == id;
other.lon == lon;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(lat.hashCode) +
(lon.hashCode) +
(id.hashCode);
(lon.hashCode);
@override
String toString() => 'MapMarkerResponseDto[type=$type, lat=$lat, lon=$lon, id=$id]';
String toString() => 'MapMarkerResponseDto[id=$id, lat=$lat, lon=$lon]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'type'] = this.type;
json[r'id'] = this.id;
json[r'lat'] = this.lat;
json[r'lon'] = this.lon;
json[r'id'] = this.id;
return json;
}
@ -73,10 +67,9 @@ class MapMarkerResponseDto {
}());
return MapMarkerResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!,
id: mapValueOfType<String>(json, r'id')!,
lat: mapValueOfType<double>(json, r'lat')!,
lon: mapValueOfType<double>(json, r'lon')!,
id: mapValueOfType<String>(json, r'id')!,
);
}
return null;
@ -124,10 +117,9 @@ class MapMarkerResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'type',
'id',
'lat',
'lon',
'id',
};
}

View file

@ -117,9 +117,7 @@ void main() {
// TODO
});
// Get all assets that have GPS information embedded
//
//Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite, bool isArchived, num skip }) async
//Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite }) async
test('test getMapMarkers', () async {
// TODO
});

View file

@ -16,8 +16,8 @@ void main() {
// final instance = MapMarkerResponseDto();
group('test MapMarkerResponseDto', () {
// AssetTypeEnum type
test('to test the property `type`', () async {
// String id
test('to test the property `id`', () async {
// TODO
});
@ -31,11 +31,6 @@ void main() {
// TODO
});
// String id
test('to test the property `id`', () async {
// TODO
});
});

View file

@ -31,7 +31,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-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 { CreateAssetDto, mapToUploadFile } from './dto/create-asset.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);
}
/**
* 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.
*/

View file

@ -504,17 +504,4 @@ describe('AssetService', () => {
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,
mapAsset,
mapAssetWithoutExif,
MapMarkerResponseDto,
mapAssetMapMarker,
PartnerCore,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
@ -149,12 +147,6 @@ export class AssetService {
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(
authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,

View file

@ -9,6 +9,7 @@ import { InfraModule } from '@app/infra';
import {
AlbumController,
APIKeyController,
AssetController,
AuthController,
PersonController,
JobController,
@ -36,6 +37,7 @@ import { AppCronJobs } from './app.cron-jobs';
AppController,
AlbumController,
APIKeyController,
AssetController,
AuthController,
JobController,
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 './api-key.controller';
export * from './asset.controller';
export * from './auth.controller';
export * from './job.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": {
"post": {
"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}": {
"get": {
"operationId": "getUserAssetsByDeviceId",
@ -4579,6 +4562,27 @@
"name"
]
},
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
}
},
"required": [
"id",
"lat",
"lon"
]
},
"LoginCredentialDto": {
"type": "object",
"properties": {
@ -5897,31 +5901,6 @@
"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": {
"type": "object",
"properties": {

View file

@ -12,6 +12,16 @@ export interface LivePhotoSearchOptions {
type: AssetType;
}
export interface MapMarkerSearchOptions {
isFavorite?: boolean;
}
export interface MapMarker {
id: string;
lat: number;
lon: number;
}
export enum WithoutProperty {
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video',
@ -31,4 +41,5 @@ export interface IAssetRepository {
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
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 { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { AssetService, IAssetRepository } from '../asset';
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 { Inject } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository';
import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto } from './response-dto';
export class AssetService {
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.assetCore = new AssetCore(assetRepository, jobRepository);
@ -28,4 +31,8 @@ export class AssetService {
save(asset: Partial<AssetEntity>) {
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';
export class MapMarkerResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
@ApiProperty({ type: 'number', format: 'double' })
@ApiProperty({ format: 'double' })
lat!: number;
@ApiProperty({ type: 'number', format: 'double' })
@ApiProperty({ format: 'double' })
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(),
save: jest.fn(),
findLivePhotoMatch: jest.fn(),
getMapMarkers: jest.fn(),
};
};

View file

@ -12,6 +12,7 @@ import {
UserEntity,
UserTokenEntity,
AssetFaceEntity,
ExifEntity,
} from '@app/infra/entities';
import {
AlbumResponseDto,
@ -220,6 +221,38 @@ export const assetEntityStub = {
fileModifiedAt: '2022-06-19T23:41:36.910Z',
fileCreatedAt: '2022-06-19T23:41:36.910Z',
} 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 = {

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 { InjectRepository } from '@nestjs/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> {
await this.repository.delete({ ownerId });
}
@ -166,4 +172,44 @@ export class AssetRepository implements IAssetRepository {
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 {
/**
*
* @type {AssetTypeEnum}
* @type {string}
* @memberof MapMarkerResponseDto
*/
'type': AssetTypeEnum;
'id': string;
/**
*
* @type {number}
@ -1487,15 +1487,7 @@ export interface MapMarkerResponseDto {
* @memberof MapMarkerResponseDto
*/
'lon': number;
/**
*
* @type {string}
* @memberof MapMarkerResponseDto
*/
'id': string;
}
/**
*
* @export
@ -4858,14 +4850,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
};
},
/**
* Get all assets that have GPS information embedded
*
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @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`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -4891,14 +4881,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -5471,15 +5453,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Get all assets that have GPS information embedded
*
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, isArchived, skip, options);
async getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MapMarkerResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapMarkers(isFavorite, options);
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));
},
/**
* Get all assets that have GPS information embedded
*
* @param {boolean} [isFavorite]
* @param {boolean} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
return localVarFp.getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(axios, basePath));
getMapMarkers(isFavorite?: boolean, options?: any): AxiosPromise<Array<MapMarkerResponseDto>> {
return localVarFp.getMapMarkers(isFavorite, options).then((request) => request(axios, basePath));
},
/**
* 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} [isArchived]
* @param {number} [skip]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getMapMarkers(isFavorite?: boolean, isArchived?: boolean, skip?: number, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMapMarkers(isFavorite, isArchived, skip, options).then((request) => request(this.axios, this.basePath));
public getMapMarkers(isFavorite?: boolean, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMapMarkers(isFavorite, options).then((request) => request(this.axios, this.basePath));
}
/**

View file

@ -237,7 +237,7 @@
{#if latlng}
<div class="h-[360px]">
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
<Map {latlng} zoom={14}>
<Map center={latlng} zoom={14}>
<TileLayer
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
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 { fade } from 'svelte/transition';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher<{ clickOutside: void }>();
</script>
<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">
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>();
@ -10,11 +10,11 @@
</script>
<script lang="ts">
import 'leaflet.markercluster';
import { onDestroy, onMount } from 'svelte';
import { getMapContext } from './map.svelte';
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 {
marker: MapMarkerResponseDto;
@ -95,49 +95,3 @@
if (cluster) cluster.remove();
});
</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 Marker } from './marker.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">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { Map, type LatLngExpression } from 'leaflet';
import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
import 'leaflet/dist/leaflet.css';
export let latlng: LatLngExpression;
export let center: LatLngExpression;
export let zoom: number;
export let options: MapOptions | undefined = undefined;
export let allowDarkMode = false;
let container: HTMLDivElement;
let map: Map;
@ -24,7 +26,7 @@
onMount(() => {
if (browser) {
map = new Map(container);
map = new Map(container, options);
}
});
@ -32,11 +34,17 @@
if (map) map.remove();
});
$: if (map) map.setView(latlng, zoom);
$: if (map) map.setView(center, zoom);
</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}
<slot />
{/if}
</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 options: TileLayerOptions | undefined = undefined;
export let allowDarkMode = false;
let tileLayer: TileLayer;
const map = getMapContext();
onMount(() => {
tileLayer = new TileLayer(urlTemplate, {
className: allowDarkMode ? 'leaflet-layer-dynamic' : 'leaflet-layer',
...options
}).addTo(map);
tileLayer = new TileLayer(urlTemplate, options).addTo(map);
});
onDestroy(() => {
if (tileLayer) tileLayer.remove();
});
</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 { MapSettings } from '$lib/components/map-page/map-settings-modal.svelte';
import { persisted } from 'svelte-local-storage-store';
const initialTheme =
@ -19,3 +20,8 @@ export const locale = persisted<string | undefined>('locale', undefined, {
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 type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api, user } }) => {
export const load = (async ({ locals: { user } }) => {
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
try {
const { data: mapMarkers } = await api.assetApi.getMapMarkers();
return {
user,
mapMarkers,
meta: {
title: 'Map'
}
};
} catch (e) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
return {
user,
meta: {
title: 'Map'
}
};
}) satisfies PageServerLoad;

View file

@ -1,27 +1,43 @@
<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 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 {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState
} 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;
let initialMapCenter: [number, number] = [48, 11];
$: {
if (data.mapMarkers.length) {
let firstMarker = data.mapMarkers[0];
initialMapCenter = [firstMarker.lat, firstMarker.lon];
}
}
let mapMarkersPromise: Promise<MapMarkerResponseDto[]>;
let abortController = new AbortController();
let viewingAssets: string[] = [];
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[]) {
assetInteractionStore.setViewingAssetId(assets[0]);
@ -40,27 +56,55 @@
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>
<UserPageLayout user={data.user} title={data.meta.title}>
<div slot="buttons" />
<div class="h-full w-full relative z-0">
{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster }}
<Map latlng={initialMapCenter} zoom={7}>
<TileLayer
allowDarkMode={true}
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
<div class="h-full w-full isolate">
{#await import('$lib/components/shared-components/leaflet') then { Map, TileLayer, AssetMarkerCluster, Control }}
{#await mapMarkersPromise then mapMarkers}
<Map
center={getMapCenter(mapMarkers)}
zoom={7}
allowDarkMode={$mapSettings.allowDarkMode}
options={{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxBounds: [
[-90, -180],
[90, 180]
],
minZoom: 3
}}
/>
<AssetMarkerCluster
markers={data.mapMarkers}
on:view={(event) => onViewAssets(event.detail.assets)}
/>
</Map>
>
<TileLayer
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}}
/>
<AssetMarkerCluster
markers={mapMarkers}
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>
{/await}
{/await}
</div>
</UserPageLayout>
@ -78,3 +122,20 @@
/>
{/if}
</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}