mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02: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
117
cli/src/api/open-api/api.ts
generated
117
cli/src/api/open-api/api.ts
generated
@ -2224,6 +2224,20 @@ export interface MapMarkerResponseDto {
|
||||
*/
|
||||
'lon': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const MapTheme = {
|
||||
Light: 'light',
|
||||
Dark: 'dark'
|
||||
} as const;
|
||||
|
||||
export type MapTheme = typeof MapTheme[keyof typeof MapTheme];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -2822,12 +2836,6 @@ export interface ServerConfigDto {
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'loginPageMessage': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'mapTileUrl': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@ -3695,6 +3703,12 @@ export interface SystemConfigMachineLearningDto {
|
||||
* @interface SystemConfigMapDto
|
||||
*/
|
||||
export interface SystemConfigMapDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigMapDto
|
||||
*/
|
||||
'darkStyle': string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -3706,7 +3720,7 @@ export interface SystemConfigMapDto {
|
||||
* @type {string}
|
||||
* @memberof SystemConfigMapDto
|
||||
*/
|
||||
'tileUrl': string;
|
||||
'lightStyle': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@ -15063,6 +15077,51 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {MapTheme} theme
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMapStyle: async (theme: MapTheme, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'theme' is not null or undefined
|
||||
assertParamExists('getMapStyle', 'theme', theme)
|
||||
const localVarPath = `/system-config/map/style.json`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (theme !== undefined) {
|
||||
localVarQueryParameter['theme'] = theme;
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
@ -15182,6 +15241,16 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {MapTheme} theme
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getMapStyle(theme: MapTheme, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapStyle(theme, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
@ -15227,6 +15296,15 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
|
||||
getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
|
||||
return localVarFp.getConfigDefaults(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
|
||||
return localVarFp.getMapStyle(requestParameters.theme, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
@ -15247,6 +15325,20 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request parameters for getMapStyle operation in SystemConfigApi.
|
||||
* @export
|
||||
* @interface SystemConfigApiGetMapStyleRequest
|
||||
*/
|
||||
export interface SystemConfigApiGetMapStyleRequest {
|
||||
/**
|
||||
*
|
||||
* @type {MapTheme}
|
||||
* @memberof SystemConfigApiGetMapStyle
|
||||
*/
|
||||
readonly theme: MapTheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for updateConfig operation in SystemConfigApi.
|
||||
* @export
|
||||
@ -15288,6 +15380,17 @@ export class SystemConfigApi extends BaseAPI {
|
||||
return SystemConfigApiFp(this.configuration).getConfigDefaults(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof SystemConfigApi
|
||||
*/
|
||||
public getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig) {
|
||||
return SystemConfigApiFp(this.configuration).getMapStyle(requestParameters.theme, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
|
@ -1,14 +1,20 @@
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
|
||||
class MapState {
|
||||
final bool isDarkTheme;
|
||||
final bool showFavoriteOnly;
|
||||
final bool includeArchived;
|
||||
final int relativeTime;
|
||||
final Style? mapStyle;
|
||||
final bool isLoading;
|
||||
|
||||
MapState({
|
||||
this.isDarkTheme = false,
|
||||
this.showFavoriteOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.relativeTime = 0,
|
||||
this.mapStyle,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
MapState copyWith({
|
||||
@ -16,18 +22,22 @@ class MapState {
|
||||
bool? showFavoriteOnly,
|
||||
bool? includeArchived,
|
||||
int? relativeTime,
|
||||
Style? mapStyle,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return MapState(
|
||||
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
|
||||
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
relativeTime: relativeTime ?? this.relativeTime,
|
||||
mapStyle: mapStyle ?? this.mapStyle,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived)';
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -38,7 +48,9 @@ class MapState {
|
||||
other.isDarkTheme == isDarkTheme &&
|
||||
other.showFavoriteOnly == showFavoriteOnly &&
|
||||
other.relativeTime == relativeTime &&
|
||||
other.includeArchived == includeArchived;
|
||||
other.includeArchived == includeArchived &&
|
||||
other.mapStyle == mapStyle &&
|
||||
other.isLoading == isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -46,6 +58,8 @@ class MapState {
|
||||
return isDarkTheme.hashCode ^
|
||||
showFavoriteOnly.hashCode ^
|
||||
relativeTime.hashCode ^
|
||||
includeArchived.hashCode;
|
||||
includeArchived.hashCode ^
|
||||
mapStyle.hashCode ^
|
||||
isLoading.hashCode;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,23 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
|
||||
class MapStateNotifier extends StateNotifier<MapState> {
|
||||
MapStateNotifier(this._appSettingsProvider)
|
||||
MapStateNotifier(this._appSettingsProvider, this._apiService)
|
||||
: super(
|
||||
MapState(
|
||||
isDarkTheme: _appSettingsProvider
|
||||
@ -15,17 +28,69 @@ class MapStateNotifier extends StateNotifier<MapState> {
|
||||
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
|
||||
relativeTime: _appSettingsProvider
|
||||
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
isLoading: true,
|
||||
),
|
||||
) {
|
||||
_fetchStyleFromServer(
|
||||
_appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
);
|
||||
}
|
||||
|
||||
final AppSettingsService _appSettingsProvider;
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("MapStateNotifier");
|
||||
|
||||
bool get isRaster =>
|
||||
state.mapStyle != null && state.mapStyle!.rasterTileProvider != null;
|
||||
|
||||
double get maxZoom =>
|
||||
(isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 14)
|
||||
.toDouble();
|
||||
|
||||
void switchTheme(bool isDarkTheme) {
|
||||
_updateThemeMode(isDarkTheme);
|
||||
_fetchStyleFromServer(isDarkTheme);
|
||||
}
|
||||
|
||||
void _updateThemeMode(bool isDarkTheme) {
|
||||
_appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
isDarkTheme,
|
||||
);
|
||||
state = state.copyWith(isDarkTheme: isDarkTheme);
|
||||
state = state.copyWith(isDarkTheme: isDarkTheme, isLoading: true);
|
||||
}
|
||||
|
||||
void _fetchStyleFromServer(bool isDarkTheme) async {
|
||||
final styleResponse = await _apiService.systemConfigApi
|
||||
.getMapStyleWithHttpInfo(isDarkTheme ? MapTheme.dark : MapTheme.light);
|
||||
if (styleResponse.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(styleResponse.statusCode, styleResponse.body);
|
||||
}
|
||||
final styleJsonString = styleResponse.body.isNotEmpty &&
|
||||
styleResponse.statusCode != HttpStatus.noContent
|
||||
? styleResponse.body
|
||||
: null;
|
||||
|
||||
if (styleJsonString == null) {
|
||||
_log.severe('Style JSON from server is empty');
|
||||
return;
|
||||
}
|
||||
final styleJson = await compute(jsonDecode, styleJsonString);
|
||||
if (styleJson is! Map<String, dynamic>) {
|
||||
_log.severe('Style JSON from server is invalid');
|
||||
return;
|
||||
}
|
||||
final styleReader = StyleReader(uri: '');
|
||||
Style? style;
|
||||
try {
|
||||
style = await styleReader.readFromMap(styleJson);
|
||||
} finally {
|
||||
// Consume all error
|
||||
}
|
||||
state = state.copyWith(
|
||||
mapStyle: style,
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
@ -51,9 +116,44 @@ class MapStateNotifier extends StateNotifier<MapState> {
|
||||
);
|
||||
state = state.copyWith(relativeTime: relativeTime);
|
||||
}
|
||||
|
||||
Widget getTileLayer([bool forceDark = false]) {
|
||||
if (isRaster) {
|
||||
final rasterProvider = state.mapStyle!.rasterTileProvider;
|
||||
final rasterLayer = TileLayer(
|
||||
urlTemplate: rasterProvider!.url,
|
||||
maxNativeZoom: rasterProvider.maximumZoom,
|
||||
maxZoom: rasterProvider.maximumZoom.toDouble(),
|
||||
);
|
||||
return state.isDarkTheme || forceDark
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: rasterLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: rasterLayer;
|
||||
}
|
||||
if (state.mapStyle != null && !isRaster) {
|
||||
return VectorTileLayer(
|
||||
// Tiles and themes will be set for vector providers
|
||||
tileProviders: state.mapStyle!.providers!,
|
||||
theme: state.mapStyle!.theme!,
|
||||
sprites: state.mapStyle!.sprites,
|
||||
concurrency: 6,
|
||||
);
|
||||
}
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
}
|
||||
|
||||
final mapStateNotifier =
|
||||
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
|
||||
return MapStateNotifier(ref.watch(appSettingsServiceProvider));
|
||||
return MapStateNotifier(
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
@ -15,7 +15,6 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MapPageBottomSheet extends StatefulHookConsumerWidget {
|
||||
final Stream mapPageEventStream;
|
||||
@ -320,18 +319,13 @@ class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
|
||||
Positioned(
|
||||
bottom: maxHeight * currentExtend.value,
|
||||
left: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: (widget.isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[100])!,
|
||||
color:
|
||||
(widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: Text(
|
||||
'© OpenStreetMap contributors',
|
||||
'OpenStreetMap contributors',
|
||||
style: TextStyle(
|
||||
fontSize: 6,
|
||||
color: !widget.isDarkTheme
|
||||
@ -342,7 +336,6 @@ class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
|
||||
right: 15,
|
||||
|
@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@ -29,11 +28,7 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: ref.watch(
|
||||
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
|
||||
),
|
||||
);
|
||||
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
@ -55,20 +50,14 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
isDarkTheme
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
)
|
||||
: tileLayer,
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme),
|
||||
if (markers.isNotEmpty) MarkerLayer(markers: markers),
|
||||
],
|
||||
),
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@ -20,10 +21,8 @@ import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/flutter_map_extensions.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
@ -79,6 +78,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
Set<AssetMarkerData>? assetMarkers, {
|
||||
bool forceReload = false,
|
||||
}) {
|
||||
try {
|
||||
final bounds = mapController.bounds;
|
||||
if (bounds != null) {
|
||||
final oldAssetsInBounds = assetsInBounds.toSet();
|
||||
@ -95,6 +95,9 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Consume all error
|
||||
}
|
||||
}
|
||||
|
||||
void openAssetInViewer(Asset asset) {
|
||||
@ -120,6 +123,10 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
final selectedAssets = useState(<Asset>{});
|
||||
final showLoadingIndicator = useState(false);
|
||||
final refetchMarkers = useState(true);
|
||||
final isLoading =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isLoading));
|
||||
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
|
||||
final zoomLevel = math.min(maxZoom, 14.0);
|
||||
|
||||
if (refetchMarkers.value) {
|
||||
mapMarkerData.value = ref.watch(mapMarkersProvider).when(
|
||||
@ -168,7 +175,6 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
final mapMarker = mapMarkerData.value
|
||||
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
|
||||
if (mapMarker != null) {
|
||||
const zoomLevel = 16.0;
|
||||
LatLng? newCenter = mapController.centerBoundsWithPadding(
|
||||
mapMarker.point,
|
||||
const Offset(0, -120),
|
||||
@ -230,7 +236,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(
|
||||
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
|
||||
12,
|
||||
zoomLevel,
|
||||
);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
@ -359,24 +365,6 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
selectedAssets.value = selection;
|
||||
}
|
||||
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: ref.watch(
|
||||
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
|
||||
),
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final darkTileLayer = InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final markerLayer = MarkerLayer(
|
||||
markers: [
|
||||
if (closestAssetMarker.value != null)
|
||||
@ -451,6 +439,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (!isLoading)
|
||||
FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
@ -464,17 +453,18 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
center: LatLng(20, 20),
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: 18, // max level supported by OSM,
|
||||
maxZoom: maxZoom,
|
||||
onMapReady: () {
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
isDarkTheme ? darkTileLayer : tileLayer,
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(),
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
if (!isLoading)
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
@ -482,7 +472,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value)
|
||||
if (showLoadingIndicator.value || isLoading)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.35,
|
||||
left: MediaQuery.of(context).size.width * 0.425,
|
||||
|
@ -46,7 +46,9 @@ class CuratedPlacesRow extends CuratedRow {
|
||||
isDarkTheme: Theme.of(context).brightness == Brightness.dark,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black,
|
||||
@ -57,7 +59,8 @@ class CuratedPlacesRow extends CuratedRow {
|
||||
Colors.blueGrey.withOpacity(0.0),
|
||||
Colors.black.withOpacity(0.4),
|
||||
],
|
||||
stops: const [0.0, 1.0],
|
||||
stops: const [0.0, 0.4],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -2,43 +2,35 @@ import 'package:openapi/api.dart';
|
||||
|
||||
class ServerConfig {
|
||||
final int trashDays;
|
||||
final String mapTileUrl;
|
||||
|
||||
const ServerConfig({
|
||||
required this.trashDays,
|
||||
required this.mapTileUrl,
|
||||
});
|
||||
|
||||
ServerConfig copyWith({
|
||||
int? trashDays,
|
||||
String? mapTileUrl,
|
||||
}) {
|
||||
return ServerConfig(
|
||||
trashDays: trashDays ?? this.trashDays,
|
||||
mapTileUrl: mapTileUrl ?? this.mapTileUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerConfig(trashDays: $trashDays, mapTileUrl: $mapTileUrl)';
|
||||
return 'ServerConfig(trashDays: $trashDays)';
|
||||
}
|
||||
|
||||
ServerConfig.fromDto(ServerConfigDto dto)
|
||||
: trashDays = dto.trashDays,
|
||||
mapTileUrl = dto.mapTileUrl;
|
||||
ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ServerConfig &&
|
||||
other.trashDays == trashDays &&
|
||||
other.mapTileUrl == mapTileUrl;
|
||||
return other is ServerConfig && other.trashDays == trashDays;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return trashDays.hashCode ^ mapTileUrl.hashCode;
|
||||
return trashDays.hashCode;
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
trash: true,
|
||||
),
|
||||
serverConfig: const ServerConfig(
|
||||
mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
trashDays: 30,
|
||||
),
|
||||
serverDiskInfo: const ServerDiskInfo(
|
||||
|
@ -22,6 +22,7 @@ class ApiService {
|
||||
late PersonApi personApi;
|
||||
late AuditApi auditApi;
|
||||
late SharedLinkApi sharedLinkApi;
|
||||
late SystemConfigApi systemConfigApi;
|
||||
late ActivityApi activityApi;
|
||||
|
||||
ApiService() {
|
||||
@ -48,6 +49,7 @@ class ApiService {
|
||||
personApi = PersonApi(_apiClient);
|
||||
auditApi = AuditApi(_apiClient);
|
||||
sharedLinkApi = SharedLinkApi(_apiClient);
|
||||
systemConfigApi = SystemConfigApi(_apiClient);
|
||||
activityApi = ActivityApi(_apiClient);
|
||||
}
|
||||
|
||||
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -81,6 +81,7 @@ doc/LoginCredentialDto.md
|
||||
doc/LoginResponseDto.md
|
||||
doc/LogoutResponseDto.md
|
||||
doc/MapMarkerResponseDto.md
|
||||
doc/MapTheme.md
|
||||
doc/MemoryLaneResponseDto.md
|
||||
doc/MergePersonDto.md
|
||||
doc/ModelType.md
|
||||
@ -263,6 +264,7 @@ lib/model/login_credential_dto.dart
|
||||
lib/model/login_response_dto.dart
|
||||
lib/model/logout_response_dto.dart
|
||||
lib/model/map_marker_response_dto.dart
|
||||
lib/model/map_theme.dart
|
||||
lib/model/memory_lane_response_dto.dart
|
||||
lib/model/merge_person_dto.dart
|
||||
lib/model/model_type.dart
|
||||
@ -418,6 +420,7 @@ test/login_credential_dto_test.dart
|
||||
test/login_response_dto_test.dart
|
||||
test/logout_response_dto_test.dart
|
||||
test/map_marker_response_dto_test.dart
|
||||
test/map_theme_test.dart
|
||||
test/memory_lane_response_dto_test.dart
|
||||
test/merge_person_dto_test.dart
|
||||
test/model_type_test.dart
|
||||
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/MapTheme.md
generated
Normal file
BIN
mobile/openapi/doc/MapTheme.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/ServerConfigDto.md
generated
BIN
mobile/openapi/doc/ServerConfigDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigApi.md
generated
BIN
mobile/openapi/doc/SystemConfigApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigMapDto.md
generated
BIN
mobile/openapi/doc/SystemConfigMapDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/system_config_api.dart
generated
BIN
mobile/openapi/lib/api/system_config_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_helper.dart
generated
BIN
mobile/openapi/lib/api_helper.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/map_theme.dart
generated
Normal file
BIN
mobile/openapi/lib/model/map_theme.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_map_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_map_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/map_theme_test.dart
generated
Normal file
BIN
mobile/openapi/test/map_theme_test.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/server_config_dto_test.dart
generated
BIN
mobile/openapi/test/server_config_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_api_test.dart
generated
BIN
mobile/openapi/test/system_config_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_map_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_map_dto_test.dart
generated
Binary file not shown.
@ -345,6 +345,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
executor_lib:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: executor_lib
|
||||
sha256: "544889daa5726462657dab6410b75f2f8e3a77479d85b307a25c346e243bc38e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1131,6 +1139,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1520,6 +1536,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
vector_map_tiles:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: immich_above_4
|
||||
resolved-ref: dc685bdbcca2ff2b49b4d0fb77b7bc17fad48608
|
||||
url: "https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git"
|
||||
source: git
|
||||
version: "4.0.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1528,6 +1553,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
vector_tile:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_tile
|
||||
sha256: "2ac77f6bbd7ddd97efe059207d67bb7eaf807ab98ad58d99fe41a22c230f44e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
vector_tile_renderer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vector_tile_renderer
|
||||
sha256: de212da0f5e48107d3b763a940a428eb1f49d8a4664d41ac0b654f77209a2d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -28,6 +28,13 @@ dependencies:
|
||||
flutter_map: ^4.0.0
|
||||
flutter_map_heatmap: ^0.0.4
|
||||
geolocator: ^10.0.0 # used to move to current location in map view
|
||||
vector_map_tiles:
|
||||
git:
|
||||
url: https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git
|
||||
ref: immich_above_4
|
||||
# Adding the following as direct dependency since flutter cannot detect them as transitive dep
|
||||
vector_tile_renderer: ^4.0.0
|
||||
executor_lib: 1.1.1
|
||||
flutter_udid: ^2.0.0
|
||||
package_info_plus: ^4.1.0
|
||||
url_launcher: ^6.1.3
|
||||
|
@ -18,6 +18,7 @@ ENV NODE_ENV=production
|
||||
COPY --from=prod /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=prod /usr/src/app/dist ./dist
|
||||
COPY --from=prod /usr/src/app/bin ./bin
|
||||
COPY ./assets ./assets
|
||||
|
||||
COPY LICENSE /licenses/LICENSE.txt
|
||||
COPY LICENSE /LICENSE
|
||||
|
1895
server/assets/style-dark.json
Normal file
1895
server/assets/style-dark.json
Normal file
File diff suppressed because it is too large
Load Diff
2000
server/assets/style-light.json
Normal file
2000
server/assets/style-light.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -4881,6 +4881,47 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/system-config/map/style.json": {
|
||||
"get": {
|
||||
"operationId": "getMapStyle",
|
||||
"parameters": [
|
||||
{
|
||||
"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": [
|
||||
"System Config"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/system-config/storage-template-options": {
|
||||
"get": {
|
||||
"operationId": "getStorageTemplateOptions",
|
||||
@ -7418,6 +7459,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MapTheme": {
|
||||
"enum": [
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MemoryLaneResponseDto": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
@ -7887,9 +7935,6 @@
|
||||
"loginPageMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
"mapTileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"oauthButtonText": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -7901,7 +7946,6 @@
|
||||
"trashDays",
|
||||
"oauthButtonText",
|
||||
"loginPageMessage",
|
||||
"mapTileUrl",
|
||||
"isInitialized"
|
||||
],
|
||||
"type": "object"
|
||||
@ -8552,16 +8596,20 @@
|
||||
},
|
||||
"SystemConfigMapDto": {
|
||||
"properties": {
|
||||
"darkStyle": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tileUrl": {
|
||||
"lightStyle": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"tileUrl"
|
||||
"lightStyle",
|
||||
"darkStyle"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -96,7 +96,6 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
|
||||
expect(body).toEqual({
|
||||
loginPageMessage: '',
|
||||
oauthButtonText: 'Login with OAuth',
|
||||
mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
trashDays: 30,
|
||||
isInitialized: true,
|
||||
});
|
||||
|
@ -6,6 +6,7 @@ export const newSystemConfigRepositoryMock = (reset = true): jest.Mocked<ISystem
|
||||
}
|
||||
|
||||
return {
|
||||
fetchStyle: jest.fn(),
|
||||
load: jest.fn().mockResolvedValue([]),
|
||||
readFile: jest.fn(),
|
||||
saveAll: jest.fn().mockResolvedValue([]),
|
||||
|
9645
web/package-lock.json
generated
9645
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -31,8 +31,6 @@
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/dom-to-image": "^2.6.4",
|
||||
"@types/justified-layout": "^4.1.0",
|
||||
"@types/leaflet": "^1.9.1",
|
||||
"@types/leaflet.markercluster": "^1.5.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/luxon": "^3.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.53.0",
|
||||
@ -70,13 +68,13 @@
|
||||
"dom-to-image": "^2.6.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"justified-layout": "^4.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"luxon": "^3.2.1",
|
||||
"maplibre-gl": "^3.5.2",
|
||||
"socket.io-client": "^4.6.1",
|
||||
"svelte-loading-spinners": "^0.3.4",
|
||||
"svelte-local-storage-store": "^0.5.0",
|
||||
"svelte-maplibre": "^0.6.0",
|
||||
"thumbhash": "^0.1.1"
|
||||
}
|
||||
}
|
||||
|
117
web/src/api/open-api/api.ts
generated
117
web/src/api/open-api/api.ts
generated
@ -2224,6 +2224,20 @@ export interface MapMarkerResponseDto {
|
||||
*/
|
||||
'lon': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const MapTheme = {
|
||||
Light: 'light',
|
||||
Dark: 'dark'
|
||||
} as const;
|
||||
|
||||
export type MapTheme = typeof MapTheme[keyof typeof MapTheme];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -2822,12 +2836,6 @@ export interface ServerConfigDto {
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'loginPageMessage': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'mapTileUrl': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@ -3695,6 +3703,12 @@ export interface SystemConfigMachineLearningDto {
|
||||
* @interface SystemConfigMapDto
|
||||
*/
|
||||
export interface SystemConfigMapDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigMapDto
|
||||
*/
|
||||
'darkStyle': string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -3706,7 +3720,7 @@ export interface SystemConfigMapDto {
|
||||
* @type {string}
|
||||
* @memberof SystemConfigMapDto
|
||||
*/
|
||||
'tileUrl': string;
|
||||
'lightStyle': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@ -15063,6 +15077,51 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {MapTheme} theme
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMapStyle: async (theme: MapTheme, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'theme' is not null or undefined
|
||||
assertParamExists('getMapStyle', 'theme', theme)
|
||||
const localVarPath = `/system-config/map/style.json`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (theme !== undefined) {
|
||||
localVarQueryParameter['theme'] = theme;
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
@ -15182,6 +15241,16 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getConfigDefaults(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {MapTheme} theme
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getMapStyle(theme: MapTheme, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMapStyle(theme, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
@ -15227,6 +15296,15 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
|
||||
getConfigDefaults(options?: AxiosRequestConfig): AxiosPromise<SystemConfigDto> {
|
||||
return localVarFp.getConfigDefaults(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
|
||||
return localVarFp.getMapStyle(requestParameters.theme, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
@ -15247,6 +15325,20 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request parameters for getMapStyle operation in SystemConfigApi.
|
||||
* @export
|
||||
* @interface SystemConfigApiGetMapStyleRequest
|
||||
*/
|
||||
export interface SystemConfigApiGetMapStyleRequest {
|
||||
/**
|
||||
*
|
||||
* @type {MapTheme}
|
||||
* @memberof SystemConfigApiGetMapStyle
|
||||
*/
|
||||
readonly theme: MapTheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for updateConfig operation in SystemConfigApi.
|
||||
* @export
|
||||
@ -15288,6 +15380,17 @@ export class SystemConfigApi extends BaseAPI {
|
||||
return SystemConfigApiFp(this.configuration).getConfigDefaults(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {SystemConfigApiGetMapStyleRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof SystemConfigApi
|
||||
*/
|
||||
public getMapStyle(requestParameters: SystemConfigApiGetMapStyleRequest, options?: AxiosRequestConfig) {
|
||||
return SystemConfigApiFp(this.configuration).getMapStyle(requestParameters.theme, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
|
@ -9,9 +9,9 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import SettingAccordion from '../setting-accordion.svelte';
|
||||
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||
import SettingSwitch from '../setting-switch.svelte';
|
||||
import SettingSelect from '../setting-select.svelte';
|
||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||
|
||||
export let config: SystemConfigDto; // this is the config that is being edited
|
||||
export let disabled = false;
|
||||
@ -34,7 +34,8 @@
|
||||
...current,
|
||||
map: {
|
||||
enabled: config.map.enabled,
|
||||
tileUrl: config.map.tileUrl,
|
||||
lightStyle: config.map.lightStyle,
|
||||
darkStyle: config.map.darkStyle,
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: config.reverseGeocoding.enabled,
|
||||
@ -95,12 +96,19 @@
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="Tile URL"
|
||||
desc="URL to a leaflet compatible tile server"
|
||||
bind:value={config.map.tileUrl}
|
||||
required={true}
|
||||
label="Light Style"
|
||||
desc="URL to a style.json map theme"
|
||||
bind:value={config.map.lightStyle}
|
||||
disabled={disabled || !config.map.enabled}
|
||||
isEdited={config.map.tileUrl !== savedConfig.map.tileUrl}
|
||||
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
|
||||
/>
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="Dark Style"
|
||||
desc="URL to a style.json map theme"
|
||||
bind:value={config.map.darkStyle}
|
||||
disabled={disabled || !config.map.enabled}
|
||||
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
|
||||
/>
|
||||
</div></SettingAccordion
|
||||
>
|
||||
|
@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { getAssetFilename } from '$lib/utils/asset-utils';
|
||||
import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
|
||||
import type { LatLngTuple } from 'leaflet';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
@ -12,6 +11,7 @@
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import { mdiCalendar, mdiCameraIris, mdiClose, mdiImageOutline, mdiMapMarkerOutline } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Map from '../shared-components/map/map.svelte';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let albums: AlbumResponseDto[] = [];
|
||||
@ -36,20 +36,10 @@
|
||||
const lng = asset.exifInfo?.longitude;
|
||||
|
||||
if (lat && lng) {
|
||||
return [Number(lat.toFixed(7)), Number(lng.toFixed(7))] as LatLngTuple;
|
||||
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
|
||||
}
|
||||
})();
|
||||
|
||||
$: {
|
||||
if (!asset.exifInfo) {
|
||||
api.assetApi.getAssetById({ id: asset.id }).then((res) => {
|
||||
asset.exifInfo = res.data?.exifInfo;
|
||||
});
|
||||
}
|
||||
}
|
||||
$: lat = latlng ? latlng[0] : undefined;
|
||||
$: lng = latlng ? latlng[1] : undefined;
|
||||
|
||||
$: people = asset.people || [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@ -297,24 +287,21 @@
|
||||
|
||||
{#if latlng && $featureFlags.loaded && $featureFlags.map}
|
||||
<div class="h-[360px]">
|
||||
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
|
||||
<Map center={latlng} zoom={14}>
|
||||
<TileLayer
|
||||
urlTemplate={$serverConfig.mapTileUrl}
|
||||
options={{
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}}
|
||||
/>
|
||||
<Marker {latlng}>
|
||||
<p>
|
||||
{lat}, {lng}
|
||||
</p>
|
||||
<a href="https://www.openstreetmap.org/?mlat={lat}&mlon={lng}&zoom=15#map=15/{lat}/{lng}">
|
||||
<Map mapMarkers={[{ lat: latlng.lat, lon: latlng.lng, id: asset.id }]} center={latlng} zoom={14} simplified>
|
||||
<svelte:fragment slot="popup" let:marker>
|
||||
{@const { lat, lon } = marker}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
|
||||
<a
|
||||
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=15#map=15/{lat}/{lon}"
|
||||
target="_blank"
|
||||
class="font-medium text-immich-primary"
|
||||
>
|
||||
Open in OpenStreetMap
|
||||
</a>
|
||||
</Marker>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Map>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
@ -1,35 +0,0 @@
|
||||
<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,5 +0,0 @@
|
||||
export { default as Control } from './control.svelte';
|
||||
export { default as Map } from './map.svelte';
|
||||
export { default as AssetMarkerCluster } from './marker-cluster/asset-marker-cluster.svelte';
|
||||
export { default as Marker } from './marker.svelte';
|
||||
export { default as TileLayer } from './tile-layer.svelte';
|
@ -1,50 +0,0 @@
|
||||
<script lang="ts" context="module">
|
||||
import { createContext } from '$lib/utils/context';
|
||||
|
||||
const { get: getContext, set: setMapContext } = createContext<() => Map>();
|
||||
|
||||
export const getMapContext = () => {
|
||||
const getMap = getContext();
|
||||
return getMap();
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { Map, type LatLngExpression, type MapOptions } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export let center: LatLngExpression;
|
||||
export let zoom: number;
|
||||
export let options: MapOptions | undefined = undefined;
|
||||
export let allowDarkMode = false;
|
||||
let container: HTMLDivElement;
|
||||
let map: Map;
|
||||
|
||||
setMapContext(() => map);
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
map = new Map(container, options);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (map) map.remove();
|
||||
});
|
||||
|
||||
$: if (map) map.setView(center, zoom);
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="h-full w-full" class:map-dark={allowDarkMode}>
|
||||
{#if map}
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.dark) .map-dark :global(.leaflet-layer) {
|
||||
filter: invert(100%) brightness(130%) saturate(0%);
|
||||
}
|
||||
</style>
|
@ -1,31 +0,0 @@
|
||||
.asset-marker-icon {
|
||||
@apply rounded-full;
|
||||
@apply object-cover;
|
||||
@apply border;
|
||||
@apply border-immich-primary;
|
||||
@apply transition-all;
|
||||
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-icon {
|
||||
@apply h-full;
|
||||
@apply w-full;
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply rounded-full;
|
||||
@apply font-bold;
|
||||
@apply bg-violet-50;
|
||||
@apply border;
|
||||
@apply border-immich-primary;
|
||||
@apply text-immich-primary;
|
||||
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
|
||||
}
|
||||
|
||||
.dark .map-dark .marker-cluster-icon {
|
||||
@apply bg-blue-200;
|
||||
@apply text-black;
|
||||
@apply border-blue-200;
|
||||
box-shadow: none;
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
<script lang="ts" context="module">
|
||||
import { createContext } from '$lib/utils/context';
|
||||
import { MarkerClusterGroup } from 'leaflet';
|
||||
|
||||
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
|
||||
|
||||
export const getClusterContext = () => {
|
||||
return getContext()();
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { MapMarkerResponseDto } from '@api';
|
||||
import { DivIcon, LeafletEvent, LeafletMouseEvent, MarkerCluster, Point } from 'leaflet';
|
||||
import 'leaflet.markercluster';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import { getMapContext } from '../map.svelte';
|
||||
import AssetMarker from './asset-marker';
|
||||
import './asset-marker-cluster.css';
|
||||
|
||||
export let markers: MapMarkerResponseDto[];
|
||||
export let spiderfyLimit = 10;
|
||||
let cluster: MarkerClusterGroup;
|
||||
|
||||
const map = getMapContext();
|
||||
const dispatch = createEventDispatcher<{
|
||||
view: { assetIds: string[]; activeAssetIndex: number };
|
||||
}>();
|
||||
|
||||
setClusterContext(() => cluster);
|
||||
|
||||
onMount(() => {
|
||||
cluster = new MarkerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: false,
|
||||
spiderfyOnMaxZoom: false,
|
||||
maxClusterRadius: (zoom) => 80 - zoom * 2,
|
||||
spiderLegPolylineOptions: { opacity: 0 },
|
||||
spiderfyDistanceMultiplier: 3,
|
||||
iconCreateFunction: (options) => {
|
||||
const childCount = options.getChildCount();
|
||||
const iconSize = childCount > spiderfyLimit ? 45 : 40;
|
||||
|
||||
return new DivIcon({
|
||||
html: `<div class="marker-cluster-icon">${childCount}</div>`,
|
||||
className: '',
|
||||
iconSize: new Point(iconSize, iconSize),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
cluster.on('clusterclick', (event: LeafletEvent) => {
|
||||
const markerCluster: MarkerCluster = event.sourceTarget;
|
||||
const childCount = markerCluster.getChildCount();
|
||||
|
||||
if (childCount > spiderfyLimit) {
|
||||
const markers = markerCluster.getAllChildMarkers() as AssetMarker[];
|
||||
onView(markers, markers[0].id);
|
||||
} else {
|
||||
markerCluster.spiderfy();
|
||||
}
|
||||
});
|
||||
|
||||
cluster.on('click', (event: LeafletMouseEvent) => {
|
||||
const marker: AssetMarker = event.sourceTarget;
|
||||
const markerCluster = getClusterByMarker(marker);
|
||||
const markers = markerCluster ? (markerCluster.getAllChildMarkers() as AssetMarker[]) : [marker];
|
||||
|
||||
onView(markers, marker.id);
|
||||
});
|
||||
|
||||
map.addLayer(cluster);
|
||||
});
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
const getClusterByMarker = (marker: any): MarkerCluster | undefined => {
|
||||
const mapZoom = map.getZoom();
|
||||
|
||||
while (marker && marker._zoom !== mapZoom) {
|
||||
marker = marker.__parent;
|
||||
}
|
||||
|
||||
return marker;
|
||||
};
|
||||
|
||||
const onView = (markers: AssetMarker[], activeAssetId: string) => {
|
||||
const assetIds = markers.map((marker) => marker.id);
|
||||
const activeAssetIndex = assetIds.indexOf(activeAssetId) || 0;
|
||||
dispatch('view', { assetIds, activeAssetIndex });
|
||||
};
|
||||
|
||||
$: if (cluster) {
|
||||
const leafletMarkers = markers.map((marker) => new AssetMarker(marker));
|
||||
|
||||
cluster.clearLayers();
|
||||
cluster.addLayers(leafletMarkers);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (cluster) cluster.remove();
|
||||
});
|
||||
</script>
|
@ -1,37 +0,0 @@
|
||||
import { api, MapMarkerResponseDto } from '@api';
|
||||
import { Icon, Map, Marker } from 'leaflet';
|
||||
|
||||
export default class AssetMarker extends Marker {
|
||||
id: string;
|
||||
private iconCreated = false;
|
||||
|
||||
constructor(marker: MapMarkerResponseDto) {
|
||||
super([marker.lat, marker.lon]);
|
||||
this.id = marker.id;
|
||||
}
|
||||
|
||||
onAdd(map: Map) {
|
||||
// Set icon when the marker gets actually added to the map. This only
|
||||
// gets called for individual assets and when selecting a cluster, so
|
||||
// creating an icon for every marker in advance is pretty wasteful.
|
||||
if (!this.iconCreated) {
|
||||
this.iconCreated = true;
|
||||
this.setIcon(this.getIcon());
|
||||
}
|
||||
|
||||
return super.onAdd(map);
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return new Icon({
|
||||
iconUrl: api.getAssetThumbnailUrl(this.id),
|
||||
iconRetinaUrl: api.getAssetThumbnailUrl(this.id),
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
className: 'asset-marker-icon',
|
||||
});
|
||||
}
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Marker, Icon, type LatLngExpression } from 'leaflet';
|
||||
import { getMapContext } from './map.svelte';
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
export let latlng: LatLngExpression;
|
||||
let popupHTML: string;
|
||||
let marker: Marker;
|
||||
|
||||
const defaultIcon = new Icon({
|
||||
iconUrl,
|
||||
iconRetinaUrl,
|
||||
shadowUrl,
|
||||
|
||||
// Default values from Leaflet
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
const map = getMapContext();
|
||||
|
||||
onMount(() => {
|
||||
marker = new Marker(latlng, {
|
||||
icon: defaultIcon,
|
||||
}).addTo(map);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (marker) marker.remove();
|
||||
});
|
||||
|
||||
$: if (marker) {
|
||||
marker.setLatLng(latlng);
|
||||
|
||||
if (popupHTML) {
|
||||
marker.bindPopup(popupHTML);
|
||||
} else {
|
||||
marker.unbindPopup();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<span contenteditable="true" bind:innerHTML={popupHTML} class="hide">
|
||||
<slot />
|
||||
</span>
|
@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { TileLayer, type TileLayerOptions } from 'leaflet';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getMapContext } from './map.svelte';
|
||||
|
||||
export let urlTemplate: string;
|
||||
export let options: TileLayerOptions | undefined = undefined;
|
||||
|
||||
let tileLayer: TileLayer;
|
||||
|
||||
const map = getMapContext();
|
||||
|
||||
onMount(() => {
|
||||
tileLayer = new TileLayer(urlTemplate, options).addTo(map);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
tileLayer?.remove();
|
||||
});
|
||||
</script>
|
146
web/src/lib/components/shared-components/map/map.svelte
Normal file
146
web/src/lib/components/shared-components/map/map.svelte
Normal file
@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
MapLibre,
|
||||
GeoJSON,
|
||||
MarkerLayer,
|
||||
AttributionControl,
|
||||
ControlButton,
|
||||
Control,
|
||||
ControlGroup,
|
||||
Map,
|
||||
FullscreenControl,
|
||||
GeolocateControl,
|
||||
NavigationControl,
|
||||
ScaleControl,
|
||||
Popup,
|
||||
} from 'svelte-maplibre';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { MapMarkerResponseDto, api } from '@api';
|
||||
import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
|
||||
import type { Feature, Geometry, GeoJsonProperties, Point } from 'geojson';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiCog } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let mapMarkers: MapMarkerResponseDto[];
|
||||
export let showSettingsModal: boolean | undefined = undefined;
|
||||
export let zoom: number | undefined = undefined;
|
||||
export let center: LngLatLike | undefined = undefined;
|
||||
export let simplified = false;
|
||||
|
||||
$: style = (async () => {
|
||||
const { data } = await api.systemConfigApi.getMapStyle({ theme: $mapSettings.allowDarkMode ? 'dark' : 'light' });
|
||||
return data as StyleSpecification;
|
||||
})();
|
||||
|
||||
const dispatch = createEventDispatcher<{ selected: string[] }>();
|
||||
|
||||
function handleAssetClick(assetId: string, map: Map | null) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
dispatch('selected', [assetId]);
|
||||
}
|
||||
|
||||
function handleClusterClick(clusterId: number, map: Map | null) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapSource = map?.getSource('geojson') as GeoJSONSource;
|
||||
mapSource.getClusterLeaves(clusterId, 10000, 0, (error, leaves) => {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaves) {
|
||||
const ids = leaves.map((leaf) => leaf.properties?.id);
|
||||
dispatch('selected', ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type FeaturePoint = Feature<Point, { id: string }>;
|
||||
|
||||
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
|
||||
properties: {
|
||||
id: marker.id,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const asMarker = (feature: Feature<Geometry, GeoJsonProperties>): MapMarkerResponseDto => {
|
||||
const featurePoint = feature as FeaturePoint;
|
||||
return {
|
||||
lat: featurePoint.geometry.coordinates[0],
|
||||
lon: featurePoint.geometry.coordinates[1],
|
||||
id: featurePoint.properties.id,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
{#await style then style}
|
||||
<MapLibre {style} class="h-full" {center} {zoom} attributionControl={false} let:map>
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" fitBoundsOptions={{ maxZoom: 12 }} />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
{#if showSettingsModal !== undefined}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
<GeoJSON
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: mapMarkers.map((marker) => {
|
||||
return asFeature(marker);
|
||||
}),
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ maxZoom: 14, radius: 500 }}
|
||||
>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
let:feature
|
||||
on:click={(event) => {
|
||||
handleClusterClick(event.detail.feature.properties.cluster_id, map);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
>
|
||||
{feature.properties?.point_count}
|
||||
</div>
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
let:feature
|
||||
on:click={(event) => {
|
||||
$$slots.popup || handleAssetClick(event.detail.feature.properties.id, map);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={api.getAssetThumbnailUrl(feature.properties?.id)}
|
||||
class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150"
|
||||
alt={`Image with id ${feature.properties?.id}`}
|
||||
/>
|
||||
{#if $$slots.popup}
|
||||
<Popup openOn="click" closeOnClickOutside>
|
||||
<slot name="popup" marker={asMarker(feature)} />
|
||||
</Popup>
|
||||
{/if}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
</MapLibre>
|
||||
{/await}
|
@ -24,7 +24,6 @@ export type ServerConfig = ServerConfigDto & { loaded: boolean };
|
||||
export const serverConfig = writable<ServerConfig>({
|
||||
loaded: false,
|
||||
oauthButtonText: '',
|
||||
mapTileUrl: '',
|
||||
loginPageMessage: '',
|
||||
trashDays: 30,
|
||||
isInitialized: false,
|
||||
|
@ -7,29 +7,26 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { MapMarkerResponseDto, api } from '@api';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { mdiCog } from '@mdi/js';
|
||||
import Map from '$lib/components/shared-components/map/map.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
|
||||
|
||||
let leaflet: typeof import('$lib/components/shared-components/leaflet');
|
||||
let mapMarkers: MapMarkerResponseDto[] = [];
|
||||
let abortController: AbortController;
|
||||
let mapMarkers: MapMarkerResponseDto[] = [];
|
||||
let viewingAssets: string[] = [];
|
||||
let viewingAssetCursor = 0;
|
||||
let showSettingsModal = false;
|
||||
|
||||
onMount(() => {
|
||||
loadMapMarkers().then((data) => (mapMarkers = data));
|
||||
import('$lib/components/shared-components/leaflet').then((data) => (leaflet = data));
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@ -84,10 +81,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onViewAssets(assetIds: string[], activeAssetIndex: number) {
|
||||
assetViewingStore.setAssetId(assetIds[activeAssetIndex]);
|
||||
function onViewAssets(assetIds: string[]) {
|
||||
assetViewingStore.setAssetId(assetIds[0]);
|
||||
viewingAssets = assetIds;
|
||||
viewingAssetCursor = activeAssetIndex;
|
||||
viewingAssetCursor = 0;
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
@ -106,44 +103,9 @@
|
||||
{#if $featureFlags.loaded && $featureFlags.map}
|
||||
<UserPageLayout user={data.user} title={data.meta.title}>
|
||||
<div class="isolate h-full w-full">
|
||||
{#if leaflet}
|
||||
{@const { Map, TileLayer, AssetMarkerCluster, Control } = leaflet}
|
||||
<Map
|
||||
center={[30, 0]}
|
||||
zoom={3}
|
||||
allowDarkMode={$mapSettings.allowDarkMode}
|
||||
options={{
|
||||
maxBounds: [
|
||||
[-90, -180],
|
||||
[90, 180],
|
||||
],
|
||||
minZoom: 2,
|
||||
}}
|
||||
<Map bind:mapMarkers bind:showSettingsModal on:selected={(event) => onViewAssets(event.detail)} />
|
||||
</div></UserPageLayout
|
||||
>
|
||||
<TileLayer
|
||||
urlTemplate={$serverConfig.mapTileUrl}
|
||||
options={{
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}}
|
||||
/>
|
||||
<AssetMarkerCluster
|
||||
markers={mapMarkers}
|
||||
on:view={({ detail }) => onViewAssets(detail.assetIds, detail.activeAssetIndex)}
|
||||
/>
|
||||
<Control>
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-sm border-2 border-black/20 bg-white font-bold text-black/70 hover:bg-gray-50 focus:bg-gray-50"
|
||||
title="Open map settings"
|
||||
on:click={() => (showSettingsModal = true)}
|
||||
>
|
||||
<Icon path={mdiCog} size="100%" class="p-1" />
|
||||
</button>
|
||||
</Control>
|
||||
</Map>
|
||||
{/if}
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
<AssetViewer
|
||||
@ -152,6 +114,7 @@
|
||||
on:next={navigateNext}
|
||||
on:previous={navigatePrevious}
|
||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
||||
isShared={false}
|
||||
/>
|
||||
{/if}
|
||||
</Portal>
|
||||
|
Loading…
Reference in New Issue
Block a user