Compare commits

..

1 Commits

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

View File

@@ -35,7 +35,8 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^8.0.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"

View File

@@ -1,12 +1,10 @@
import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
resolve: {
alias: { src: '/src' },
tsconfigPaths: true,
},
resolve: { alias: { src: '/src' } },
build: {
rolldownOptions: {
rollupOptions: {
input: 'src/index.ts',
output: {
dir: 'dist',
@@ -18,6 +16,7 @@ export default defineConfig({
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
plugins: [tsconfigPaths()],
test: {
name: 'cli:unit',
globals: true,

View File

@@ -1,51 +0,0 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});

View File

@@ -79,7 +79,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
final person = people[index];
return Column(
key: ValueKey(person.id),
children: [
GestureDetector(
onTap: () {
@@ -89,7 +88,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),

View File

@@ -69,7 +69,6 @@ class DriftSearchPage extends HookConsumerWidget {
);
final previousFilter = useState<SearchFilter?>(null);
final hasRequestedSearch = useState<bool>(false);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
@@ -92,11 +91,9 @@ class DriftSearchPage extends HookConsumerWidget {
if (filter.isEmpty) {
previousFilter.value = null;
hasRequestedSearch.value = false;
return;
}
hasRequestedSearch.value = true;
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
}
@@ -110,8 +107,6 @@ class DriftSearchPage extends HookConsumerWidget {
searchPreFilter() {
if (preFilter != null) {
Future.delayed(Duration.zero, () {
filter.value = preFilter;
textSearchController.clear();
searchFilter(preFilter);
if (preFilter.location.city != null) {
@@ -724,7 +719,7 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (!hasRequestedSearch.value)
if (filter.value.isEmpty)
const _SearchSuggestions()
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),

View File

@@ -24,22 +24,20 @@ class SimilarPhotosActionButton extends ConsumerWidget {
}
ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider);
ref.read(searchPreFilterProvider.notifier)
..clear()
..setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
ref
.read(searchPreFilterProvider.notifier)
.setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);
unawaited(context.navigateTo(const DriftSearchRoute()));
}

View File

@@ -39,16 +39,6 @@ class _RatingBarState extends State<RatingBar> {
_currentRating = widget.initialRating;
}
@override
void didUpdateWidget(covariant RatingBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
setState(() {
_currentRating = widget.initialRating;
});
}
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;

View File

@@ -16,15 +16,9 @@ class SearchDropdown<T> extends StatelessWidget {
final Widget? label;
final Widget? leadingIcon;
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
);
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
const menuStyle = MenuStyle(
final menuStyle = const MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
),
@@ -32,26 +26,11 @@ class SearchDropdown<T> extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final styledEntries = dropdownMenuEntries
.map(
(entry) => DropdownMenuEntry<T>(
value: entry.value,
label: entry.label,
labelWidget: entry.labelWidget,
enabled: entry.enabled,
leadingIcon: entry.leadingIcon,
trailingIcon: entry.trailingIcon,
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
),
)
.toList(growable: false);
return DropdownMenu(
controller: controller,
leadingIcon: leadingIcon,
width: constraints.maxWidth,
menuHeight: maxMenuHeight,
dropdownMenuEntries: styledEntries,
dropdownMenuEntries: dropdownMenuEntries,
label: label,
menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),

777
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -72,10 +72,10 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/enhanced-img": "^0.10.0",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "7.0.0",
"@tailwindcss/vite": "^4.2.2",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
"@testing-library/user-event": "^14.5.2",
@@ -103,10 +103,10 @@
"svelte": "5.53.13",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.1.7",
"typescript": "^5.8.3",
"typescript-eslint": "^8.45.0",
"vite": "^8.0.0",
"vite": "^7.1.2",
"vitest": "^4.0.0"
},
"volta": {

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

@@ -20,12 +20,12 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
@@ -187,7 +187,7 @@
if (!current) {
return;
}
memoryManager.hideAssetsFromMemory(ids);
memoryStore.hideAssetsFromMemory(ids);
init(page);
};
@@ -196,7 +196,7 @@
return;
}
await memoryManager.deleteAssetFromMemory(current.asset.id);
await memoryStore.deleteAssetFromMemory(current.asset.id);
init(page);
};
@@ -205,7 +205,7 @@
return;
}
await memoryManager.deleteMemory(current.memory.id);
await memoryStore.deleteMemory(current.memory.id);
toastManager.primary($t('removed_memory'));
init(page);
};
@@ -216,7 +216,7 @@
}
const newSavedState = !current.memory.isSaved;
await memoryManager.updateMemorySaved(current.memory.id, newSavedState);
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};
@@ -254,11 +254,11 @@
const loadFromParams = (page: Page | NavigationTarget | null) => {
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
return memoryManager.getMemoryAsset(assetId);
return memoryStore.getMemoryAsset(assetId);
};
const init = (target: Page | NavigationTarget | null) => {
if (memoryManager.memories.length === 0) {
if (memoryStore.memories.length === 0) {
return handlePromiseError(goto(Route.photos()));
}
@@ -291,7 +291,7 @@
};
afterNavigate(({ from, to }) => {
memoryManager.ready().then(
memoryStore.ready().then(
() => {
let target;
if (to?.params?.assetId) {

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

@@ -8,16 +8,17 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -307,7 +308,6 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{
filename: asset.originalFileName,
id: asset.id,
cacheKey: asset.thumbhash,
},
];
@@ -321,12 +321,13 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
cacheKey: motionAsset.thumbhash,
});
}
}
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
@@ -334,7 +335,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}

View File

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

View File

@@ -21,7 +21,7 @@ export type MemoryAsset = MemoryIndex & {
nextMemory?: MemoryResponseDto;
};
class MemoryManager {
class MemoryStoreSvelte {
#loading: Promise<void> | undefined;
constructor() {
@@ -135,4 +135,4 @@ class MemoryManager {
}
}
export const memoryManager = new MemoryManager();
export const memoryStore = new MemoryStoreSvelte();

View File

@@ -80,34 +80,7 @@ function createUploadStore() {
};
const removeItem = (id: string) => {
uploadAssets.update((uploadingAsset) => {
const assetToRemove = uploadingAsset.find((a) => a.id === id);
if (assetToRemove) {
stats.update((stats) => {
switch (assetToRemove.state) {
case UploadState.DONE: {
stats.success--;
break;
}
case UploadState.DUPLICATED: {
stats.duplicates--;
break;
}
case UploadState.ERROR: {
stats.errors--;
break;
}
}
stats.total--;
return stats;
});
}
return uploadingAsset.filter((a) => a.id != id);
});
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
};
const dismissErrors = () =>

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

@@ -20,13 +20,13 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import {
@@ -91,7 +91,7 @@
});
const items = $derived(
memoryManager.memories.map((memory) => ({
memoryStore.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.memories({ id: memory.assets[0].id }),

View File

@@ -178,7 +178,19 @@
const handleFirst = () => navigateToIndex(0);
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = () => navigateToIndex(duplicates.length - 1);
const navigateToIndex = async (index: number) =>
@@ -186,12 +198,10 @@
</script>
<svelte:document
use:shortcuts={$showAssetViewer
? []
: [
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>

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