From 662d44536e85e1b18cbca6c181967637b27c201e Mon Sep 17 00:00:00 2001 From: Johann Date: Thu, 28 Aug 2025 18:54:11 +0200 Subject: [PATCH] feat(web): add geolocation utility (#20758) * feat(geolocation): add geolocation utility * feat(web): geolocation utility - fix code review - 1 * feat(web): geolocation utility - fix code review - 2 * chore: cleanup * chore: feedback * feat(web): add animation and text animation on locations change and action text on thumbnail * styling, messages and filtering * selected color * format i18n * fix lint --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex --- i18n/en.json | 13 + web/src/lib/assets/empty-5.svg | 1 + .../asset-viewer/detail-panel-location.svelte | 12 +- .../assets/thumbnail/thumbnail.svelte | 2 +- .../actions/change-location-action.svelte | 15 +- .../shared-components/change-location.svelte | 42 +-- .../shared-components/date-picker.svelte | 113 ++++++ .../geolocation/geolocation.svelte | 104 ++++++ .../utilities-page/utilities-menu.svelte | 32 +- web/src/lib/constants.ts | 1 + .../GeolocationUpdateConfirmModal.svelte | 33 ++ web/src/lib/utils/date-time.spec.ts | 23 +- web/src/lib/utils/date-time.ts | 30 ++ web/src/lib/utils/navigation.ts | 13 + web/src/lib/utils/string-utils.ts | 10 + .../(user)/utilities/geolocation/+page.svelte | 321 ++++++++++++++++++ .../(user)/utilities/geolocation/+page.ts | 17 + 17 files changed, 733 insertions(+), 49 deletions(-) create mode 100644 web/src/lib/assets/empty-5.svg create mode 100644 web/src/lib/components/shared-components/date-picker.svelte create mode 100644 web/src/lib/components/utilities-page/geolocation/geolocation.svelte create mode 100644 web/src/lib/modals/GeolocationUpdateConfirmModal.svelte create mode 100644 web/src/routes/(user)/utilities/geolocation/+page.svelte create mode 100644 web/src/routes/(user)/utilities/geolocation/+page.ts diff --git a/i18n/en.json b/i18n/en.json index ccd0c9d7fe..5d215e2c36 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -461,6 +461,7 @@ "app_bar_signout_dialog_title": "Sign out", "app_settings": "App Settings", "appears_in": "Appears in", + "apply_count": "Apply ({count, number})", "archive": "Archive", "archive_action_prompt": "{count} added to Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", @@ -1073,12 +1074,18 @@ "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", "general": "General", + "geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date", + "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", + "geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day", + "geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them", "get_help": "Get Help", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "getting_started": "Getting Started", "go_back": "Go back", "go_to_folder": "Go to folder", "go_to_search": "Go to search", + "gps": "GPS", + "gps_missing": "No GPS", "grant_permission": "Grant permission", "group_albums_by": "Group albums by...", "group_country": "Group by country", @@ -1262,6 +1269,7 @@ "main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_menu": "Main menu", "make": "Make", + "manage_geolocation": "Manage location", "manage_shared_links": "Manage shared links", "manage_sharing_with_partners": "Manage sharing with partners", "manage_the_app_settings": "Manage the app settings", @@ -1722,6 +1730,7 @@ "select_user_for_sharing_page_err_album": "Failed to create album", "selected": "Selected", "selected_count": "{count, plural, other {# selected}}", + "selected_gps_coordinates": "selected gps coordinates", "send_message": "Send message", "send_welcome_email": "Send welcome email", "server_endpoint": "Server Endpoint", @@ -1832,8 +1841,10 @@ "shift_to_permanent_delete": "press ⇧ to permanently delete asset", "show_album_options": "Show album options", "show_albums": "Show albums", + "show_all_assets": "Show all assets", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", + "show_assets_without_location": "Show assets without location", "show_file_location": "Show file location", "show_gallery": "Show gallery", "show_hidden_people": "Show hidden people", @@ -1993,6 +2004,7 @@ "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "untagged": "Untagged", "up_next": "Up next", + "update_location_action_prompt": "Update the location of {count} selected assets with:", "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", @@ -2017,6 +2029,7 @@ "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", + "use_this_location": "Click to use location", "user": "User", "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", diff --git a/web/src/lib/assets/empty-5.svg b/web/src/lib/assets/empty-5.svg new file mode 100644 index 0000000000..e9e24d0499 --- /dev/null +++ b/web/src/lib/assets/empty-5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/lib/components/asset-viewer/detail-panel-location.svelte b/web/src/lib/components/asset-viewer/detail-panel-location.svelte index 42cbefadf1..783eba9c5f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte @@ -16,18 +16,22 @@ let isShowChangeLocation = $state(false); - async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) { + const onClose = async (point?: { lng: number; lat: number }) => { isShowChangeLocation = false; + if (!point) { + return; + } + try { asset = await updateAsset({ id: asset.id, - updateAssetDto: { latitude: gps.lat, longitude: gps.lng }, + updateAssetDto: { latitude: point.lat, longitude: point.lng }, }); } catch (error) { handleError(error, $t('errors.unable_to_change_location')); } - } + }; {#if asset.exifInfo?.country} @@ -85,6 +89,6 @@ {#if isShowChangeLocation} - (isShowChangeLocation = false)} /> + {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 9af9287c76..e01f2dc4f6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -197,7 +197,7 @@
@@ -39,5 +44,5 @@ /> {/if} {#if isShowChangeLocation} - (isShowChangeLocation = false)} /> + {/if} diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 0829adaf4e..831fae02c2 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -21,11 +21,11 @@ interface Props { asset?: AssetResponseDto | undefined; - onCancel: () => void; - onConfirm: (point: Point) => void; + point?: Point; + onClose: (point?: Point) => void; } - let { asset = undefined, onCancel, onConfirm }: Props = $props(); + let { asset = undefined, point: initialPoint, onClose }: Props = $props(); let places: PlacesResponseDto[] = $state([]); let suggestedPlaces: PlacesResponseDto[] = $state([]); @@ -38,14 +38,20 @@ let previousLocation = get(lastChosenLocation); - let assetLat = $derived(asset?.exifInfo?.latitude ?? undefined); - let assetLng = $derived(asset?.exifInfo?.longitude ?? undefined); + let assetLat = $derived(initialPoint?.lat ?? asset?.exifInfo?.latitude ?? undefined); + let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined); let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined); let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined); let zoom = $derived(mapLat !== undefined && mapLng !== undefined ? 12.5 : 1); + $effect(() => { + if (mapElement && initialPoint) { + mapElement.addClipMapMarker(initialPoint.lng, initialPoint.lat); + } + }); + $effect(() => { if (places) { suggestedPlaces = places.slice(0, 5); @@ -55,14 +61,14 @@ } }); - let point: Point | null = $state(null); + let point: Point | null = $state(initialPoint ?? null); - const handleConfirm = () => { - if (point) { + const handleConfirm = (confirmed?: boolean) => { + if (point && confirmed) { lastChosenLocation.set(point); - onConfirm(point); + onClose(point); } else { - onCancel(); + onClose(); } }; @@ -109,6 +115,11 @@ point = { lng: longitude, lat: latitude }; mapElement?.addClipMapMarker(longitude, latitude); }; + + const onUpdate = (lat: number, lng: number) => { + point = { lat, lng }; + mapElement?.addClipMapMarker(lng, lat); + }; (confirmed ? handleConfirm() : onCancel())} + onClose={handleConfirm} > {#snippet promptSnippet()}
@@ -197,14 +208,7 @@
- { - point = { lat, lng }; - mapElement?.addClipMapMarker(lng, lat); - }} - /> +
{/snippet} diff --git a/web/src/lib/components/shared-components/date-picker.svelte b/web/src/lib/components/shared-components/date-picker.svelte new file mode 100644 index 0000000000..67b1ee73a9 --- /dev/null +++ b/web/src/lib/components/shared-components/date-picker.svelte @@ -0,0 +1,113 @@ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte new file mode 100644 index 0000000000..0efde6df7e --- /dev/null +++ b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte @@ -0,0 +1,104 @@ + + +
+
+ { + if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) { + onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude }); + } else { + onSelectAsset(asset); + } + }} + onSelect={() => onSelectAsset(asset)} + onMouseEvent={() => onMouseEvent(asset)} + selected={assetInteraction.hasSelectedAsset(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + thumbnailSize={boxWidth} + readonly={hasGps} + /> + + {#if hasGps} +
+ {$t('gps')} +
+ {:else} +
+ {$t('gps_missing')} +
+ {/if} +
+ +
+

+ {new Date(asset.localDateTime).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+

+ {new Date(asset.localDateTime).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: 'UTC', + })} +

+ {#if hasGps} +

+ {asset.exifInfo?.country} +

+

+ {asset.exifInfo?.city} +

+ {/if} +
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 5484ce4ea0..97e205fcd1 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,29 +1,23 @@

{$t('organize_your_library').toUpperCase()}

- - - - {$t('review_duplicates')} - - - - - {$t('review_large_files')} - + {#each links as link (link.href)} + + + {link.label} + + {/each}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index f2de6d5deb..a4cdb656b4 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -52,6 +52,7 @@ export enum AppRoute { UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', LARGE_FILES = '/utilities/large-files', + GEOLOCATION = '/utilities/geolocation', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte b/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte new file mode 100644 index 0000000000..7bad707447 --- /dev/null +++ b/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte @@ -0,0 +1,33 @@ + + + + +

+ {$t('update_location_action_prompt', { + values: { + count: assetCount, + }, + })} +

+ +

- {$t('latitude')}: {location.latitude}

+

- {$t('longitude')}: {location.longitude}

+
+ + + + + + +
diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index d96bef45d6..bca57863a9 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { getAlbumDateRange, timeToSeconds } from './date-time'; +import { buildDateRangeFromYearMonthAndDay, getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -75,3 +75,24 @@ describe('getAlbumDate', () => { expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); }); }); + +describe('buildDateRangeFromYearMonthAndDay', () => { + it('should build correct date range for a specific day', () => { + const result = buildDateRangeFromYearMonthAndDay(2023, 1, 8); + + expect(result.from).toContain('2023-01-08T00:00:00'); + expect(result.to).toContain('2023-01-09T00:00:00'); + }); + + it('should build correct date range for a month', () => { + const result = buildDateRangeFromYearMonthAndDay(2023, 2); + expect(result.from).toContain('2023-02-01T00:00:00'); + expect(result.to).toContain('2023-03-01T00:00:00'); + }); + + it('should build correct date range for a year', () => { + const result = buildDateRangeFromYearMonthAndDay(2023); + expect(result.from).toContain('2023-01-01T00:00:00'); + expect(result.to).toContain('2024-01-01T00:00:00'); + }); +}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index 8a50df9cfe..bf87d041cc 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -85,3 +85,33 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string */ export const asLocalTimeISO = (date: DateTime) => (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); + +/** + * Creates a date range for filtering assets based on year, month, and day parameters + */ +export const buildDateRangeFromYearMonthAndDay = (year: number, month?: number, day?: number) => { + const baseDate = DateTime.fromObject({ + year, + month: month || 1, + day: day || 1, + }); + + let from: DateTime; + let to: DateTime; + + if (day) { + from = baseDate.startOf('day'); + to = baseDate.plus({ days: 1 }).startOf('day'); + } else if (month) { + from = baseDate.startOf('month'); + to = baseDate.plus({ months: 1 }).startOf('month'); + } else { + from = baseDate.startOf('year'); + to = baseDate.plus({ years: 1 }).startOf('year'); + } + + return { + from: from.toISO() || undefined, + to: to.toISO() || undefined, + }; +}; diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 642c8165df..c3fe051f12 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -145,3 +145,16 @@ export const clearQueryParam = async (queryParam: string, url: URL) => { await goto(url, { keepFocus: true }); } }; + +export const getQueryValue = (queryKey: string) => { + const url = globalThis.location.href; + const urlObject = new URL(url); + return urlObject.searchParams.get(queryKey); +}; + +export const setQueryValue = async (queryKey: string, queryValue: string) => { + const url = globalThis.location.href; + const urlObject = new URL(url); + urlObject.searchParams.set(queryKey, queryValue); + await goto(urlObject, { keepFocus: true }); +}; diff --git a/web/src/lib/utils/string-utils.ts b/web/src/lib/utils/string-utils.ts index 0170c34737..3795af40c7 100644 --- a/web/src/lib/utils/string-utils.ts +++ b/web/src/lib/utils/string-utils.ts @@ -5,3 +5,13 @@ export const removeAccents = (str: string) => { export const normalizeSearchString = (str: string) => { return removeAccents(str.toLocaleLowerCase()); }; + +export const buildDateString = (year: number, month?: number, day?: number) => { + return [ + year.toString(), + month && !Number.isNaN(month) ? month.toString() : undefined, + day && !Number.isNaN(day) ? day.toString() : undefined, + ] + .filter((date) => date !== undefined) + .join('-'); +}; diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte new file mode 100644 index 0000000000..c251146b45 --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -0,0 +1,321 @@ + + + + + + {#snippet buttons()} +
+ {#if filteredAssets.length > 0} + + {/if} +
+

{$t('selected_gps_coordinates')}

+ {location.latitude.toFixed(3)}, {location.longitude.toFixed(3)} +
+ + + +
+ {/snippet} + +
+
+ +
+ +
+ + +
+
+ + {#if isLoading} +
+ +
+ {/if} + + {#if filteredAssets && filteredAssets.length > 0} +
+ {#each filteredAssets as asset (asset.id)} + handleSelectAssets(asset)} + onMouseEvent={(asset) => assetMouseEventHandler(asset)} + onLocation={(selected) => { + location = selected; + locationUpdated = true; + setTimeout(() => { + locationUpdated = false; + }, 1000); + }} + /> + {/each} +
+ {:else} +
+ {#if partialDate == null} + + {:else if showOnlyAssetsWithoutLocation && filteredAssets.length === 0 && assets.length > 0} + + {:else} + + {/if} +
+ {/if} +
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.ts b/web/src/routes/(user)/utilities/geolocation/+page.ts new file mode 100644 index 0000000000..f5c227a7ef --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/+page.ts @@ -0,0 +1,17 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getQueryValue } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const partialDate = getQueryValue('date'); + const $t = await getFormatter(); + + return { + partialDate, + meta: { + title: $t('manage_geolocation'), + }, + }; +}) satisfies PageLoad;