feat: serve map tile styles from tiles.immich.cloud (#12858)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Zack Pollard 2024-09-23 21:30:23 +01:00 committed by GitHub
parent e41785b1a1
commit bcd416477b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 676 additions and 948 deletions

6
e2e/package-lock.json generated
View File

@ -5016,9 +5016,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz",
"integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"dev": true
},
"node_modules/pathe": {

View File

@ -1,8 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/map', () => {
let websocket: Socket;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
websocket = await utils.connectWebsocket(admin.accessToken);
asset = await utils.createAsset(admin.accessToken);
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
utils.resetEvents();
const uploadFile = async (input: string) => {
@ -103,63 +97,6 @@ describe('/map', () => {
});
});
describe('GET /map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
describe('GET /map/reverse-geocode', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/reverse-geocode');

View File

@ -128,6 +128,8 @@ describe('/server-info', () => {
isInitialized: true,
externalDomain: '',
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
});
});

View File

@ -134,6 +134,8 @@ describe('/server', () => {
isInitialized: true,
externalDomain: '',
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
});
});

View File

@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.24.0",
"dart.flutterSdkPath": ".fvm/versions/3.24.3",
"search.exclude": {
"**/.fvm": true
},

View File

@ -4,11 +4,15 @@ class ServerConfig {
final int trashDays;
final String oauthButtonText;
final String externalDomain;
final String mapDarkStyleUrl;
final String mapLightStyleUrl;
const ServerConfig({
required this.trashDays,
required this.oauthButtonText,
required this.externalDomain,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
});
ServerConfig copyWith({
@ -20,6 +24,8 @@ class ServerConfig {
trashDays: trashDays ?? this.trashDays,
oauthButtonText: oauthButtonText ?? this.oauthButtonText,
externalDomain: externalDomain ?? this.externalDomain,
mapDarkStyleUrl: mapDarkStyleUrl,
mapLightStyleUrl: mapLightStyleUrl,
);
}
@ -30,7 +36,9 @@ class ServerConfig {
ServerConfig.fromDto(ServerConfigDto dto)
: trashDays = dto.trashDays,
oauthButtonText = dto.oauthButtonText,
externalDomain = dto.externalDomain;
externalDomain = dto.externalDomain,
mapDarkStyleUrl = dto.mapDarkStyleUrl,
mapLightStyleUrl = dto.mapLightStyleUrl;
@override
bool operator ==(covariant ServerConfig other) {

View File

@ -1,4 +1,5 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget {
),
Positioned(
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 16,
bottom: MediaQuery.paddingOf(context).bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(

View File

@ -1,28 +1,23 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/models/map/map_state.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'map_state.provider.g.dart';
@Riverpod(keepAlive: true)
class MapStateNotifier extends _$MapStateNotifier {
final _log = Logger("MapStateNotifier");
@override
MapState build() {
final appSettingsProvider = ref.read(appSettingsServiceProvider);
// Fetch and save the Style JSONs
loadStyles();
final lightStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl;
final darkStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl;
return MapState(
themeMode: ThemeMode.values[
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier {
appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapwithPartners),
relativeTime:
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
lightStyleFetched: AsyncData(lightStyleUrl),
darkStyleFetched: AsyncData(darkStyleUrl),
);
}
void loadStyles() async {
final documents = (await getApplicationDocumentsDirectory()).path;
// Set to loading
state = state.copyWith(lightStyleFetched: const AsyncLoading());
// Fetch and save light theme
final lightResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.light);
if (lightResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
);
_log.severe(
"Cannot fetch map light style",
lightResponse.toLoggerString(),
);
return;
}
final lightJSON = lightResponse.body;
final lightFile = await File("$documents/map-style-light.json")
.writeAsString(lightJSON, flush: true);
// Update state with path
state =
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));
// Set to loading
state = state.copyWith(darkStyleFetched: const AsyncLoading());
// Fetch and save dark theme
final darkResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.dark);
if (darkResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
);
_log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
return;
}
final darkJSON = darkResponse.body;
final darkFile = await File("$documents/map-style-dark.json")
.writeAsString(darkJSON, flush: true);
// Update state with path
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
}
void switchTheme(ThemeMode mode) {
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapThemeMode,

View File

@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
trashDays: 30,
oauthButtonText: '',
externalDomain: '',
mapLightStyleUrl:
'https://tiles.immich.cloud/v1/style/light.json',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
),
serverDiskInfo: const ServerDiskInfo(
diskAvailable: "0",

View File

@ -12,6 +12,19 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'tags', TagsResponse().toJson());
}
break;
case 'ServerConfigDto':
if (value is Map) {
addDefault(
value,
'mapLightStyleUrl',
'https://tiles.immich.cloud/v1/style/light.json',
);
addDefault(
value,
'mapDarkStyleUrl',
'https://tiles.immich.cloud/v1/style/dark.json',
);
}
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());

