feat: location favorites

This commit is contained in:
Yaros
2025-12-12 15:19:24 +01:00
parent 33cdea88aa
commit d307843870
20 changed files with 1388 additions and 6 deletions

View File

@@ -1112,6 +1112,8 @@
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder",
"favorite": "Favorite",
"favorite_locations": "Favorite Locations",
"favorite_locations_not_found": "No favorite locations saved",
"favorite_action_prompt": "{count} added to Favorites",
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
"favorites": "Favorites",

View File

@@ -163,8 +163,12 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**createFavoriteLocation**](doc//MapApi.md#createfavoritelocation) | **POST** /map/favorite-locations | Create favorite location
*MapApi* | [**deleteFavoriteLocation**](doc//MapApi.md#deletefavoritelocation) | **DELETE** /map/favorite-locations/{id} | Delete favorite location
*MapApi* | [**getFavoriteLocations**](doc//MapApi.md#getfavoritelocations) | **GET** /map/favorite-locations | Get favorite locations
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
*MapApi* | [**updateFavoriteLocation**](doc//MapApi.md#updatefavoritelocation) | **PUT** /map/favorite-locations/{id} | Update favorite location
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | Create a memory
*MemoriesApi* | [**deleteMemory**](doc//MemoriesApi.md#deletememory) | **DELETE** /memories/{id} | Delete a memory
@@ -384,6 +388,7 @@ Class | Method | HTTP request | Description
- [Colorspace](doc//Colorspace.md)
- [ContributorCountResponseDto](doc//ContributorCountResponseDto.md)
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateFavoriteLocationDto](doc//CreateFavoriteLocationDto.md)
- [CreateLibraryDto](doc//CreateLibraryDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
@@ -399,6 +404,7 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- [FavoriteLocationResponseDto](doc//FavoriteLocationResponseDto.md)
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
@@ -613,6 +619,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateFavoriteLocationDto](doc//UpdateFavoriteLocationDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)

View File

@@ -136,6 +136,7 @@ part 'model/check_existing_assets_response_dto.dart';
part 'model/colorspace.dart';
part 'model/contributor_count_response_dto.dart';
part 'model/create_album_dto.dart';
part 'model/create_favorite_location_dto.dart';
part 'model/create_library_dto.dart';
part 'model/create_profile_image_response_dto.dart';
part 'model/database_backup_config.dart';
@@ -151,6 +152,7 @@ part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart';
part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart';
part 'model/favorite_location_response_dto.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
@@ -365,6 +367,7 @@ part 'model/trash_response_dto.dart';
part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_favorite_location_dto.dart';
part 'model/update_library_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart';

View File

@@ -16,6 +16,162 @@ class MapApi {
final ApiClient apiClient;
/// Create favorite location
///
/// Create a new favorite location for the user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [CreateFavoriteLocationDto] createFavoriteLocationDto (required):
Future<Response> createFavoriteLocationWithHttpInfo(CreateFavoriteLocationDto createFavoriteLocationDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations';
// ignore: prefer_final_locals
Object? postBody = createFavoriteLocationDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Create favorite location
///
/// Create a new favorite location for the user.
///
/// Parameters:
///
/// * [CreateFavoriteLocationDto] createFavoriteLocationDto (required):
Future<FavoriteLocationResponseDto?> createFavoriteLocation(CreateFavoriteLocationDto createFavoriteLocationDto,) async {
final response = await createFavoriteLocationWithHttpInfo(createFavoriteLocationDto,);
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), 'FavoriteLocationResponseDto',) as FavoriteLocationResponseDto;
}
return null;
}
/// Delete favorite location
///
/// Delete a favorite location by its ID.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteFavoriteLocationWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Delete favorite location
///
/// Delete a favorite location by its ID.
///
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteFavoriteLocation(String id,) async {
final response = await deleteFavoriteLocationWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Get favorite locations
///
/// Retrieve a list of user's favorite locations.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getFavoriteLocationsWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Get favorite locations
///
/// Retrieve a list of user's favorite locations.
Future<List<FavoriteLocationResponseDto>?> getFavoriteLocations() async {
final response = await getFavoriteLocationsWithHttpInfo();
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) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<FavoriteLocationResponseDto>') as List)
.cast<FavoriteLocationResponseDto>()
.toList(growable: false);
}
return null;
}
/// Retrieve map markers
///
/// Retrieve a list of latitude and longitude coordinates for every asset with location data.
@@ -179,4 +335,65 @@ class MapApi {
}
return null;
}
/// Update favorite location
///
/// Update an existing favorite location.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateFavoriteLocationDto] updateFavoriteLocationDto (required):
Future<Response> updateFavoriteLocationWithHttpInfo(String id, UpdateFavoriteLocationDto updateFavoriteLocationDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/map/favorite-locations/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateFavoriteLocationDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Update favorite location
///
/// Update an existing favorite location.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateFavoriteLocationDto] updateFavoriteLocationDto (required):
Future<FavoriteLocationResponseDto?> updateFavoriteLocation(String id, UpdateFavoriteLocationDto updateFavoriteLocationDto,) async {
final response = await updateFavoriteLocationWithHttpInfo(id, updateFavoriteLocationDto,);
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), 'FavoriteLocationResponseDto',) as FavoriteLocationResponseDto;
}
return null;
}
}

View File

@@ -320,6 +320,8 @@ class ApiClient {
return ContributorCountResponseDto.fromJson(value);
case 'CreateAlbumDto':
return CreateAlbumDto.fromJson(value);
case 'CreateFavoriteLocationDto':
return CreateFavoriteLocationDto.fromJson(value);
case 'CreateLibraryDto':
return CreateLibraryDto.fromJson(value);
case 'CreateProfileImageResponseDto':
@@ -350,6 +352,8 @@ class ApiClient {
return FaceDto.fromJson(value);
case 'FacialRecognitionConfig':
return FacialRecognitionConfig.fromJson(value);
case 'FavoriteLocationResponseDto':
return FavoriteLocationResponseDto.fromJson(value);
case 'FoldersResponse':
return FoldersResponse.fromJson(value);
case 'FoldersUpdate':
@@ -778,6 +782,8 @@ class ApiClient {
return UpdateAlbumUserDto.fromJson(value);
case 'UpdateAssetDto':
return UpdateAssetDto.fromJson(value);
case 'UpdateFavoriteLocationDto':
return UpdateFavoriteLocationDto.fromJson(value);
case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value);
case 'UsageByUserDto':

View File

@@ -0,0 +1,115 @@
//
// 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 CreateFavoriteLocationDto {
/// Returns a new [CreateFavoriteLocationDto] instance.
CreateFavoriteLocationDto({
required this.latitude,
required this.longitude,
required this.name,
});
num latitude;
num longitude;
String name;
@override
bool operator ==(Object other) => identical(this, other) || other is CreateFavoriteLocationDto &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(latitude.hashCode) +
(longitude.hashCode) +
(name.hashCode);
@override
String toString() => 'CreateFavoriteLocationDto[latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'latitude'] = this.latitude;
json[r'longitude'] = this.longitude;
json[r'name'] = this.name;
return json;
}
/// Returns a new [CreateFavoriteLocationDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CreateFavoriteLocationDto? fromJson(dynamic value) {
upgradeDto(value, "CreateFavoriteLocationDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return CreateFavoriteLocationDto(
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<CreateFavoriteLocationDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <CreateFavoriteLocationDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CreateFavoriteLocationDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CreateFavoriteLocationDto> mapFromJson(dynamic json) {
final map = <String, CreateFavoriteLocationDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CreateFavoriteLocationDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CreateFavoriteLocationDto-objects as value to a dart map
static Map<String, List<CreateFavoriteLocationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CreateFavoriteLocationDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = CreateFavoriteLocationDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'latitude',
'longitude',
'name',
};
}

View File

@@ -0,0 +1,135 @@
//
// 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 FavoriteLocationResponseDto {
/// Returns a new [FavoriteLocationResponseDto] instance.
FavoriteLocationResponseDto({
required this.id,
required this.latitude,
required this.longitude,
required this.name,
});
String id;
num? latitude;
num? longitude;
String name;
@override
bool operator ==(Object other) => identical(this, other) || other is FavoriteLocationResponseDto &&
other.id == id &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) +
(name.hashCode);
@override
String toString() => 'FavoriteLocationResponseDto[id=$id, latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
if (this.latitude != null) {
json[r'latitude'] = this.latitude;
} else {
// json[r'latitude'] = null;
}
if (this.longitude != null) {
json[r'longitude'] = this.longitude;
} else {
// json[r'longitude'] = null;
}
json[r'name'] = this.name;
return json;
}
/// Returns a new [FavoriteLocationResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static FavoriteLocationResponseDto? fromJson(dynamic value) {
upgradeDto(value, "FavoriteLocationResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return FavoriteLocationResponseDto(
id: mapValueOfType<String>(json, r'id')!,
latitude: json[r'latitude'] == null
? null
: num.parse('${json[r'latitude']}'),
longitude: json[r'longitude'] == null
? null
: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<FavoriteLocationResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <FavoriteLocationResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = FavoriteLocationResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, FavoriteLocationResponseDto> mapFromJson(dynamic json) {
final map = <String, FavoriteLocationResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = FavoriteLocationResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of FavoriteLocationResponseDto-objects as value to a dart map
static Map<String, List<FavoriteLocationResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<FavoriteLocationResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = FavoriteLocationResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'id',
'latitude',
'longitude',
'name',
};
}

View File

@@ -0,0 +1,142 @@
//
// 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 UpdateFavoriteLocationDto {
/// Returns a new [UpdateFavoriteLocationDto] instance.
UpdateFavoriteLocationDto({
this.latitude,
this.longitude,
this.name,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? latitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? longitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? name;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateFavoriteLocationDto &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'UpdateFavoriteLocationDto[latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.latitude != null) {
json[r'latitude'] = this.latitude;
} else {
// json[r'latitude'] = null;
}
if (this.longitude != null) {
json[r'longitude'] = this.longitude;
} else {
// json[r'longitude'] = null;
}
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
return json;
}
/// Returns a new [UpdateFavoriteLocationDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateFavoriteLocationDto? fromJson(dynamic value) {
upgradeDto(value, "UpdateFavoriteLocationDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateFavoriteLocationDto(
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name'),
);
}
return null;
}
static List<UpdateFavoriteLocationDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateFavoriteLocationDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateFavoriteLocationDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateFavoriteLocationDto> mapFromJson(dynamic json) {
final map = <String, UpdateFavoriteLocationDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateFavoriteLocationDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateFavoriteLocationDto-objects as value to a dart map
static Map<String, List<UpdateFavoriteLocationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateFavoriteLocationDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateFavoriteLocationDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -5592,6 +5592,216 @@
"x-immich-state": "Stable"
}
},
"/map/favorite-locations": {
"get": {
"description": "Retrieve a list of user's favorite locations.",
"operationId": "getFavoriteLocations",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/FavoriteLocationResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get favorite locations",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"post": {
"description": "Create a new favorite location for the user.",
"operationId": "createFavoriteLocation",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateFavoriteLocationDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FavoriteLocationResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Create favorite location",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
}
},
"/map/favorite-locations/{id}": {
"delete": {
"description": "Delete a favorite location by its ID.",
"operationId": "deleteFavoriteLocation",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete favorite location",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
},
"put": {
"description": "Update an existing favorite location.",
"operationId": "updateFavoriteLocation",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateFavoriteLocationDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FavoriteLocationResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update favorite location",
"tags": [
"Map"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
}
},
"/map/markers": {
"get": {
"description": "Retrieve a list of latitude and longitude coordinates for every asset with location data.",
@@ -16150,6 +16360,25 @@
],
"type": "object"
},
"CreateFavoriteLocationDto": {
"properties": {
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"name": {
"type": "string"
}
},
"required": [
"latitude",
"longitude",
"name"
],
"type": "object"
},
"CreateLibraryDto": {
"properties": {
"exclusionPatterns": {
@@ -16554,6 +16783,31 @@
],
"type": "object"
},
"FavoriteLocationResponseDto": {
"properties": {
"id": {
"type": "string"
},
"latitude": {
"nullable": true,
"type": "number"
},
"longitude": {
"nullable": true,
"type": "number"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"latitude",
"longitude",
"name"
],
"type": "object"
},
"FoldersResponse": {
"properties": {
"enabled": {
@@ -22593,6 +22847,20 @@
},
"type": "object"
},
"UpdateFavoriteLocationDto": {
"properties": {
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"name": {
"type": "string"
}
},
"type": "object"
},
"UpdateLibraryDto": {
"properties": {
"exclusionPatterns": {

View File

@@ -790,6 +790,22 @@ export type ValidateLibraryImportPathResponseDto = {
export type ValidateLibraryResponseDto = {
importPaths?: ValidateLibraryImportPathResponseDto[];
};
export type FavoriteLocationResponseDto = {
id: string;
latitude: number | null;
longitude: number | null;
name: string;
};
export type CreateFavoriteLocationDto = {
latitude: number;
longitude: number;
name: string;
};
export type UpdateFavoriteLocationDto = {
latitude?: number;
longitude?: number;
name?: string;
};
export type MapMarkerResponseDto = {
city: string | null;
country: string | null;
@@ -3082,6 +3098,59 @@ export function validate({ id, validateLibraryDto }: {
body: validateLibraryDto
})));
}
/**
* Get favorite locations
*/
export function getFavoriteLocations(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: FavoriteLocationResponseDto[];
}>("/map/favorite-locations", {
...opts
}));
}
/**
* Create favorite location
*/
export function createFavoriteLocation({ createFavoriteLocationDto }: {
createFavoriteLocationDto: CreateFavoriteLocationDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: FavoriteLocationResponseDto;
}>("/map/favorite-locations", oazapfts.json({
...opts,
method: "POST",
body: createFavoriteLocationDto
})));
}
/**
* Delete favorite location
*/
export function deleteFavoriteLocation({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/map/favorite-locations/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Update favorite location
*/
export function updateFavoriteLocation({ id, updateFavoriteLocationDto }: {
id: string;
updateFavoriteLocationDto: UpdateFavoriteLocationDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: FavoriteLocationResponseDto;
}>(`/map/favorite-locations/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: updateFavoriteLocationDto
})));
}
/**
* Retrieve map markers
*/

View File

@@ -1,7 +1,12 @@
import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
CreateFavoriteLocationDto,
FavoriteLocationResponseDto,
UpdateFavoriteLocationDto,
} from 'src/dtos/favorite-location.dto';
import {
MapMarkerDto,
MapMarkerResponseDto,
@@ -39,4 +44,59 @@ export class MapController {
reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise<MapReverseGeocodeResponseDto[]> {
return this.service.reverseGeocode(dto);
}
@Authenticated()
@Get('favorite-locations')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get favorite locations',
description: "Retrieve a list of user's favorite locations.",
history: new HistoryBuilder().added('v2').stable('v2'),
})
getFavoriteLocations(@Auth() auth: AuthDto): Promise<FavoriteLocationResponseDto[]> {
return this.service.getFavoriteLocations(auth);
}
@Authenticated()
@Post('favorite-locations')
@HttpCode(HttpStatus.CREATED)
@Endpoint({
summary: 'Create favorite location',
description: 'Create a new favorite location for the user.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
createFavoriteLocation(
@Auth() auth: AuthDto,
@Body() dto: CreateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
return this.service.createFavoriteLocation(auth, dto);
}
@Authenticated()
@Put('favorite-locations/:id')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Update favorite location',
description: 'Update an existing favorite location.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
updateFavoriteLocation(
@Auth() auth: AuthDto,
@Param('id') id: string,
@Body() dto: UpdateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
return this.service.updateFavoriteLocation(auth, id, dto);
}
@Authenticated()
@Delete('favorite-locations/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete favorite location',
description: 'Delete a favorite location by its ID.',
history: new HistoryBuilder().added('v2').stable('v2'),
})
deleteFavoriteLocation(@Param('id') id: string) {
return this.service.deleteFavoriteLocation(id);
}
}

View File

@@ -274,6 +274,16 @@ export type AssetFace = {
updateId: string;
};
export type FavoriteLocation = {
id: string;
name: string;
userId: string;
latitude: number | null;
longitude: number | null;
createdAt: Date;
updatedAt: Date;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {

View File

@@ -0,0 +1,34 @@
import { IsLatitude, IsLongitude, IsString } from 'class-validator';
import { Optional } from 'src/validation';
export class CreateFavoriteLocationDto {
@IsString()
name!: string;
@IsLatitude()
latitude!: number;
@IsLongitude()
longitude!: number;
}
export class UpdateFavoriteLocationDto {
@Optional()
@IsString()
name?: string;
@Optional()
@IsLatitude()
latitude?: number;
@Optional()
@IsLongitude()
longitude?: number;
}
export class FavoriteLocationResponseDto {
id!: string;
name!: string;
latitude!: number | null;
longitude!: number | null;
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
@@ -12,6 +12,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { DB } from 'src/schema';
import { FavoriteLocationTable } from 'src/schema/tables/favorite-location.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
@@ -138,6 +139,42 @@ export class MapRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFavoriteLocations(userId: string) {
return this.db
.selectFrom('favorite_location')
.selectAll()
.where('userId', '=', userId)
.orderBy('name', 'asc')
.execute();
}
async createFavoriteLocation(entity: Insertable<FavoriteLocationTable>) {
const inserted = await this.db
.insertInto('favorite_location')
.values(entity)
.returningAll()
.executeTakeFirstOrThrow();
return inserted;
}
async updateFavoriteLocation(id: string, userId: string, updates: Updateable<FavoriteLocationTable>) {
const updated = await this.db
.updateTable('favorite_location')
.set(updates)
.where('id', '=', id)
.where('userId', '=', userId)
.returningAll()
.executeTakeFirstOrThrow();
return updated;
}
async deleteFavoriteLocation(id: string) {
await this.db.deleteFrom('favorite_location').where('id', '=', id).execute();
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);

View File

@@ -39,6 +39,7 @@ import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { FavoriteLocationTable } from 'src/schema/tables/favorite-location.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
@@ -97,6 +98,7 @@ export class ImmichDatabase {
AuditTable,
AssetExifTable,
FaceSearchTable,
FavoriteLocationTable,
GeodataPlacesTable,
LibraryTable,
MemoryTable,
@@ -192,6 +194,7 @@ export interface DB {
audit: AuditTable;
face_search: FaceSearchTable;
favorite_location: FavoriteLocationTable;
geodata_places: GeodataPlacesTable;

View File

@@ -0,0 +1,20 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "favorite_location" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"userId" uuid NOT NULL,
"latitude" double precision,
"longitude" double precision,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "favorite_location_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "favorite_location_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "favorite_location_userId_idx" ON "favorite_location" ("userId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "favorite_location";`.execute(db);
}

View File

@@ -0,0 +1,35 @@
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('favorite_location')
export class FavoriteLocationTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'character varying', nullable: false })
name!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', index: true })
userId!: string;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}

View File

@@ -1,3 +1,4 @@
import { CreateFavoriteLocationDto, UpdateFavoriteLocationDto } from 'src/dtos/favorite-location.dto';
import { MapService } from 'src/services/map.service';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -93,4 +94,71 @@ describe(MapService.name, () => {
expect(mocks.map.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 });
});
});
describe('getFavoriteLocations', () => {
it('should return favorite locations for the user', async () => {
const favoriteLocation = {
id: 'loc1',
userId: authStub.user1.user.id,
name: 'Home',
latitude: 12.34,
longitude: 56.78,
};
mocks.map.getFavoriteLocations.mockResolvedValue([favoriteLocation]);
const result = await sut.getFavoriteLocations(authStub.user1);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(favoriteLocation);
expect(mocks.map.getFavoriteLocations).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('createFavoriteLocation', () => {
it('should create a new favorite location', async () => {
const dto: CreateFavoriteLocationDto = { name: 'Work', latitude: 1, longitude: 2 };
const created = { id: 'loc2', userId: authStub.user1.user.id, ...dto };
mocks.map.createFavoriteLocation.mockResolvedValue(created);
const result = await sut.createFavoriteLocation(authStub.user1, dto);
expect(result).toEqual(created);
expect(mocks.map.createFavoriteLocation).toHaveBeenCalledWith({
userId: authStub.user1.user.id,
name: dto.name,
latitude: dto.latitude,
longitude: dto.longitude,
});
});
});
describe('updateFavoriteLocation', () => {
it('should update an existing favorite location', async () => {
const dto: UpdateFavoriteLocationDto = { name: 'Gym' };
const updated = { id: 'loc3', userId: authStub.user1.user.id, name: 'Gym', latitude: null, longitude: null };
mocks.map.updateFavoriteLocation.mockResolvedValue(updated);
const result = await sut.updateFavoriteLocation(authStub.user1, 'loc3', dto);
expect(result).toEqual(updated);
expect(mocks.map.updateFavoriteLocation).toHaveBeenCalledWith('loc3', authStub.user1.user.id, {
id: 'loc3',
userId: authStub.user1.user.id,
name: 'Gym',
});
});
});
describe('deleteFavoriteLocation', () => {
it('should call repository to delete a location by id', async () => {
mocks.map.deleteFavoriteLocation.mockResolvedValue(undefined);
await sut.deleteFavoriteLocation('loc4');
expect(mocks.map.deleteFavoriteLocation).toHaveBeenCalledWith('loc4');
});
});
});

View File

@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import {
CreateFavoriteLocationDto,
FavoriteLocationResponseDto,
UpdateFavoriteLocationDto,
} from 'src/dtos/favorite-location.dto';
import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto';
import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util';
@@ -32,4 +37,38 @@ export class MapService extends BaseService {
const result = await this.mapRepository.reverseGeocode({ latitude, longitude });
return result ? [result] : [];
}
async getFavoriteLocations(auth: AuthDto): Promise<FavoriteLocationResponseDto[]> {
return this.mapRepository.getFavoriteLocations(auth.user.id);
}
async createFavoriteLocation(auth: AuthDto, dto: CreateFavoriteLocationDto): Promise<FavoriteLocationResponseDto> {
const entity = {
userId: auth.user.id,
name: dto.name,
latitude: dto.latitude,
longitude: dto.longitude,
};
return this.mapRepository.createFavoriteLocation(entity);
}
async updateFavoriteLocation(
auth: AuthDto,
id: string,
dto: UpdateFavoriteLocationDto,
): Promise<FavoriteLocationResponseDto> {
const entity = {
userId: auth.user.id,
id,
...(dto.name !== undefined && { name: dto.name }),
...(dto.latitude !== undefined && { latitude: dto.latitude }),
...(dto.longitude !== undefined && { longitude: dto.longitude }),
};
return this.mapRepository.updateFavoriteLocation(id, auth.user.id, entity);
}
async deleteFavoriteLocation(id: string) {
await this.mapRepository.deleteFavoriteLocation(id);
}
}

View File

@@ -8,9 +8,18 @@
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { ConfirmModal, LoadingSpinner } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import {
createFavoriteLocation,
deleteFavoriteLocation,
getFavoriteLocations,
searchPlaces,
type AssetResponseDto,
type FavoriteLocationResponseDto,
type PlacesResponseDto,
} from '@immich/sdk';
import { Button, ConfirmModal, IconButton, Input, LoadingSpinner } from '@immich/ui';
import { mdiDelete, mdiMapMarkerMultipleOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
@@ -45,6 +54,22 @@
let zoom = $derived(mapLat && mapLng ? 12.5 : 1);
let favoriteLocations: FavoriteLocationResponseDto[] = $state([]);
let newFavoriteName = $state('');
let savingFavorite = $state(false);
const loadFavoriteLocations = async () => {
try {
favoriteLocations = await getFavoriteLocations();
} catch (err) {
handleError(err, 'Failed to load favorite locations');
}
};
onMount(() => {
loadFavoriteLocations();
});
$effect(() => {
if (mapElement && initialPoint) {
mapElement.addClipMapMarker(initialPoint.lng, initialPoint.lat);
@@ -68,6 +93,39 @@
}
};
const handleSaveFavorite = async () => {
if (newFavoriteName.trim() === '') {
return;
}
savingFavorite = true;
try {
const newLocation: FavoriteLocationResponseDto = await createFavoriteLocation({
createFavoriteLocationDto: {
name: newFavoriteName,
latitude: point!.lat,
longitude: point!.lng,
},
});
favoriteLocations = [...favoriteLocations, newLocation];
favoriteLocations = favoriteLocations.sort((a, b) => a.name.localeCompare(b.name));
newFavoriteName = '';
} catch (err) {
handleError(err, 'Failed to save favorite location');
} finally {
savingFavorite = false;
}
};
const handleDeleteFavorite = async (locationId: string) => {
try {
await deleteFavoriteLocation({ id: locationId });
favoriteLocations = favoriteLocations.filter((loc) => loc.id !== locationId);
} catch (err) {
handleError(err, 'Failed to delete favorite location');
}
};
const getLocation = (name: string, admin1Name?: string, admin2Name?: string): string => {
return `${name}${admin1Name ? ', ' + admin1Name : ''}${admin2Name ? ', ' + admin2Name : ''}`;
};
@@ -106,10 +164,13 @@
latestSearchTimeout = searchTimeout;
};
const handleUseSuggested = (latitude: number, longitude: number) => {
const handleUseSuggested = (latitude: number, longitude: number, setZoom?: number) => {
hideSuggestion = true;
point = { lng: longitude, lat: latitude };
mapElement?.addClipMapMarker(longitude, latitude);
if (setZoom) {
zoom = setZoom;
}
};
const onUpdate = (lat: number, lng: number) => {
@@ -206,6 +267,57 @@
<div class="grid sm:grid-cols-2 gap-4 text-sm text-start mt-4">
<CoordinatesInput lat={point ? point.lat : assetLat} lng={point ? point.lng : assetLng} {onUpdate} />
</div>
<div class="mt-4">
<div class="flex justify-between items-center gap-2 mb-2">
<p>{$t('favorite_locations')}</p>
<div class="flex gap-2 items-center justify-end">
<Input placeholder={$t('name')} size="tiny" bind:value={newFavoriteName} />
<Button
onclick={handleSaveFavorite}
disabled={newFavoriteName.trim() === '' || savingFavorite || point === null}
variant="outline"
size="tiny"
class="shrink-0">{$t('save')}</Button
>
</div>
</div>
<div class="max-h-40 overflow-y-auto border border-gray-300 dark:border-immich-dark-gray rounded-md p-2">
{#if favoriteLocations.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">{$t('favorite_locations_not_found')}</p>
{:else}
<ul class="space-y-2">
{#each favoriteLocations as location (location.id)}
<li>
<button
class="w-full"
onclick={() => handleUseSuggested(location.latitude!, location.longitude!, 14)}
>
<div
class="flex justify-between items-center p-2 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 hover:dark:bg-gray-700"
>
{location.name}
<IconButton
icon={mdiDelete}
shape="round"
variant="outline"
size="medium"
color="danger"
aria-label={$t('delete')}
onclick={(e: Event) => {
e.stopPropagation();
handleDeleteFavorite(location.id);
}}
/>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/snippet}
</ConfirmModal>