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:
parent
e028cf9002
commit
a7b9adc692
BIN
mobile/openapi/doc/AssetApi.md
generated
BIN
mobile/openapi/doc/AssetApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/MapMarkerResponseDto.md
generated
BIN
mobile/openapi/doc/MapMarkerResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/asset_api.dart
generated
BIN
mobile/openapi/lib/api/asset_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/map_marker_response_dto.dart
generated
BIN
mobile/openapi/lib/model/map_marker_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_api_test.dart
generated
BIN
mobile/openapi/test/asset_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/map_marker_response_dto_test.dart
generated
BIN
mobile/openapi/test/map_marker_response_dto_test.dart
generated
Binary file not shown.
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
20
server/apps/immich/src/controllers/asset.controller.ts
Normal file
20
server/apps/immich/src/controllers/asset.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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';
|
||||||
|
@ -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": {
|
||||||
|
@ -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[]>;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
10
server/libs/domain/src/asset/dto/map-marker.dto.ts
Normal file
10
server/libs/domain/src/asset/dto/map-marker.dto.ts
Normal 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;
|
||||||
|
}
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -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(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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 = {
|
||||||
|
@ -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!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
50
web/src/api/open-api/api.ts
generated
50
web/src/api/open-api/api.ts
generated
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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={{
|
||||||
|
40
web/src/lib/components/map-page/map-settings-modal.svelte
Normal file
40
web/src/lib/components/map-page/map-settings-modal.svelte
Normal 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>
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -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>
|
|
||||||
|
@ -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>
|
@ -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';
|
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
|
||||||
|
@ -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
|
||||||
|
});
|
||||||
|
@ -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 {
|
return {
|
||||||
const { data: mapMarkers } = await api.assetApi.getMapMarkers();
|
user,
|
||||||
|
meta: {
|
||||||
return {
|
title: 'Map'
|
||||||
user,
|
}
|
||||||
mapMarkers,
|
};
|
||||||
meta: {
|
|
||||||
title: 'Map'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw redirect(302, AppRoute.AUTH_LOGIN);
|
|
||||||
}
|
|
||||||
}) satisfies PageServerLoad;
|
}) satisfies PageServerLoad;
|
||||||
|
@ -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,27 +56,55 @@
|
|||||||
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)}
|
||||||
<TileLayer
|
zoom={7}
|
||||||
allowDarkMode={true}
|
allowDarkMode={$mapSettings.allowDarkMode}
|
||||||
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
|
|
||||||
options={{
|
options={{
|
||||||
attribution:
|
maxBounds: [
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
[-90, -180],
|
||||||
|
[90, 180]
|
||||||
|
],
|
||||||
|
minZoom: 3
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<AssetMarkerCluster
|
<TileLayer
|
||||||
markers={data.mapMarkers}
|
urlTemplate={'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'}
|
||||||
on:view={(event) => onViewAssets(event.detail.assets)}
|
options={{
|
||||||
/>
|
attribution:
|
||||||
</Map>
|
'© <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}
|
{/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}
|
||||||
|
Loading…
Reference in New Issue
Block a user