mirror of
https://github.com/immich-app/immich.git
synced 2026-03-25 11:34:23 -07:00
Compare commits
1 Commits
fix/face-s
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393cbdb3d9 |
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,3 +14,5 @@ export const cleanClass = (...classNames: unknown[]) => {
|
||||
.join(' '),
|
||||
);
|
||||
};
|
||||
|
||||
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
|
||||
|
||||
15
web/src/lib/managers/geolocation.manager.svelte.ts
Normal file
15
web/src/lib/managers/geolocation.manager.svelte.ts
Normal 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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
//-----other
|
||||
export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null);
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user