refactor: change location (#27201)

This commit is contained in:
Jason Rasmussen
2026-03-26 13:22:14 -04:00
committed by GitHub
parent fae25dbe65
commit 940a1d4ab8
10 changed files with 119 additions and 96 deletions
@@ -1,24 +1,20 @@
<script lang="ts"> <script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk'; import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui'; import { Icon, modalManager } from '@immich/ui';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js'; import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { type Props = {
isOwner: boolean; isOwner: boolean;
asset: AssetResponseDto; asset: AssetResponseDto;
} };
let { isOwner, asset = $bindable() }: Props = $props(); let { isOwner, asset = $bindable() }: Props = $props();
let isShowChangeLocation = $state(false); const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
const onClose = async (point?: { lng: number; lat: number }) => {
isShowChangeLocation = false;
if (!point) { if (!point) {
return; return;
} }
@@ -38,7 +34,7 @@
<button <button
type="button" type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4" class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)} onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''} title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner} class:hover:text-primary={isOwner}
> >
@@ -72,12 +68,11 @@
<button <button
type="button" type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary" class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
onclick={() => (isShowChangeLocation = true)} onclick={onAction}
title={$t('add_location')} title={$t('add_location')}
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div> <div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<p>{$t('add_a_location')}</p> <p>{$t('add_a_location')}</p>
</div> </div>
<div class="focus:outline-none p-1"> <div class="focus:outline-none p-1">
@@ -85,9 +80,3 @@
</div> </div>
</button> </button>
{/if} {/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation {asset} {onClose} />
</Portal>
{/if}
@@ -1,26 +1,24 @@
<script lang="ts"> <script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils'; import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context'; import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk'; import { updateAssets } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js'; import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props { type Props = {
menuItem?: boolean; menuItem?: boolean;
} };
let { menuItem = false }: Props = $props(); let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeLocation = $state(false); const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, {});
async function handleConfirm(point?: { lng: number; lat: number }) {
isShowChangeLocation = false;
if (!point) { if (!point) {
return; return;
} }
@@ -29,20 +27,14 @@
try { try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } }); await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
toastManager.primary();
clearSelect(); clearSelect();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_update_location')); handleError(error, $t('errors.unable_to_update_location'));
} }
} };
</script> </script>
{#if menuItem} {#if menuItem}
<MenuOption <MenuOption text={$t('change_location')} icon={mdiMapMarkerMultipleOutline} onClick={onAction} />
text={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
onClick={() => (isShowChangeLocation = true)}
/>
{/if}
{#if isShowChangeLocation}
<ChangeLocation onClose={handleConfirm} />
{/if} {/if}
+17 -1
View File
@@ -1,4 +1,4 @@
import { cleanClass } from '$lib'; import { cleanClass, isDefined } from '$lib';
describe('cleanClass', () => { describe('cleanClass', () => {
it('should return a string of class names', () => { it('should return a string of class names', () => {
@@ -13,3 +13,19 @@ describe('cleanClass', () => {
expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3'); expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3');
}); });
}); });
describe('isDefined', () => {
it('should return false for null', () => {
expect(isDefined(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isDefined(undefined)).toBe(false);
});
it('should return true for everything else', () => {
for (const value of [0, 1, 2, true, false, {}, 'foo', 'bar', []]) {
expect(isDefined(value)).toBe(true);
}
});
});
+2
View File
@@ -14,3 +14,5 @@ export const cleanClass = (...classNames: unknown[]) => {
.join(' '), .join(' '),
); );
}; };
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
@@ -0,0 +1,15 @@
import type { LatLng } from '$lib/types';
class GeolocationManager {
#lastPoint = $state<LatLng>();
get lastPoint() {
return this.#lastPoint;
}
onSelected(point: LatLng) {
this.#lastPoint = point;
}
}
export const geolocationManager = new GeolocationManager();
@@ -1,30 +1,27 @@
<script lang="ts"> <script lang="ts">
import { isDefined } from '$lib';
import { clickOutside } from '$lib/actions/click-outside'; import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation'; import { listNavigation } from '$lib/actions/list-navigation';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte'; import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import type Map from '$lib/components/shared-components/map/map.svelte'; import type Map from '$lib/components/shared-components/map/map.svelte';
import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants'; import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte'; import SearchBar from '$lib/elements/SearchBar.svelte';
import { lastChosenLocation } from '$lib/stores/asset-editor.store'; import { geolocationManager } from '$lib/managers/geolocation.manager.svelte';
import type { LatLng } from '$lib/types';
import { delay } from '$lib/utils/asset-utils'; import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk'; import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { ConfirmModal, LoadingSpinner } from '@immich/ui'; import { ConfirmModal, LoadingSpinner } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js'; import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
lng: number;
lat: number;
}
interface Props { type Props = {
asset?: AssetResponseDto | undefined; asset?: AssetResponseDto;
point?: Point; point?: LatLng;
onClose: (point?: Point) => void; onClose: (point?: LatLng) => void;
} };
let { asset = undefined, point: initialPoint, onClose }: Props = $props(); let { asset, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]); let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5)); let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
@@ -35,15 +32,22 @@
let hideSuggestion = $state(false); let hideSuggestion = $state(false);
let mapElement = $state<ReturnType<typeof Map>>(); let mapElement = $state<ReturnType<typeof Map>>();
let previousLocation = get(lastChosenLocation); let assetPoint = $derived.by<LatLng | undefined>(() => {
if (!asset || !asset.exifInfo) {
return;
}
let assetLat = $derived(initialPoint?.lat ?? asset?.exifInfo?.latitude ?? undefined); const { latitude, longitude } = asset.exifInfo;
let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined); if (!isDefined(latitude) || !isDefined(longitude)) {
return;
}
let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined); return { lat: latitude, lng: longitude };
let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined); });
let zoom = $derived(mapLat && mapLng ? 12.5 : 1); let point = $state<LatLng | undefined>(initialPoint ?? assetPoint);
let zoom = $state(point ? 12.5 : 1);
let center = $state(point ?? geolocationManager.lastPoint);
$effect(() => { $effect(() => {
if (mapElement && initialPoint) { if (mapElement && initialPoint) {
@@ -57,11 +61,9 @@
} }
}); });
let point: Point | null = $state(initialPoint ?? null);
const handleConfirm = (confirmed?: boolean) => { const handleConfirm = (confirmed?: boolean) => {
if (point && confirmed) { if (point && confirmed) {
lastChosenLocation.set(point); geolocationManager.onSelected(point);
onClose(point); onClose(point);
} else { } else {
onClose(); onClose();
@@ -201,12 +203,12 @@
{:then { default: Map }} {:then { default: Map }}
<Map <Map
bind:this={mapElement} bind:this={mapElement}
mapMarkers={assetLat !== undefined && assetLng !== undefined && asset mapMarkers={asset && assetPoint
? [ ? [
{ {
id: asset.id, id: asset.id,
lat: assetLat, lat: assetPoint.lat,
lon: assetLng, lon: assetPoint.lng,
city: asset.exifInfo?.city ?? null, city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null, state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null, country: asset.exifInfo?.country ?? null,
@@ -214,7 +216,7 @@
] ]
: []} : []}
{zoom} {zoom}
center={mapLat && mapLng ? { lat: mapLat, lng: mapLng } : undefined} {center}
simplified={true} simplified={true}
clickable={true} clickable={true}
onClickPoint={(selected) => (point = selected)} onClickPoint={(selected) => (point = selected)}
@@ -225,7 +227,7 @@
</div> </div>
<div class="grid sm:grid-cols-2 gap-4 text-sm text-start mt-4"> <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} /> <CoordinatesInput lat={point?.lat} lng={point?.lng} {onUpdate} />
</div> </div>
</div> </div>
{/snippet} {/snippet}
@@ -1,20 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { LatLng } from '$lib/types';
import { ConfirmModal } from '@immich/ui'; import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { type Props = {
location: { latitude: number | undefined; longitude: number | undefined }; point: LatLng;
assetCount: number; assetCount: number;
onClose: (confirm: boolean) => void; onClose: (confirm: boolean) => void;
} };
let { location, assetCount, onClose }: Props = $props(); let { point, assetCount, onClose }: Props = $props();
</script> </script>
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}> <ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
{#snippet prompt()} {#snippet prompt()}
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p> <p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
<p>- {$t('latitude')}: {location.latitude}</p> <p>- {$t('latitude')}: {point.lat}</p>
<p>- {$t('longitude')}: {location.longitude}</p> <p>- {$t('longitude')}: {point.lng}</p>
{/snippet} {/snippet}
</ConfirmModal> </ConfirmModal>
-4
View File
@@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
//-----other
export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null);
+2
View File
@@ -5,6 +5,8 @@ import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon'; import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity'; import type { SvelteSet } from 'svelte/reactivity';
export type LatLng = { lng: number; lat: number };
export interface ReleaseEvent { export interface ReleaseEvent {
isAvailable: boolean; isAvailable: boolean;
/** ISO8601 */ /** ISO8601 */
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { isDefined } from '$lib';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
@@ -8,8 +8,10 @@
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte'; import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { LatLng } from '$lib/types';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { setQueryValue } from '$lib/utils/navigation'; import { setQueryValue } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -19,15 +21,15 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { type Props = {
data: PageData; data: PageData;
} };
let { data }: Props = $props(); let { data }: Props = $props();
let isLoading = $state(false); let isLoading = $state(false);
let assetInteraction = new AssetInteraction(); let assetInteraction = new AssetInteraction();
let location = $state<{ latitude: number; longitude: number }>({ latitude: 0, longitude: 0 }); let point = $state<LatLng>();
let locationUpdated = $state(false); let locationUpdated = $state(false);
let timelineManager = $state<TimelineManager>() as TimelineManager; let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -39,8 +41,12 @@
}; };
const handleUpdate = async () => { const handleUpdate = async () => {
if (!point) {
return;
}
const confirmed = await modalManager.show(GeolocationUpdateConfirmModal, { const confirmed = await modalManager.show(GeolocationUpdateConfirmModal, {
location: location ?? { latitude: 0, longitude: 0 }, point,
assetCount: assetInteraction.selectedAssets.length, assetCount: assetInteraction.selectedAssets.length,
}); });
@@ -51,8 +57,8 @@
await updateAssets({ await updateAssets({
assetBulkUpdateDto: { assetBulkUpdateDto: {
ids: assetInteraction.selectedAssets.map((asset) => asset.id), ids: assetInteraction.selectedAssets.map((asset) => asset.id),
latitude: location?.latitude ?? undefined, latitude: point.lat,
longitude: location?.longitude ?? undefined, longitude: point.lng,
}, },
}); });
@@ -86,18 +92,13 @@
cancelMultiselect(assetInteraction); cancelMultiselect(assetInteraction);
}; };
const handlePickOnMap = async () => { const handlePickPoint = async () => {
const point = await modalManager.show(ChangeLocation, { const selected = await modalManager.show(GeolocationPointPickerModal, { point });
point: { if (!selected) {
lat: location.latitude,
lng: location.longitude,
},
});
if (!point) {
return; return;
} }
location = { latitude: point.lat, longitude: point.lng }; point = selected;
}; };
const handleEscape = () => { const handleEscape = () => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
@@ -106,9 +107,10 @@
} }
}; };
const hasGps = (asset: TimelineAsset) => { type AssetPoint = { latitude: number; longitude: number };
return !!asset.latitude && !!asset.longitude;
}; const hasGps = (asset: TimelineAsset | AssetPoint): asset is AssetPoint =>
isDefined(asset.latitude) && isDefined(asset.longitude);
const handleThumbnailClick = ( const handleThumbnailClick = (
asset: TimelineAsset, asset: TimelineAsset,
@@ -126,7 +128,7 @@
setTimeout(() => { setTimeout(() => {
locationUpdated = false; locationUpdated = false;
}, 1500); }, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! }; point = { lat: asset.latitude, lng: asset.longitude };
void setQueryValue('at', asset.id); void setQueryValue('at', asset.id);
} else { } else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
@@ -148,11 +150,17 @@
title="latitude, longitude" title="latitude, longitude"
class="rounded-3xl font-mono text-sm text-primary px-2 py-1 transition-all duration-100 ease-in-out {locationUpdated class="rounded-3xl font-mono text-sm text-primary px-2 py-1 transition-all duration-100 ease-in-out {locationUpdated
? 'bg-primary/90 text-light font-semibold scale-105' ? 'bg-primary/90 text-light font-semibold scale-105'
: ''}">{location.latitude.toFixed(3)}, {location.longitude.toFixed(3)}</Text : ''}"
> >
{#if point}
{point.lat.toFixed(3)}, {point.lng.toFixed(3)}
{:else}
{$t('none')}
{/if}
</Text>
</div> </div>
<Button size="small" color="secondary" variant="ghost" leadingIcon={mdiPencilOutline} onclick={handlePickOnMap}> <Button size="small" color="secondary" variant="ghost" leadingIcon={mdiPencilOutline} onclick={handlePickPoint}>
<Text class="hidden sm:inline-block">{$t('location_picker_choose_on_map')}</Text> <Text class="hidden sm:inline-block">{$t('location_picker_choose_on_map')}</Text>
</Button> </Button>
<Button <Button