View File

@ -138,7 +138,6 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} |
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate |
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers |
*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json |
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode |
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets |
*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories |
@ -348,7 +347,6 @@ Class | Method | HTTP request | Description
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
- [MapTheme](doc//MapTheme.md)
- [MemoriesResponse](doc//MemoriesResponse.md)
- [MemoriesUpdate](doc//MemoriesUpdate.md)
- [MemoryCreateDto](doc//MemoryCreateDto.md)

View File

@ -159,7 +159,6 @@ part 'model/logout_response_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
part 'model/map_theme.dart';
part 'model/memories_response.dart';
part 'model/memories_update.dart';
part 'model/memory_create_dto.dart';

View File

@ -105,62 +105,6 @@ class MapApi {
return null;
}
/// Performs an HTTP 'GET /map/style.json' operation and returns the [Response].
/// Parameters:
///
/// * [MapTheme] theme (required):
///
/// * [String] key:
Future<Response> getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/map/style.json';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
queryParams.addAll(_queryParams('', 'theme', theme));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [MapTheme] theme (required):
///
/// * [String] key:
Future<Object?> getMapStyle(MapTheme theme, { String? key, }) async {
final response = await getMapStyleWithHttpInfo(theme, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object;
}
return null;
}
/// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response].
/// Parameters:
///

View File

@ -372,8 +372,6 @@ class ApiClient {
return MapMarkerResponseDto.fromJson(value);
case 'MapReverseGeocodeResponseDto':
return MapReverseGeocodeResponseDto.fromJson(value);
case 'MapTheme':
return MapThemeTypeTransformer().decode(value);
case 'MemoriesResponse':
return MemoriesResponse.fromJson(value);
case 'MemoriesUpdate':

View File

@ -100,9 +100,6 @@ String parameterToString(dynamic value) {
if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString();
}
if (value is MapTheme) {
return MapThemeTypeTransformer().encode(value).toString();
}
if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString();
}

View File

@ -1,85 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MapTheme {
/// Instantiate a new enum with the provided [value].
const MapTheme._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const light = MapTheme._(r'light');
static const dark = MapTheme._(r'dark');
/// List of all possible values in this [enum][MapTheme].
static const values = <MapTheme>[
light,
dark,
];
static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value);
static List<MapTheme> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MapTheme>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MapTheme.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MapTheme] to String,
/// and [decode] dynamic data back to [MapTheme].
class MapThemeTypeTransformer {
factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._();
const MapThemeTypeTransformer._();
String encode(MapTheme data) => data.value;
/// Decodes a [dynamic value][data] to a MapTheme.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
MapTheme? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'light': return MapTheme.light;
case r'dark': return MapTheme.dark;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MapThemeTypeTransformer] instance.
static MapThemeTypeTransformer? _instance;
}

View File

@ -17,6 +17,8 @@ class ServerConfigDto {
required this.isInitialized,
required this.isOnboarded,
required this.loginPageMessage,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.oauthButtonText,
required this.trashDays,
required this.userDeleteDelay,
@ -30,6 +32,10 @@ class ServerConfigDto {
String loginPageMessage;
String mapDarkStyleUrl;
String mapLightStyleUrl;
String oauthButtonText;
int trashDays;
@ -42,6 +48,8 @@ class ServerConfigDto {
other.isInitialized == isInitialized &&
other.isOnboarded == isOnboarded &&
other.loginPageMessage == loginPageMessage &&
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.oauthButtonText == oauthButtonText &&
other.trashDays == trashDays &&
other.userDeleteDelay == userDeleteDelay;
@ -53,12 +61,14 @@ class ServerConfigDto {
(isInitialized.hashCode) +
(isOnboarded.hashCode) +
(loginPageMessage.hashCode) +
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(oauthButtonText.hashCode) +
(trashDays.hashCode) +
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -66,6 +76,8 @@ class ServerConfigDto {
json[r'isInitialized'] = this.isInitialized;
json[r'isOnboarded'] = this.isOnboarded;
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'oauthButtonText'] = this.oauthButtonText;
json[r'trashDays'] = this.trashDays;
json[r'userDeleteDelay'] = this.userDeleteDelay;
@ -85,6 +97,8 @@ class ServerConfigDto {
isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!,
userDeleteDelay: mapValueOfType<int>(json, r'userDeleteDelay')!,
@ -139,6 +153,8 @@ class ServerConfigDto {
'isInitialized',
'isOnboarded',
'loginPageMessage',
'mapDarkStyleUrl',
'mapLightStyleUrl',
'oauthButtonText',
'trashDays',
'userDeleteDelay',

View File

@ -3167,55 +3167,6 @@
]
}
},
"/map/style.json": {
"get": {
"operationId": "getMapStyle",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "theme",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/MapTheme"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Map"
]
}
},
"/memories": {
"get": {
"operationId": "searchMemories",
@ -5356,8 +5307,8 @@
"name": "password",
"required": false,
"in": "query",
"example": "password",
"schema": {
"example": "password",
"type": "string"
}
},
@ -9695,13 +9646,6 @@
],
"type": "object"
},
"MapTheme": {
"enum": [
"light",
"dark"
],
"type": "string"
},
"MemoriesResponse": {
"properties": {
"enabled": {
@ -10917,6 +10861,12 @@
"loginPageMessage": {
"type": "string"
},
"mapDarkStyleUrl": {
"type": "string"
},
"mapLightStyleUrl": {
"type": "string"
},
"oauthButtonText": {
"type": "string"
},
@ -10932,6 +10882,8 @@
"isInitialized",
"isOnboarded",
"loginPageMessage",
"mapDarkStyleUrl",
"mapLightStyleUrl",
"oauthButtonText",
"trashDays",
"userDeleteDelay"

View File

@ -928,6 +928,8 @@ export type ServerConfigDto = {
isInitialized: boolean;
isOnboarded: boolean;
loginPageMessage: string;
mapDarkStyleUrl: string;
mapLightStyleUrl: string;
oauthButtonText: string;
trashDays: number;
userDeleteDelay: number;
@ -2138,20 +2140,6 @@ export function reverseGeocode({ lat, lon }: {
...opts
}));
}
export function getMapStyle({ key, theme }: {
key?: string;
theme: MapTheme;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: object;
}>(`/map/style.json${QS.query(QS.explode({
key,
theme
}))}`, {
...opts
}));
}
export function searchMemories(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -3469,10 +3457,6 @@ export enum JobCommand {
Empty = "empty",
ClearFailed = "clear-failed"
}
export enum MapTheme {
Light = "light",
Dark = "dark"
}
export enum MemoryType {
OnThisDay = "on_this_day"
}

1122
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -285,8 +285,8 @@ export const defaults = Object.freeze<SystemConfig>({
},
map: {
enabled: true,
lightStyle: '',
darkStyle: '',
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: {
enabled: true,

View File

@ -7,7 +7,6 @@ import {
MapReverseGeocodeDto,
MapReverseGeocodeResponseDto,
} from 'src/dtos/map.dto';
import { MapThemeDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service';
@ -22,12 +21,6 @@ export class MapController {
return this.service.getMapMarkers(auth, options);
}
@Authenticated({ sharedLink: true })
@Get('style.json')
getMapStyle(@Query() dto: MapThemeDto) {
return this.service.getMapStyle(dto.theme);
}
@Authenticated()
@Get('reverse-geocode')
@HttpCode(HttpStatus.OK)

View File

@ -121,6 +121,8 @@ export class ServerConfigDto {
isInitialized!: boolean;
isOnboarded!: boolean;
externalDomain!: string;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
}
export class ServerFeaturesDto {

View File

@ -296,10 +296,12 @@ class SystemConfigMapDto {
@ValidateBoolean()
enabled!: boolean;
@IsString()
@IsNotEmpty()
@IsUrl()
lightStyle!: string;
@IsString()
@IsNotEmpty()
@IsUrl()
darkStyle!: string;
}

View File

@ -43,17 +43,6 @@ export class MapService {
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
}
async getMapStyle(theme: 'light' | 'dark') {
const { map } = await this.configCore.getConfig({ withCache: false });
const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle;
if (styleUrl) {
return this.mapRepository.fetchStyle(styleUrl);
}
return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`));
}
async reverseGeocode(dto: MapReverseGeocodeDto) {
const { lat: latitude, lon: longitude } = dto;
// eventually this should probably return an array of results

View File

@ -186,6 +186,8 @@ describe(ServerService.name, () => {
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
expect(systemMock.get).toHaveBeenCalled();
});

View File

@ -129,6 +129,8 @@ export class ServerService {
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
};
}

View File

@ -100,8 +100,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
map: {
enabled: true,
lightStyle: '',
darkStyle: '',
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: {
enabled: true,

View File

@ -6,8 +6,8 @@
import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants';
import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils';
import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk';
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
@ -57,11 +57,13 @@
let map: maplibregl.Map;
let marker: maplibregl.Marker | null = null;
$: style = (() =>
getMapStyle({
theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme,
key: getKey(),
}) as Promise<StyleSpecification>)();
$: style = (async () => {
const config = await getServerConfig();
const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT;
const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl;
const style = await fetch(styleUrl).then((response) => response.json());
return style as StyleSpecification;
})();
function handleAssetClick(assetId: string, map: Map | null) {
if (!map) {

View File

@ -32,6 +32,8 @@ export const serverConfig = writable<ServerConfig>({
isInitialized: false,
isOnboarded: false,
externalDomain: '',
mapDarkStyleUrl: '',
mapLightStyleUrl: '',
});
export const retrieveServerConfig = async () => {