feat: Maplibre (#4294)

* maplibre on web, custom styles from server

Actually use new vector tile server, custom style.json

support multiple style files, light/dark mode

cleanup, use new map everywhere

send file directly instead of loading first

better light/dark mode switching

remove leaflet

fix mapstyles dto, first draft of map settings

delete and add styles

fix delete default styles

fix tests

only allow one light and one dark style url

revert config core changes

fix server config store

fix tests

move axios fetches to repo

fix package-lock

fix tests

* open api

* add assets to docker container

* web: use mapSettings color for style

* style: add unique ids to map styles

* mobile: use style json for vector / raster

* do not use svelte-material-icons

* add click events to markers, simplify asset detail map

* improve map performance by using asset thumbnails for markers instead of original file

* Remove custom attribution

(by request)

* mobile: update map attribution

* style: map dark mode

* style: map light mode

* zoom level for state

* styling

* overflow gradient

* Limit maxZoom to 14

* mobile: listen for mapStyle changes in MapThumbnail

* mobile: update concurrency

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: bo0tzz <git@bo0tzz.me>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Daniel Dietzler 2023-11-09 17:10:56 +01:00 committed by GitHub
parent 5423f1c25b
commit a147dee4b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 5457 additions and 9751 deletions

View file

@ -3,8 +3,9 @@ import { SystemConfigEntity } from '@app/infra/entities';
export const ISystemConfigRepository = 'ISystemConfigRepository';
export interface ISystemConfigRepository {
fetchStyle(url: string): Promise<any>;
load(): Promise<SystemConfigEntity[]>;
readFile(filename: string): Promise<Buffer>;
readFile(filename: string): Promise<string>;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
deleteKeys(keys: string[]): Promise<void>;
}

View file

@ -85,7 +85,6 @@ export class ServerThemeDto extends SystemConfigThemeDto {}
export class ServerConfigDto {
oauthButtonText!: string;
loginPageMessage!: string;
mapTileUrl!: string;
@ApiProperty({ type: 'integer' })
trashDays!: number;
isInitialized!: boolean;

View file

@ -185,7 +185,6 @@ describe(ServerInfoService.name, () => {
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
});
expect(configMock.load).toHaveBeenCalled();
});

View file

@ -85,7 +85,6 @@ export class ServerInfoService {
return {
loginPageMessage,
mapTileUrl: config.map.tileUrl,
trashDays: config.trash.days,
oauthButtonText: config.oauth.buttonText,
isInitialized,

View file

@ -5,5 +5,8 @@ export class SystemConfigMapDto {
enabled!: boolean;
@IsString()
tileUrl!: string;
lightStyle!: string;
@IsString()
darkStyle!: string;
}

View file

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
export enum MapTheme {
LIGHT = 'light',
DARK = 'dark',
}
export class MapThemeDto {
@IsEnum(MapTheme)
@ApiProperty({ enum: MapTheme, enumName: 'MapTheme' })
theme!: MapTheme;
}

View file

@ -80,7 +80,8 @@ export const defaults = Object.freeze<SystemConfig>({
},
map: {
enabled: true,
tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
lightStyle: '',
darkStyle: '',
},
reverseGeocoding: {
enabled: true,

View file

@ -80,7 +80,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
map: {
enabled: true,
tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
lightStyle: '',
darkStyle: '',
},
reverseGeocoding: {
enabled: true,
@ -185,7 +186,7 @@ describe(SystemConfigService.name, () => {
it('should load the config from a file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } };
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
@ -194,7 +195,7 @@ describe(SystemConfigService.name, () => {
it('should accept an empty configuration file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
configMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getConfig()).resolves.toEqual(defaults);
@ -204,7 +205,7 @@ describe(SystemConfigService.name, () => {
it('should allow underscores in the machine learning url', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getConfig();
expect(config.machineLearning.url).toEqual('immich_machine_learning');
@ -222,7 +223,7 @@ describe(SystemConfigService.name, () => {
for (const test of tests) {
it(`should ${test.should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(test.config)));
configMock.readFile.mockResolvedValue(JSON.stringify(test.config));
await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
});
@ -286,7 +287,7 @@ describe(SystemConfigService.name, () => {
it('should throw an error if a config file is in use', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
configMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(configMock.saveAll).not.toHaveBeenCalled();
});

View file

@ -20,7 +20,7 @@ import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
export class SystemConfigService {
private core: SystemConfigCore;
constructor(
@Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
@Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
@ -76,4 +76,15 @@ export class SystemConfigService {
return options;
}
async getMapStyle(theme: 'light' | 'dark') {
const { map } = await this.getConfig();
const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle;
if (styleUrl) {
return this.repository.fetchStyle(styleUrl);
}
return JSON.parse(await this.repository.readFile(`./assets/style-${theme}.json`));
}
}

View file

@ -1,5 +1,6 @@
import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain';
import { Body, Controller, Get, Put } from '@nestjs/common';
import { MapThemeDto } from '@app/domain/system-config/system-config-map-theme.dto';
import { Body, Controller, Get, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ -30,4 +31,9 @@ export class SystemConfigController {
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.service.getStorageTemplateOptions();
}
@Get('map/style.json')
getMapStyle(@Query() dto: MapThemeDto) {
return this.service.getMapStyle(dto.theme);
}
}

View file

@ -62,7 +62,8 @@ export enum SystemConfigKey {
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES = 'machineLearning.facialRecognition.minFaces',
MAP_ENABLED = 'map.enabled',
MAP_TILE_URL = 'map.tileUrl',
MAP_LIGHT_STYLE = 'map.lightStyle',
MAP_DARK_STYLE = 'map.darkStyle',
REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
@ -194,7 +195,8 @@ export interface SystemConfig {
};
map: {
enabled: boolean;
tileUrl: string;
lightStyle: string;
darkStyle: string;
};
reverseGeocoding: {
enabled: boolean;

View file

@ -1,5 +1,6 @@
import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { readFile } from 'fs/promises';
import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities';
@ -9,12 +10,17 @@ export class SystemConfigRepository implements ISystemConfigRepository {
@InjectRepository(SystemConfigEntity)
private repository: Repository<SystemConfigEntity>,
) {}
async fetchStyle(url: string) {
return axios.get(url).then((response) => response.data);
}
load(): Promise<SystemConfigEntity[]> {
return this.repository.find();
}
readFile = readFile;
readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf-8' });
}
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);