mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
5423f1c25b
commit
a147dee4b6
63 changed files with 5457 additions and 9751 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ export class ServerInfoService {
|
|||
|
||||
return {
|
||||
loginPageMessage,
|
||||
mapTileUrl: config.map.tileUrl,
|
||||
trashDays: config.trash.days,
|
||||
oauthButtonText: config.oauth.buttonText,
|
||||
isInitialized,
|
||||
|
|
|
|||
|
|
@ -5,5 +5,8 @@ export class SystemConfigMapDto {
|
|||
enabled!: boolean;
|
||||
|
||||
@IsString()
|
||||
tileUrl!: string;
|
||||
lightStyle!: string;
|
||||
|
||||
@IsString()
|
||||
darkStyle!: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue