Compare commits

...

1 Commits

Author SHA1 Message Date
Jason Rasmussen
393cbdb3d9 refactor: change location 2026-03-24 09:41:51 -04:00
10 changed files with 119 additions and 96 deletions

View File

@@ -1,24 +1,20 @@
<script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import Portal from '$lib/elements/Portal.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { handleError } from '$lib/utils/handle-error';
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 { t } from 'svelte-i18n';
interface Props {
type Props = {
isOwner: boolean;
asset: AssetResponseDto;
}
};
let { isOwner, asset = $bindable() }: Props = $props();
let isShowChangeLocation = $state(false);
const onClose = async (point?: { lng: number; lat: number }) => {
isShowChangeLocation = false;
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
if (!point) {
return;
}
@@ -38,7 +34,7 @@
<button
type="button"
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') : ''}
class:hover:text-primary={isOwner}
>
@@ -72,12 +68,11 @@
<button
type="button"
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')}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<p>{$t('add_a_location')}</p>
</div>
<div class="focus:outline-none p-1">
@@ -85,9 +80,3 @@
</div>
</button>
{/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation {asset} {onClose} />
</Portal>
{/if}

View File

@@ -1,26 +1,24 @@
<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 { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
type Props = {
menuItem?: boolean;
}
};
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeLocation = $state(false);
async function handleConfirm(point?: { lng: number; lat: number }) {
isShowChangeLocation = false;
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, {});
if (!point) {
return;
}
@@ -29,20 +27,14 @@
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
toastManager.primary();
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_update_location'));
}
}
};
</script>
{#if menuItem}
<MenuOption
text={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
onClick={() => (isShowChangeLocation = true)}
/>
{/if}
{#if isShowChangeLocation}
<ChangeLocation onClose={handleConfirm} />
<MenuOption text={$t('change_location')} icon={mdiMapMarkerMultipleOutline} onClick={onAction} />
{/if}

View File

@@ -1,4 +1,4 @@
import { cleanClass } from '$lib';
import { cleanClass, isDefined } from '$lib';
describe('cleanClass', () => {
it('should return a string of class names', () => {
@@ -13,3 +13,19 @@ describe('cleanClass', () => {
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);
}
});
});

View File

@@ -14,3 +14,5 @@ export const cleanClass = (...classNames: unknown[]) => {
.join(' '),
);
};
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;

View File

@@ -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();

View File

@@ -1,30 +1,27 @@
<script lang="ts">
import { isDefined } from '$lib';
import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import type Map from '$lib/components/shared-components/map/map.svelte';
import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants';
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 { 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 { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
lng: number;
lat: number;
}
interface Props {
asset?: AssetResponseDto | undefined;
point?: Point;
onClose: (point?: Point) => void;
}
type Props = {
asset?: AssetResponseDto;
point?: LatLng;
onClose: (point?: LatLng) => void;
};
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let { asset, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
@@ -35,15 +32,22 @@
let hideSuggestion = $state(false);
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);
let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined);
const { latitude, longitude } = asset.exifInfo;
if (!isDefined(latitude) || !isDefined(longitude)) {
return;
}
let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined);
let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined);
return { lat: latitude, lng: longitude };
});
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(() => {
if (mapElement && initialPoint) {
@@ -57,11 +61,9 @@
}
});
let point: Point | null = $state(initialPoint ?? null);
const handleConfirm = (confirmed?: boolean) => {
if (point && confirmed) {
lastChosenLocation.set(point);
geolocationManager.onSelected(point);
onClose(point);
} else {
onClose();
@@ -201,12 +203,12 @@
{:then { default: Map }}
<Map
bind:this={mapElement}
mapMarkers={assetLat !== undefined && assetLng !== undefined && asset
mapMarkers={asset && assetPoint
? [
{
id: asset.id,
lat: assetLat,
lon: assetLng,
lat: assetPoint.lat,
lon: assetPoint.lng,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
@@ -214,7 +216,7 @@
]
: []}
{zoom}
center={mapLat && mapLng ? { lat: mapLat, lng: mapLng } : undefined}
{center}
simplified={true}
clickable={true}
onClickPoint={(selected) => (point = selected)}
@@ -225,7 +227,7 @@
</div>
<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>
{/snippet}

View File

@@ -1,20 +1,21 @@
<script lang="ts">
import type { LatLng } from '$lib/types';
import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
location: { latitude: number | undefined; longitude: number | undefined };
type Props = {
point: LatLng;
assetCount: number;
onClose: (confirm: boolean) => void;
}
};
let { location, assetCount, onClose }: Props = $props();
let { point, assetCount, onClose }: Props = $props();
</script>
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
{#snippet prompt()}
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
<p>- {$t('latitude')}: {location.latitude}</p>
<p>- {$t('longitude')}: {location.longitude}</p>
<p>- {$t('latitude')}: {point.lat}</p>
<p>- {$t('longitude')}: {point.lng}</p>
{/snippet}
</ConfirmModal>

View File

@@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
//-----other
export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null);

View File

@@ -5,6 +5,8 @@ import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
export type LatLng = { lng: number; lat: number };
export interface ReleaseEvent {
isAvailable: boolean;
/** ISO8601 */

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { isDefined } from '$lib';
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 Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
@@ -8,8 +8,10 @@
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { LatLng } from '$lib/types';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { setQueryValue } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -19,15 +21,15 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
type Props = {
data: PageData;
}
};
let { data }: Props = $props();
let isLoading = $state(false);
let assetInteraction = new AssetInteraction();
let location = $state<{ latitude: number; longitude: number }>({ latitude: 0, longitude: 0 });
let point = $state<LatLng>();
let locationUpdated = $state(false);
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -39,8 +41,12 @@
};
const handleUpdate = async () => {
if (!point) {
return;
}
const confirmed = await modalManager.show(GeolocationUpdateConfirmModal, {
location: location ?? { latitude: 0, longitude: 0 },
point,
assetCount: assetInteraction.selectedAssets.length,
});
@@ -51,8 +57,8 @@
await updateAssets({
assetBulkUpdateDto: {
ids: assetInteraction.selectedAssets.map((asset) => asset.id),
latitude: location?.latitude ?? undefined,
longitude: location?.longitude ?? undefined,
latitude: point.lat,
longitude: point.lng,
},
});
@@ -86,18 +92,13 @@
cancelMultiselect(assetInteraction);
};
const handlePickOnMap = async () => {
const point = await modalManager.show(ChangeLocation, {
point: {
lat: location.latitude,
lng: location.longitude,
},
});
if (!point) {
const handlePickPoint = async () => {
const selected = await modalManager.show(GeolocationPointPickerModal, { point });
if (!selected) {
return;
}
location = { latitude: point.lat, longitude: point.lng };
point = selected;
};
const handleEscape = () => {
if (assetInteraction.selectionActive) {
@@ -106,9 +107,10 @@
}
};
const hasGps = (asset: TimelineAsset) => {
return !!asset.latitude && !!asset.longitude;
};
type AssetPoint = { latitude: number; longitude: number };
const hasGps = (asset: TimelineAsset | AssetPoint): asset is AssetPoint =>
isDefined(asset.latitude) && isDefined(asset.longitude);
const handleThumbnailClick = (
asset: TimelineAsset,
@@ -126,7 +128,7 @@
setTimeout(() => {
locationUpdated = false;
}, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! };
point = { lat: asset.latitude, lng: asset.longitude };
void setQueryValue('at', asset.id);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
@@ -148,11 +150,17 @@
title="latitude, longitude"
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'
: ''}">{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>
<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>
</Button>
<Button