Merge branch 'main' into feat-no-thumbhash-cache

This commit is contained in:
Thomas
2025-09-15 15:30:09 +01:00
committed by GitHub
670 changed files with 75737 additions and 69495 deletions

View File

@@ -1 +1 @@
22.18.0
22.19.0

View File

@@ -1,17 +0,0 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e
RUN apk add --no-cache tini bash
USER node
WORKDIR /usr/src/app
COPY --chown=node:node ./web/package* ./web/
WORKDIR /usr/src/app/web
RUN npm ci
ENV CHOKIDAR_USEPOLLING=true \
PATH="${PATH}:/usr/src/app/web/bin"
EXPOSE 24678
EXPOSE 3000
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]

View File

@@ -1,14 +1,11 @@
#!/usr/bin/env sh
TYPESCRIPT_SDK=/usr/src/app/open-api/typescript-sdk
npm --prefix "$TYPESCRIPT_SDK" install
npm --prefix "$TYPESCRIPT_SDK" run build
cd /usr/src/app/web || exit 1
echo "Build dependencies for Immich Web"
cd /usr/src/app || exit
COUNT=0
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
UPSTREAM="${UPSTREAM%/}"
until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
if [ $((COUNT % 10)) -eq 0 ]; then
echo "Waiting for $UPSTREAM to start..."
@@ -16,7 +13,6 @@ until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
COUNT=$((COUNT + 1))
sleep 1
done
echo "Connected to $UPSTREAM"
npx vite dev --host 0.0.0.0 --port 3000
echo "Connected to $UPSTREAM, starting Immich Web..."
pnpm --filter @immich/sdk build
pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port 3000

View File

@@ -1,5 +1,6 @@
import js from '@eslint/js';
import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat';
import prettier from 'eslint-config-prettier';
import eslintPluginCompat from 'eslint-plugin-compat';
import eslintPluginSvelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
@@ -17,6 +18,7 @@ export default typescriptEslint.config(
...eslintPluginSvelte.configs.recommended,
eslintPluginUnicorn.configs.recommended,
js.configs.recommended,
prettier,
{
plugins: {
tscompat: tslintPluginCompat,

10514
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.137.3",
"version": "1.142.0",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -19,7 +19,7 @@
"lint:fix": "npm run lint -- --fix",
"format": "prettier --check .",
"format:fix": "prettier --write . && npm run format:i18n",
"format:i18n": "npx --yes sort-json ../i18n/*.json",
"format:i18n": "pnpx sort-json ../i18n/*.json",
"test": "vitest --run",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
@@ -28,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.24.0",
"@immich/ui": "^0.27.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -50,27 +50,26 @@
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.3.0",
"maplibre-gl": "^5.6.2",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.1.3",
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^1.0.0",
"svelte-maplibre": "^1.2.0",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
"thumbhash": "^0.1.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.3.0",
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.7.0",
"@sveltejs/enhanced-img": "^0.8.0",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.1.0",
"@sveltejs/vite-plugin-svelte": "6.1.2",
"@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8",
@@ -82,7 +81,6 @@
"@types/luxon": "^3.4.2",
"@types/qrcode": "^1.5.5",
"@vitest/coverage-v8": "^3.0.0",
"autoprefixer": "^10.4.17",
"dotenv": "^17.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.1.8",
@@ -102,13 +100,12 @@
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.2.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.6.2",
"typescript": "^5.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.5",
"vite": "^7.1.2",
"vitest": "^3.0.0"
},
"volta": {
"node": "22.18.0"
"node": "22.19.0"
}
}

View File

@@ -169,3 +169,13 @@
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.8));
}
}
.maplibregl-popup {
.maplibregl-popup-tip {
@apply border-t-subtle! translate-y-[-1px];
}
.maplibregl-popup-content {
@apply bg-subtle rounded-lg;
}
}

View File

@@ -0,0 +1 @@
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M249.841 115.734v250.041c0 13.572 10.867 24.563 24.287 24.563h147.186l64.25-91.581c3.063-4.369 10.722-4.369 13.786 0l22.494 32.07.175.25.152-.221 48.243-70.046c3.336-4.85 11.695-4.85 15.031 0l63.892 92.779v12.215-250.07c0-13.572-10.897-24.562-24.288-24.562H274.128c-13.42 0-24.287 10.99-24.287 24.562z" fill="#9d9ea3"/><path d="M362.501 281.935c-34.737 0-62.896-28.16-62.896-62.897 0-34.736 28.159-62.896 62.896-62.896s62.897 28.16 62.897 62.896c0 34.737-28.16 62.897-62.897 62.897z" fill="#fff"/><path d="M449.176 445.963H259.725c-7.79 0-14.188-6.399-14.188-14.188 0-7.882 6.398-14.281 14.188-14.281h189.451c7.882 0 14.28 6.399 14.28 14.281 0 7.789-6.398 14.188-14.28 14.188zm189.543.002H501.662c-7.882 0-14.281-6.399-14.281-14.281 0-7.882 6.399-14.281 14.281-14.281h137.057c7.883 0 14.281 6.399 14.281 14.281 0 7.882-6.398 14.281-14.281 14.281zm-298.503 62.592h-80.491c-7.79 0-14.188-6.398-14.188-14.188 0-7.882 6.398-14.281 14.188-14.281h80.491c7.882 0 14.281 6.399 14.281 14.281 0 7.79-6.399 14.188-14.281 14.188zm298.503.002H388.065c-7.882 0-14.28-6.398-14.28-14.28s6.398-14.281 14.28-14.281h250.654c7.883 0 14.281 6.399 14.281 14.281 0 7.882-6.398 14.28-14.281 14.28z" fill="#E1E4E5"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,5 +1,9 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
@@ -7,8 +11,10 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk';
import { Button, modalManager, Text } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -44,6 +50,26 @@
onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth });
};
const handleUnlinkAllOAuthAccounts = async () => {
const confirmed = await modalManager.showDialog({
icon: mdiRestart,
title: $t('admin.unlink_all_oauth_accounts'),
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}
try {
await unlinkAllOAuthAccountsAdmin({});
notificationController.show({ message: $t('success'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
</script>
<div>
@@ -56,7 +82,7 @@
subtitle={$t('admin.oauth_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<Text size="small">
<FormatMessage key="admin.oauth_settings_more_details">
{#snippet children({ message })}
<a
@@ -69,7 +95,7 @@
</a>
{/snippet}
</FormatMessage>
</p>
</Text>
<SettingSwitch
{disabled}
@@ -79,6 +105,14 @@
{#if config.oauth.enabled}
<hr />
<div class="flex items-center gap-2 justify-between">
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
>{$t('admin.unlink_all_oauth_accounts')}</Button
>
</div>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"

View File

@@ -48,7 +48,7 @@
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="ghost"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
@@ -32,7 +33,7 @@
}
abortController = new AbortController();
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false });
let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false, ...authManager.params });
let markers: MapMarkerResponseDto[] = [];
for (const asset of albumInfo.assets) {

View File

@@ -14,7 +14,7 @@
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import DownloadAction from '../photos-page/actions/download-action.svelte';
@@ -125,7 +125,7 @@
variant="ghost"
aria-label={$t('download')}
onclick={() => downloadAlbum(album)}
icon={mdiFolderDownloadOutline}
icon={mdiDownload}
/>
{/if}
{#if sharedLink.showMetadata && $featureFlags.loaded && $featureFlags.map}

View File

@@ -38,7 +38,7 @@
import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, deleteAlbum, isHttpError, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiFolderDownloadOutline, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -419,7 +419,7 @@
/>
<MenuOption icon={mdiShareVariantOutline} text={$t('share')} onClick={() => openShareModal()} />
{/if}
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={() => handleDownloadAlbum()} />
<MenuOption icon={mdiDownload} text={$t('download')} onClick={() => handleDownloadAlbum()} />
{#if showFullContextMenu}
<MenuOption icon={mdiDeleteOutline} text={$t('delete')} onClick={() => setAlbumToDelete()} />
{/if}

View File

@@ -4,7 +4,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
@@ -20,14 +20,23 @@
let { asset, onAction, shared = false }: Props = $props();
const onClick = async () => {
const album = await modalManager.show(AlbumPickerModal, { shared });
const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!album) {
if (!albums || albums.length === 0) {
return;
}
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
if (albums.length === 1) {
const album = albums[0];
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
} else {
await addAssetsToAlbums(
albums.map((a) => a.id),
[asset.id],
);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
}
};
</script>

View File

@@ -6,7 +6,7 @@
import { downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiFolderDownloadOutline } from '@mdi/js';
import { mdiDownload } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -26,10 +26,10 @@
color="secondary"
shape="round"
variant="ghost"
icon={mdiFolderDownloadOutline}
icon={mdiDownload}
aria-label={$t('download')}
onclick={onDownloadFile}
/>
{:else}
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} />
<MenuOption icon={mdiDownload} text={$t('download')} onClick={onDownloadFile} />
{/if}

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
import { mdiCheckCircle } from '@mdi/js';
import type { Action } from 'svelte/action';
import AlbumListItemDetails from './album-list-item-details.svelte';
@@ -10,10 +13,19 @@
album: AlbumResponseDto;
searchQuery?: string;
selected: boolean;
multiSelected?: boolean;
onAlbumClick: () => void;
onMultiSelect: () => void;
}
let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props();
let {
album,
searchQuery = '',
selected = false,
multiSelected = false,
onAlbumClick,
onMultiSelect,
}: Props = $props();
const scrollIntoViewIfSelected: Action = (node) => {
$effect(() => {
@@ -37,33 +49,127 @@
albumName.slice(findIndex + findLength),
];
});
const handleMultiSelectClicked = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
onMultiSelect();
};
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let mouseOver = $state(false);
const onMouseEnter = () => {
if (usingMobileDevice) {
return;
}
mouseOver = true;
};
const onMouseLeave = () => {
mouseOver = false;
};
let timer: ReturnType<typeof setTimeout> | null = null;
const preventContextMenu = (evt: Event) => evt.preventDefault();
const disposeables: (() => void)[] = [];
const clearLongPressTimer = () => {
if (!timer) {
return;
}
clearTimeout(timer);
timer = null;
for (const dispose of disposeables) {
dispose();
}
disposeables.length = 0;
};
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = () => {
didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
didPress = true;
}, 350);
};
const click = (e: MouseEvent) => {
if (!didPress) {
return;
}
e.stopPropagation();
e.preventDefault();
};
element.addEventListener('click', click);
element.addEventListener('pointerdown', start, true);
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
return {
destroy: () => {
element.removeEventListener('click', click);
element.removeEventListener('pointerdown', start, true);
element.removeEventListener('pointerup', clearLongPressTimer, true);
},
};
}
</script>
<button
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
class="flex w-full gap-4 px-6 py-2 text-start transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
<div
role="group"
class={[
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
]}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
>
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="flex gap-1 text-sm">
<AlbumListItemDetails {album} />
<button
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
class="flex w-full gap-4 px-2 py-2 text-start"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
>
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
</span>
</button>
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="flex gap-1 text-sm">
<AlbumListItemDetails {album} />
</span>
</span>
</button>
{#if mouseOver || multiSelected}
<button
type="button"
onclick={handleMultiSelectClicked}
class="absolute right-0 top-4 p-3 focus:outline-none hover:cursor-pointer"
role="checkbox"
tabindex={-1}
aria-checked={selected}
>
{#if multiSelected}
<div class="rounded-full">
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
{/if}
</button>
{/if}
</div>

View File

@@ -22,6 +22,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
@@ -41,6 +42,7 @@
import {
mdiAlertOutline,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDotsVertical,
@@ -98,6 +100,7 @@
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
// $: showEditorButton =
// isOwner &&
@@ -225,6 +228,13 @@
text={$t('view_in_timeline')}
/>
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() => goto(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`)}
text={$t('view_similar_photos')}
/>
{/if}
{/if}
{#if !asset.isTrashed}

View File

@@ -491,7 +491,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{/if}
{:else}

View File

@@ -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'));
}
}
};
</script>
{#if asset.exifInfo?.country}
@@ -85,6 +89,6 @@
{#if isShowChangeLocation}
<Portal>
<ChangeLocation {asset} onConfirm={handleConfirmChangeLocation} onCancel={() => (isShowChangeLocation = false)} />
<ChangeLocation {asset} {onClose} />
</Portal>
{/if}

View File

@@ -21,7 +21,6 @@
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}

View File

@@ -16,21 +16,14 @@
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAssetInfo, updateAsset, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import {
mdiCalendar,
@@ -61,17 +54,28 @@
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
if (isFlipped(exifInfo.orientation)) {
return { width: height, height: width };
}
return { width, height };
};
let showAssetPath = $state(false);
let showEditFaces = $state(false);
let isOwner = $derived($user?.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let previousId: string | undefined = $state();
$effect(() => {
@@ -84,42 +88,6 @@
}
});
let isOwner = $derived($user?.id === asset.ownerId);
const handleNewAsset = async (newAsset: AssetResponseDto) => {
// TODO: check if reloading asset data is necessary
if (newAsset.id && !authManager.isSharedLink) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
}
};
$effect(() => {
handlePromiseError(handleNewAsset(asset));
});
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
}
})(),
);
let people = $state(asset.people || []);
let unassignedFaces = $state(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
@@ -131,10 +99,7 @@
};
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || [];
unassignedFaces = data?.unassignedFaces || [];
});
asset = await getAssetInfo({ id: asset.id });
showEditFaces = false;
};
@@ -415,9 +380,9 @@
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
{#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
@@ -525,7 +490,7 @@
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-immich-primary"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</a>

View File

@@ -7,12 +7,10 @@
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetMediaSize } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { swipe } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
@@ -40,7 +38,6 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let forceMuted = $state(false);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -49,7 +46,6 @@
showVideo = true;
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
forceMuted = false;
videoPlayer.load();
}
});
@@ -67,23 +63,27 @@
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
await tryForceMutedPlay(video);
return;
}
handleError(error, $t('errors.unable_to_play_video'));
// auto-play failed
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
if (video.muted) {
return;
}
try {
video.muted = true;
await handleCanPlay(video);
} catch (error) {
handleError(error, $t('errors.unable_to_play_video'));
} catch {
// muted auto-play failed
}
};
@@ -134,18 +134,14 @@
onswipe={onSwipe}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}

View File

@@ -204,7 +204,7 @@
<div
class={[
'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
]}
style:width="{width}px"
style:height="{height}px"
@@ -328,7 +328,7 @@
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
<div class="absolute top-0 h-full w-full">
<div class="absolute top-0 h-full w-full pointer-events-none">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
@@ -338,7 +338,7 @@
/>
</div>
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<div class="absolute top-0 h-full w-full pointer-events-none">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}

View File

@@ -110,7 +110,7 @@
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<div
class="fixed top-0 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
class="fixed top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
>
<div class="flex items-center">
<IconButton

View File

@@ -64,9 +64,10 @@
{#if showVerticalDots}
<div class="absolute top-2 end-2 z-1">
<ButtonContextMenu
buttonClass="icon-white-drop-shadow focus:opacity-100 {showVerticalDots ? 'opacity-100' : 'opacity-0'}"
color="primary"
buttonClass="icon-white-drop-shadow"
color="secondary"
size="medium"
variant="filled"
icon={mdiDotsVertical}
title={$t('show_person_options')}
>

View File

@@ -68,7 +68,7 @@
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none" tabindex="-1" id={headerId}>{title}</div>
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
{/if}
{#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>

View File

@@ -10,26 +10,28 @@
interface Props {
asset: TimelineAsset;
onImageLoad: () => void;
}
const { asset }: Props = $props();
const { asset, onImageLoad }: Props = $props();
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let loader = $state<HTMLImageElement>();
const onload = () => {
const onLoadCallback = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
onImageLoad();
};
onMount(() => {
if (loader?.complete) {
onload();
onLoadCallback();
}
loader?.addEventListener('load', onload);
loader?.addEventListener('load', onLoadCallback);
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('load', onLoadCallback);
};
});

View File

@@ -84,6 +84,7 @@
let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
return asset;
@@ -95,6 +96,7 @@
await goto(asHref(asset));
};
const setProgressDuration = (asset: TimelineAsset) => {
if (asset.isVideo) {
const timeParts = asset.duration!.split(':').map(Number);
@@ -108,6 +110,7 @@
});
}
};
const handleNextAsset = () => handleNavigate(current?.next?.asset);
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
@@ -115,6 +118,7 @@
const handleEscape = async () => goto(AppRoute.PHOTOS);
const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
// leaving these log statements here as comments. Very useful to figure out what's going on during dev!
// console.log(`handleAction[${callingContext}] called with: ${action}`);
@@ -154,6 +158,7 @@
}
}
};
const handleProgress = async (progress: number) => {
if (!progressBarController) {
return;
@@ -184,6 +189,7 @@
memoryStore.hideAssetsFromMemory(ids);
init(page);
};
const handleDeleteMemoryAsset = async () => {
if (!current) {
return;
@@ -192,6 +198,7 @@
await memoryStore.deleteAssetFromMemory(current.asset.id);
init(page);
};
const handleDeleteMemory = async () => {
if (!current) {
return;
@@ -201,6 +208,7 @@
notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info });
init(page);
};
const handleSaveMemory = async () => {
if (!current) {
return;
@@ -214,10 +222,12 @@
});
init(page);
};
const handleGalleryScrollsIntoView = () => {
galleryInView = true;
handlePromiseError(handleAction('galleryInView', 'pause'));
};
const handleGalleryScrollsOutOfView = () => {
galleryInView = false;
// only call play after the first page load. When page first loads the gallery will not be visible
@@ -246,16 +256,22 @@
playerInitialized = false;
};
const resetAndPlay = () => {
handlePromiseError(handleAction('resetAndPlay', 'reset'));
handlePromiseError(handleAction('resetAndPlay', 'play'));
};
const initPlayer = () => {
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.isVideo && !videoPlayer;
const isVideo = current && current.asset.isVideo;
const isVideoAssetButPlayerHasNotLoadedYet = isVideo && !videoPlayer;
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return;
}
if ($isViewing) {
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else {
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'reset'));
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'play'));
} else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets.
resetAndPlay();
}
playerInitialized = true;
};
@@ -474,7 +490,7 @@
videoViewerVolume={$videoViewerVolume}
/>
{:else}
<MemoryPhotoViewer asset={current.asset} />
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
{/if}
{/key}
@@ -646,6 +662,7 @@
viewport={galleryViewport}
{assetInteraction}
slidingWindowOffset={viewerHeight}
arrowNavigation={false}
/>
</div>
</section>

View File

@@ -1,16 +1,22 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { Heading, HStack, Stack } from '@immich/ui';
import { mdiAlert } from '@mdi/js';
import { Stack } from '@immich/ui';
import { mdiAlertCircleOutline } from '@mdi/js';
import type { Translations } from 'svelte-i18n';
const messageKeys = [
'admin.backup_onboarding_3_description',
'admin.backup_onboarding_2_description',
'admin.backup_onboarding_1_description',
];
</script>
<div class="flex flex-col">
<Stack gap={2}>
<HStack gap={4}>
<Icon path={mdiAlert} size="96" class="text-warning" />
<p class="mb-2">
<div class="flex items-start gap-4 p-6 my-10 bg-gray-100 dark:bg-gray-800/40 rounded-xl border border-gray-700/50">
<Icon path={mdiAlertCircleOutline} size="36" class="text-warning flex-shrink-0 mt-0.5" />
<div class="text-gray-800 dark:text-gray-300 leading-relaxed">
<FormatMessage key="admin.backup_onboarding_description">
{#snippet children({ message })}
<a
@@ -23,40 +29,41 @@
</a>
{/snippet}
</FormatMessage>
</div>
</div>
<div class="space-y-1">
<h2 class="mb-6"><FormatMessage key="admin.backup_onboarding_parts_title" /></h2>
<div class="space-y-6">
{#each messageKeys as keyString, index (index)}
<div class="flex items-start gap-6">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center">
<span class="text-light text-xl font-semibold">{3 - index}</span>
</div>
<div class="leading-relaxed pt-2">
<FormatMessage key={keyString as Translations} />
</div>
</div>
{/each}
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mt-4">
<p>
<FormatMessage key="admin.backup_onboarding_footer">
{#snippet children({ message })}
<a
href="https://immich.app/docs/administration/backup-and-restore/"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
</HStack>
<p class="text-lg font-semibold">
<FormatBoldMessage key="admin.backup_onboarding_parts_title"></FormatBoldMessage>
</p>
<Stack class="bg-gray-100 dark:bg-gray-800 rounded-xl p-4" gap={4}>
<HStack gap={6}>
<Heading tag="h1" size="title" color="primary">3</Heading>
<FormatMessage key="admin.backup_onboarding_3_description" />
</HStack>
<HStack gap={6}>
<Heading tag="h1" size="title" color="primary">2</Heading>
<FormatMessage key="admin.backup_onboarding_2_description" />
</HStack>
<HStack gap={6} class="ml-2">
<Heading tag="h1" size="title" color="primary">1</Heading>
<FormatMessage key="admin.backup_onboarding_1_description" />
</HStack>
</Stack>
<p>
<FormatMessage key="admin.backup_onboarding_footer">
{#snippet children({ message })}
<a
href="https://immich.app/docs/administration/backup-and-restore/"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
</div>
</Stack>
</div>

View File

@@ -31,7 +31,7 @@
<div
id="onboarding-card"
class="flex w-full max-w-4xl flex-col gap-4 rounded-3xl border-2 border-gray-500 px-8 py-8 dark:border-immich-dark-gray dark:bg-immich-dark-gray text-black dark:text-immich-dark-fg bg-gray-50"
class="flex w-full max-w-4xl flex-col gap-4 rounded-3xl border-2 border-gray-500 px-8 py-8 dark:border-gray-700 dark:bg-immich-dark-gray text-black dark:text-immich-dark-fg bg-gray-50"
in:fade={{ duration: 250 }}
>
{#if title || icon}

View File

@@ -24,7 +24,7 @@
</button>
<button
type="button"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
class="w-1/2 aspect-square bg-dark dark:bg-light rounded-3xl transition-all shadow-sm hover:shadow-xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
onclick={() => themeManager.setTheme(Theme.DARK)}
>
<div

View File

@@ -2,7 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import type { OnAddToAlbum } from '$lib/utils/actions';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { modalManager } from '@immich/ui';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -18,15 +18,23 @@
const { getAssets } = getAssetControlContext();
const onClick = async () => {
const album = await modalManager.show(AlbumPickerModal, { shared });
if (!album) {
const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!albums || albums.length === 0) {
return;
}
const assetIds = [...getAssets()].map(({ id }) => id);
await addAssetsToAlbum(album.id, assetIds);
onAddToAlbum(assetIds, album.id);
if (albums.length === 1) {
const album = albums[0];
await addAssetsToAlbum(album.id, assetIds);
onAddToAlbum(assetIds, album.id);
} else {
await addAssetsToAlbums(
albums.map(({ id }) => id),
assetIds,
);
onAddToAlbum(assetIds, albums[0].id);
}
};
</script>

View File

@@ -4,10 +4,10 @@
import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props {
menuItem?: boolean;
@@ -18,16 +18,21 @@
let isShowChangeLocation = $state(false);
async function handleConfirm(point: { lng: number; lat: number }) {
async function handleConfirm(point?: { lng: number; lat: number }) {
isShowChangeLocation = false;
if (!point) {
return;
}
const ids = getSelectedAssets(getOwnedAssets(), $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_update_location'));
}
clearSelect();
}
</script>
@@ -39,5 +44,5 @@
/>
{/if}
{#if isShowChangeLocation}
<ChangeLocation onConfirm={handleConfirm} onCancel={() => (isShowChangeLocation = false)} />
<ChangeLocation onClose={handleConfirm} />
{/if}

View File

@@ -5,7 +5,7 @@
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { mdiDownload } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
@@ -31,21 +31,19 @@
clearSelect();
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
};
let menuItemIcon = $derived(getAssets().length === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline);
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} />
{#if menuItem}
<MenuOption text={$t('download')} icon={menuItemIcon} onClick={handleDownloadFiles} />
<MenuOption text={$t('download')} icon={mdiDownload} onClick={handleDownloadFiles} />
{:else}
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('download')}
icon={mdiCloudDownloadOutline}
icon={mdiDownload}
onclick={handleDownloadFiles}
/>
{/if}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -12,8 +13,10 @@
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { fly, scale } from 'svelte/transition';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
@@ -25,11 +28,23 @@
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
@@ -40,10 +55,12 @@
monthGroup = $bindable(),
assetInteraction,
timelineManager,
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onScrollCompensation,
onThumbnailClick,
}: Props = $props();
let isMouseOverGroup = $state(false);
@@ -53,7 +70,7 @@
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = (
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
@@ -108,6 +125,16 @@
return intersectable.filter((int) => int.intersecting);
}
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
onScrollCompensation(timelineManager.scrollCompensation);
@@ -142,10 +169,11 @@
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dayGroup.groupTitle))}
{#if !singleSelect}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block pe-2 hover:cursor-pointer"
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
>
@@ -157,7 +185,7 @@
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={dayGroup.groupTitle}>
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
@@ -190,7 +218,13 @@
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
@@ -200,6 +234,9 @@
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div>
<!-- {/if} -->
{/each}

View File

@@ -13,6 +13,7 @@
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -65,6 +66,18 @@
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
@@ -84,6 +97,8 @@
onEscape = () => {},
children,
empty,
customLayout,
onThumbnailClick,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
@@ -940,6 +955,8 @@
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
onScrollCompensation={handleScrollCompensation}
{customLayout}
{onThumbnailClick}
/>
</div>
{/if}

View File

@@ -14,7 +14,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
@@ -135,7 +135,7 @@
variant="ghost"
aria-label={$t('download')}
onclick={downloadAssets}
icon={mdiFolderDownloadOutline}
icon={mdiDownload}
/>
{/if}
{/snippet}

View File

@@ -1,18 +1,24 @@
import NumberRangeInput from '$lib/components/shared-components/number-range-input.svelte';
import { render, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import type { Mock } from 'vitest';
describe('NumberRangeInput component', () => {
const user = userEvent.setup();
let sut: RenderResult<NumberRangeInput>;
let input: HTMLInputElement;
let onInput: Mock;
let onKeyDown: Mock;
beforeEach(() => {
onInput = vi.fn();
onKeyDown = vi.fn();
sut = render(NumberRangeInput, {
id: '',
min: -90,
max: 90,
onInput: () => {},
onInput,
onKeyDown,
});
input = sut.getByRole('spinbutton') as HTMLInputElement;
});
@@ -21,35 +27,55 @@ describe('NumberRangeInput component', () => {
expect(input.value).toBe('');
await sut.rerender({ value: 10 });
expect(input.value).toBe('10');
expect(onInput).not.toHaveBeenCalled();
expect(onKeyDown).not.toHaveBeenCalled();
});
it('restricts minimum value', async () => {
await user.type(input, '-91');
expect(input.value).toBe('-90');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('restricts maximum value', async () => {
await user.type(input, '09990');
expect(input.value).toBe('90');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('allows entering negative numbers', async () => {
await user.type(input, '-10');
expect(input.value).toBe('-10');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('allows entering zero', async () => {
await user.type(input, '0');
expect(input.value).toBe('0');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('allows entering decimal numbers', async () => {
await user.type(input, '-0.09001');
expect(input.value).toBe('-0.09001');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('ignores text input', async () => {
await user.type(input, 'test');
expect(input.value).toBe('');
expect(onInput).toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
it('test', async () => {
await user.type(input, 'd');
expect(onInput).not.toHaveBeenCalled();
expect(onKeyDown).toHaveBeenCalled();
});
});

View File

@@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
type: AlbumModalRowType.ALBUM_ITEM,
album,
selected,
multiSelected: false,
});
describe('Album Modal', () => {
it('non-shared with no albums configured yet shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows('', [], [], -1);
const modalRows = converter.toModalRows('', [], [], -1, []);
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
});
it('non-shared with no matching albums shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1);
const modalRows = converter.toModalRows(
'matches_nothing',
[],
[albumFactory.build({ albumName: 'Holidays' })],
-1,
[],
);
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
});
@@ -44,7 +51,7 @@ describe('Album Modal', () => {
it('non-shared displays single albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1);
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
@@ -64,6 +71,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
@@ -90,6 +98,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
@@ -112,6 +121,7 @@ describe('Album Modal', () => {
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
@@ -125,7 +135,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0);
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(true),
@@ -141,7 +151,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1);
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
@@ -157,7 +167,7 @@ describe('Album Modal', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3);
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),

View File

@@ -16,6 +16,7 @@ export enum AlbumModalRowType {
export type AlbumModalRow = {
type: AlbumModalRowType;
selected?: boolean;
multiSelected?: boolean;
text?: string;
album?: AlbumResponseDto;
};
@@ -41,6 +42,7 @@ export class AlbumModalRowConverter {
recentAlbums: AlbumResponseDto[],
albums: AlbumResponseDto[],
selectedRowIndex: number,
multiSelectedAlbumIds: string[],
): AlbumModalRow[] {
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
@@ -64,6 +66,7 @@ export class AlbumModalRowConverter {
rows.push({
type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album,
});
}
@@ -81,6 +84,7 @@ export class AlbumModalRowConverter {
rows.push({
type: AlbumModalRowType.ALBUM_ITEM,
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
multiSelected: multiSelectedAlbumIds.includes(album.id),
album,
});
}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import type { Action } from 'svelte/action';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { Action } from 'svelte/action';
interface Props {
searchQuery?: string;

View File

@@ -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,13 +38,19 @@
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);
let zoom = $derived(mapLat && mapLng ? 12.5 : 1);
$effect(() => {
if (mapElement && initialPoint) {
mapElement.addClipMapMarker(initialPoint.lng, initialPoint.lat);
}
});
$effect(() => {
if (places) {
@@ -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);
};
</script>
<ConfirmModal
@@ -116,7 +127,7 @@
title={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
size="medium"
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
onClose={handleConfirm}
>
{#snippet promptSnippet()}
<div class="flex flex-col w-full h-full gap-2">
@@ -197,14 +208,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={(lat, lng) => {
point = { lat, lng };
mapElement?.addClipMapMarker(lng, lat);
}}
/>
<CoordinatesInput lat={point ? point.lat : assetLat} lng={point ? point.lng : assetLng} {onUpdate} />
</div>
</div>
{/snippet}

View File

@@ -40,6 +40,8 @@
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
$effect(() => {
if (menuElement) {
let layoutDirection = direction;
@@ -64,6 +66,12 @@
style:top="{top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {
isTransitioned = true;
}}
onoutrostart={() => {
isTransitioned = false;
}}
>
<ul
{id}
@@ -73,7 +81,9 @@
bind:this={menuElement}
class="{isVisible
? 'max-h-dvh'
: 'max-h-0'} flex flex-col transition-all duration-250 ease-in-out outline-none overflow-auto"
: 'max-h-0'} flex flex-col transition-all duration-250 ease-in-out outline-none {isTransitioned
? 'overflow-auto'
: ''}"
role="menu"
tabindex="-1"
>

View File

@@ -20,6 +20,10 @@
}
};
const onKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
};
const onPaste = (event: ClipboardEvent) => {
const pastedText = event.clipboardData?.getData('text/plain');
if (!pastedText) {
@@ -42,10 +46,10 @@
<div>
<label class="immich-form-label" for="latitude-input-{id}">{$t('latitude')}</label>
<NumberRangeInput id="latitude-input-{id}" min={-90} max={90} {onInput} {onPaste} bind:value={lat} />
<NumberRangeInput id="latitude-input-{id}" min={-90} max={90} {onKeyDown} {onInput} {onPaste} bind:value={lat} />
</div>
<div>
<label class="immich-form-label" for="longitude-input-{id}">{$t('longitude')}</label>
<NumberRangeInput id="longitude-input-{id}" min={-180} max={180} {onInput} {onPaste} bind:value={lng} />
<NumberRangeInput id="longitude-input-{id}" min={-180} max={180} {onKeyDown} {onInput} {onPaste} bind:value={lng} />
</div>

View File

@@ -42,6 +42,7 @@
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
arrowNavigation?: boolean;
}
let {
@@ -60,6 +61,7 @@
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
arrowNavigation = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
@@ -145,14 +147,10 @@
let lastIntersectedHeight = 0;
$effect(() => {
const scrollRatio = slidingWindow.bottom / assetLayouts.containerHeight;
// TODO: We may want to limit to an absolute value as the ratio scaling will
// get weird with lots of assets. The page may be nowhere near the bottom in
// absolute terms, and yet the intersection will still be triggered.
if (scrollRatio > 0.9) {
// Intersect if there's only one viewport worth of assets left to scroll.
if (assetLayouts.containerHeight - slidingWindow.bottom <= viewport.height) {
// Notify we got to (near) the end of scroll.
const intersectedHeight = geometry?.containerHeight || 0;
const intersectedHeight = assetLayouts.containerHeight;
if (lastIntersectedHeight !== intersectedHeight) {
debouncedOnIntersected();
lastIntersectedHeight = intersectedHeight;
@@ -310,8 +308,12 @@
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
...(arrowNavigation
? [
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
]
: []),
];
if (assetInteraction.selectionActive) {

View File

@@ -353,7 +353,7 @@
<div
class="rounded-full w-[40px] h-[40px] bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count}
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { clamp } from 'lodash-es';
import type { ClipboardEventHandler } from 'svelte/elements';
import type { ClipboardEventHandler, KeyboardEventHandler } from 'svelte/elements';
interface Props {
id: string;
@@ -11,6 +11,7 @@
value?: number;
onInput: (value: number | null) => void;
onPaste?: ClipboardEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
}
let {
@@ -22,10 +23,12 @@
value = $bindable(),
onInput,
onPaste = undefined,
onKeyDown = undefined,
}: Props = $props();
const oninput = () => {
if (!value) {
// value can be 0
if (value === undefined) {
return;
}
@@ -47,4 +50,5 @@
bind:value
{oninput}
onpaste={onPaste}
onkeydown={onKeyDown}
/>

View File

@@ -91,9 +91,9 @@
</div>
{#if uploadAsset.state === UploadState.STARTED}
<div class="text-black relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 dark:bg-immich-dark-gray">
<div class="text-black relative mt-[5px] h-[15px] w-full rounded-md bg-gray-300 dark:bg-gray-700">
<div class="h-[15px] rounded-md bg-immich-primary transition-all" style={`width: ${uploadAsset.progress}%`}></div>
<p class="absolute top-0 h-full w-full text-center text-[10px]">
<p class="absolute top-0 h-full w-full text-center text-primary text-[10px]">
{#if uploadAsset.message}
{uploadAsset.message}
{:else}

View File

@@ -12,7 +12,7 @@
playVideoThumbnailOnHover,
showDeleteModal,
} from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { createDateFormatter, findLocale } from '$lib/utils';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -48,21 +48,7 @@
}
};
let editedLocale = $derived(findLocale($locale).code);
let formattedDate = $derived(
time.toLocaleString(editedLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}),
);
let timePortion = $derived(
time.toLocaleString(editedLocale, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
);
let selectedDate = $derived(`${formattedDate} ${timePortion}`);
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
let selectedOption = $derived({
value: findLocale(editedLocale).code || fallbackLocale.code,
label: findLocale(editedLocale).name || fallbackLocale.name,

View File

@@ -104,7 +104,7 @@
try {
for (const user of users) {
await createPartner({ id: user.id });
await createPartner({ partnerCreateDto: { sharedWithId: user.id } });
}
await refreshPartners();
@@ -115,7 +115,7 @@
const handleShowOnTimelineChanged = async (partner: PartnerSharing, inTimeline: boolean) => {
try {
await updatePartner({ id: partner.user.id, updatePartnerDto: { inTimeline } });
await updatePartner({ id: partner.user.id, partnerUpdateDto: { inTimeline } });
partner.inTimeline = inTimeline;
} catch (error) {

View File

@@ -54,7 +54,7 @@
<div
class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors {isSelected
? 'bg-green-400/90'
: 'bg-red-300/90'}"
: 'bg-red-300/90'} text-black"
>
{isSelected ? $t('keep') : $t('to_trash')}
</div>

View File

@@ -2,11 +2,12 @@
import { shortcuts } from '$lib/actions/shortcut';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
@@ -42,32 +43,32 @@
assetViewingStore.showAssetViewer(false);
});
const onNext = () => {
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
return Promise.resolve(false);
return false;
}
setAsset(assets[index]);
return Promise.resolve(true);
await onViewAsset(assets[index]);
return true;
};
const onPrevious = () => {
const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
return Promise.resolve(false);
return false;
}
setAsset(assets[index]);
return Promise.resolve(true);
await onViewAsset(assets[index]);
return true;
};
const onRandom = () => {
const onRandom = async () => {
if (assets.length <= 0) {
return Promise.resolve(undefined);
return;
}
const index = Math.floor(Math.random() * assets.length);
const asset = assets[index];
setAsset(asset);
return Promise.resolve(asset);
await onViewAsset(asset);
return { id: asset.id };
};
const onSelectAsset = (asset: AssetResponseDto) => {
@@ -86,6 +87,12 @@
selectedAssetIds = new SvelteSet(assets.map((asset) => asset.id));
};
const onViewAsset = async ({ id }: AssetResponseDto) => {
const asset = await getAssetInfo({ ...authManager.params, id });
setAsset(asset);
await navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleResolve = () => {
const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = assets.map((asset) => asset.id);
@@ -102,9 +109,7 @@
{ shortcut: { key: 'a' }, onShortcut: onSelectAll },
{
shortcut: { key: 's' },
onShortcut: () => {
setAsset(assets[0]);
},
onShortcut: () => onViewAsset(assets[0]),
},
{ shortcut: { key: 'd' }, onShortcut: onSelectNone },
{ shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
@@ -112,7 +117,7 @@
]}
/>
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-16">
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-4">
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
<!-- MARK ALL BUTTONS -->
<div class="flex text-xs text-black">
@@ -166,12 +171,7 @@
<div class="flex flex-wrap gap-1 mb-4 place-items-center place-content-center px-4 pt-4">
{#each assets as asset (asset.id)}
<DuplicateAsset
{asset}
{onSelectAsset}
isSelected={selectedAssetIds.has(asset.id)}
onViewAsset={(asset) => setAsset(asset)}
/>
<DuplicateAsset {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
{/each}
</div>
</div>

View File

@@ -1,29 +1,23 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { mdiContentDuplicate, mdiImageSizeSelectLarge } from '@mdi/js';
import { mdiContentDuplicate, mdiCrosshairsGps, mdiImageSizeSelectLarge } from '@mdi/js';
import { t } from 'svelte-i18n';
const links = [
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
];
</script>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">{$t('organize_your_library').toUpperCase()}</p>
<a
href={AppRoute.DUPLICATES}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('review_duplicates')}
</a>
<a
href={AppRoute.LARGE_FILES}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span
><Icon path={mdiImageSizeSelectLarge} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('review_large_files')}
</a>
{#each links as link (link.href)}
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
<span><Icon path={link.icon} class="text-immich-primary dark:text-immich-dark-primary" size="24" /> </span>
{link.label}
</a>
{/each}
</div>

View File

@@ -52,6 +52,7 @@ export enum AppRoute {
UTILITIES = '/utilities',
DUPLICATES = '/utilities/duplicates',
LARGE_FILES = '/utilities/large-files',
GEOLOCATION = '/utilities/geolocation',
FOLDERS = '/folders',
TAGS = '/tags',

View File

@@ -187,6 +187,11 @@ export class MonthGroup {
thumbhash: bucketAssets.thumbhash[i],
people: null, // People are not included in the bucket assets
};
if (bucketAssets.latitude?.[i] && bucketAssets.longitude?.[i]) {
timelineAsset.latitude = bucketAssets.latitude?.[i];
timelineAsset.longitude = bucketAssets.longitude?.[i];
}
this.addTimelineAsset(timelineAsset, addContext);
}

View File

@@ -419,14 +419,22 @@ export class TimelineManager {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
if (monthGroup) {
return monthGroup;
}
const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id }));
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
if (!response) {
return;
}
const asset = toTimelineAsset(response);
if (!asset || this.isExcluded(asset)) {
return;
}
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) {
return monthGroup;

View File

@@ -31,6 +31,8 @@ export type TimelineAsset = {
city: string | null;
country: string | null;
people: string[] | null;
latitude?: number | null;
longitude?: number | null;
};
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };

View File

@@ -1,11 +1,16 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
class UploadManager {
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
constructor() {
eventManager.on('app.init', () => void this.#loadExtensions());
eventManager.on('app.init', () => void this.#loadExtensions()).on('auth.logout', () => void this.reset());
}
reset() {
uploadAssetsStore.reset();
}
async #loadExtensions() {

View File

@@ -6,8 +6,9 @@
isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiKeyboardReturn } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
@@ -21,7 +22,7 @@
interface Props {
shared: boolean;
onClose: (album?: AlbumResponseDto) => void;
onClose: (albums?: AlbumResponseDto[]) => void;
}
let { shared, onClose }: Props = $props();
@@ -32,13 +33,54 @@
loading = false;
});
const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
const albumModalRows = $derived(rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex));
const albumModalRows = $derived(
rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds),
);
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
const onNewAlbum = async (name: string) => {
const album = await createAlbum({ createAlbumDto: { albumName: name } });
onClose(album);
onClose([album]);
};
const handleAlbumClick = (album?: AlbumResponseDto) => {
if (multiSelectActive) {
handleMultiSelect(album);
return;
}
if (album) {
onClose([album]);
return;
}
onClose();
};
const handleMultiSelect = (album?: AlbumResponseDto) => {
const selectedAlbum = album ?? albumModalRows.find(({ selected }) => selected)?.album;
if (!selectedAlbum) {
return;
}
const index = multiSelectedAlbumIds.indexOf(selectedAlbum.id);
if (index === -1) {
multiSelectedAlbumIds.push(selectedAlbum.id);
return;
}
multiSelectedAlbumIds.splice(index, 1);
};
const handleMultiSubmit = () => {
const selectedAlbums = new Set(albums.filter(({ id }) => multiSelectedAlbumIds.includes(id)));
if (selectedAlbums.size > 0) {
onClose([...selectedAlbums]);
} else {
onClose();
}
};
const onEnter = async () => {
@@ -53,8 +95,12 @@
break;
}
case AlbumModalRowType.ALBUM_ITEM: {
if (multiSelectActive) {
handleMultiSubmit();
break;
}
if (item.album) {
onClose(item.album);
onClose([item.album]);
}
break;
}
@@ -88,6 +134,11 @@
await onEnter();
break;
}
case 'Control': {
e.preventDefault();
handleMultiSelect();
break;
}
default: {
selectedRowIndex = -1;
}
@@ -133,13 +184,38 @@
<AlbumListItem
album={row.album}
selected={row.selected || false}
multiSelected={row.multiSelected}
searchQuery={search}
onAlbumClick={() => onClose(row.album)}
onAlbumClick={() => handleAlbumClick(row.album)}
onMultiSelect={() => handleMultiSelect(row.album)}
/>
{/if}
{/each}
</div>
{/if}
</div>
{#if multiSelectActive}
<Button size="small" shape="round" fullWidth onclick={handleMultiSubmit}
>{$t('add_to_albums_count', { values: { count: multiSelectedAlbumIds.length } })}</Button
>
{/if}
</ModalBody>
<ModalFooter>
<div class="flex justify-around w-full">
<div class="flex gap-4">
<div class="flex gap-1 place-items-center">
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
<Icon icon={mdiKeyboardReturn} size="1rem" />
</span>
<Text size="tiny">{$t('to_select')}</Text>
</div>
<div class="flex gap-1 place-items-center">
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
<Text size="tiny">CTRL</Text>
</span>
<Text size="tiny">{$t('to_multi_select')}</Text>
</div>
</div>
</div>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
location: { latitude: number | undefined; longitude: number | undefined };
assetCount: number;
onClose: (confirm?: true) => void;
}
let { location, assetCount, onClose }: Props = $props();
</script>
<Modal title={$t('confirm')} size="small" {onClose}>
<ModalBody>
<p>
{$t('update_location_action_prompt', {
values: {
count: assetCount,
},
})}
</p>
<p>- {$t('latitude')}: {location.latitude}</p>
<p>- {$t('longitude')}: {location.longitude}</p>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={() => onClose(true)}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -64,8 +64,16 @@
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
}
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
let filter: SearchFilter = $state({
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
query,
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:

View File

@@ -122,7 +122,7 @@
</Field>
<Field label={$t('admin.quota_size_gib')}>
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
{#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if}

View File

@@ -83,6 +83,7 @@
name="quotaSize"
placeholder={$t('unlimited')}
type="number"
step="1"
min="0"
bind:value={quotaSize}
/>

View File

@@ -36,6 +36,12 @@ interface DownloadRequestOptions<T = unknown> {
onDownloadProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
}
interface DateFormatter {
formatDate: (date: Date) => string;
formatTime: (date: Date) => string;
formatDateTime: (date: Date) => string;
}
export const initLanguage = async () => {
const preferenceLang = get(lang);
for (const { code, loader } of langs) {
@@ -68,6 +74,10 @@ class ApiError extends Error {
}
}
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
const { onUploadProgress: onProgress, data, url } = options;
@@ -343,3 +353,35 @@ export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T]
// eslint-disable-next-line unicorn/prefer-code-point
export const decodeBase64 = (data: string) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
export function createDateFormatter(localeCode: string | undefined): DateFormatter {
return {
formatDate: (date: Date): string =>
date.toLocaleString(localeCode, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}),
formatTime: (date: Date): string =>
date.toLocaleString(localeCode, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
formatDateTime: (date: Date): string => {
const formattedDate = date.toLocaleString(localeCode, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const formattedTime = date.toLocaleString(localeCode, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return `${formattedDate} ${formattedTime}`;
},
};
}

View File

@@ -9,14 +9,16 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, withError } from '$lib/utils';
import { downloadRequest, sleep, withError } from '$lib/utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import {
addAssetsToAlbum as addAssets,
addAssetsToAlbums as addToAlbums,
AssetVisibility,
BulkIdErrorReason,
bulkTagAssets,
createStack,
deleteAssets,
@@ -32,6 +34,7 @@ import {
type AssetResponseDto,
type AssetTypeEnum,
type DownloadInfoDto,
type ExifResponseDto,
type StackResponseDto,
type UserPreferencesResponseDto,
type UserResponseDto,
@@ -74,6 +77,52 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
}
};
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => {
const result = await addToAlbums({
...authManager.params,
albumsAddAssetsDto: {
albumIds,
assetIds,
},
});
if (!showNotification) {
return result;
}
if (showNotification) {
const $t = get(t);
if (result.error === BulkIdErrorReason.Duplicate) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }),
});
return result;
}
if (result.error) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }),
});
return result;
}
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_added_to_albums_count', {
values: {
albumTotal: albumIds.length,
assetTotal: assetIds.length,
},
}),
});
return result;
}
};
export const tagAssets = async ({
assetIds,
tagIds,
@@ -230,7 +279,12 @@ export const downloadFile = async (asset: AssetResponseDto) => {
const queryParams = asQueryString(authManager.params);
for (const { filename, id } of assets) {
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
}
try {
notificationController.show({
type: NotificationType.Info,
@@ -275,6 +329,15 @@ export function isFlipped(orientation?: string | null) {
return value && (isRotated270CW(value) || isRotated90CW(value));
}
export const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
if (isFlipped(exifInfo.orientation)) {
return { width: height, height: width };
}
return { width, height };
};
export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string {
const size = asset.exifInfo?.fileSizeInByte || 0;
return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data';

View File

@@ -27,7 +27,7 @@ export class GCastDestination implements ICastDestination {
async initialize(): Promise<boolean> {
const preferencesStore = get(preferences);
if (!preferencesStore.cast.gCastEnabled) {
if (!preferencesStore || !preferencesStore.cast.gCastEnabled) {
this.isAvailable = false;
return false;
}

View File

@@ -11,15 +11,41 @@ describe('converting time to seconds', () => {
});
it('parses h:m:s.S correctly', () => {
expect(timeToSeconds('1:2:3.4')).toBeCloseTo(3723.4);
expect(timeToSeconds('1:2:3.4')).toBe(0); // Non-standard format, Luxon returns NaN
});
it('parses hhh:mm:ss.SSS correctly', () => {
expect(timeToSeconds('100:02:03.456')).toBeCloseTo(360_123.456);
expect(timeToSeconds('100:02:03.456')).toBe(0); // Non-standard format, Luxon returns NaN
});
it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => {
expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456);
expect(timeToSeconds('01:02:03.456.123456')).toBe(0); // Non-standard format, Luxon returns NaN
});
// Test edge cases that can cause crashes
it('handles "0" string input', () => {
expect(timeToSeconds('0')).toBe(0);
});
it('handles empty string input', () => {
expect(timeToSeconds('')).toBe(0);
});
it('parses HH:MM format correctly', () => {
expect(timeToSeconds('01:02')).toBe(3720); // 1 hour 2 minutes = 3720 seconds
});
it('handles malformed time strings', () => {
expect(timeToSeconds('invalid')).toBe(0);
});
it('parses single hour format correctly', () => {
expect(timeToSeconds('01')).toBe(3600); // Luxon interprets "01" as 1 hour
});
it('handles time strings with invalid numbers', () => {
expect(timeToSeconds('aa:bb:cc')).toBe(0);
expect(timeToSeconds('01:bb:03')).toBe(0);
});
});

View File

@@ -7,14 +7,14 @@ import { get } from 'svelte/store';
* Convert time like `01:02:03.456` to seconds.
*/
export function timeToSeconds(time: string) {
const parts = time.split(':');
parts[2] = parts[2].split('.').slice(0, 2).join('.');
if (!time || time === '0') {
return 0;
}
const [hours, minutes, seconds] = parts.map(Number);
const seconds = Duration.fromISOTime(time).as('seconds');
return Duration.fromObject({ hours, minutes, seconds }).as('seconds');
return Number.isNaN(seconds) ? 0 : seconds;
}
export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
}

View File

@@ -2,6 +2,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { uploadManager } from '$lib/managers/upload-manager.svelte';
import { UploadState } from '$lib/models/upload-asset';
import { uploadAssetsStore } from '$lib/stores/upload';
import { user } from '$lib/stores/user.store';
import { uploadRequest } from '$lib/utils';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { ExecutorQueue } from '$lib/utils/executor-queue';
@@ -231,6 +232,11 @@ async function fileUploader({
return responseData.id;
} catch (error) {
// ignore errors if the user logs out during uploads
if (!get(user)) {
return;
}
const errorMessage = handleError(error, $t('errors.unable_to_upload_file'));
uploadAssetsStore.track('error');
uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: errorMessage });

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { locale } from '$lib/stores/preferences.store';
import { parseUtcDate } from '$lib/utils/date-time';
import { formatGroupTitle } from '$lib/utils/timeline-util';
import { formatGroupTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { DateTime } from 'luxon';
describe('formatGroupTitle', () => {
@@ -77,3 +77,13 @@ describe('formatGroupTitle', () => {
expect(formatGroupTitle(date)).toBe('Invalid DateTime');
});
});
describe('toISOYearMonthUTC', () => {
it('should prefix year with 0s', () => {
expect(toISOYearMonthUTC({ year: 28, month: 1 })).toBe('0028-01-01T00:00:00.000Z');
});
it('should prefix month with 0s', () => {
expect(toISOYearMonthUTC({ year: 2025, month: 1 })).toBe('2025-01-01T00:00:00.000Z');
});
});

View File

@@ -94,8 +94,11 @@ export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelineYearMonth)
{ zone: 'local', locale: get(locale) },
) as DateTime<true>;
export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string =>
`${year}-${month.toString().padStart(2, '0')}-01T00:00:00.000Z`;
export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string => {
const yearFull = `${year}`.padStart(4, '0');
const monthFull = `${month}`.padStart(2, '0');
return `${yearFull}-${monthFull}-01T00:00:00.000Z`;
};
export function formatMonthGroupTitle(_date: DateTime): string {
if (!_date.isValid) {
@@ -190,6 +193,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
city: city || null,
country: country || null,
people,
latitude: assetResponse.exifInfo?.latitude || null,
longitude: assetResponse.exifInfo?.longitude || null,
};
};

View File

@@ -75,7 +75,7 @@
mdiCogOutline,
mdiDeleteOutline,
mdiDotsVertical,
mdiFolderDownloadOutline,
mdiDownload,
mdiImageOutline,
mdiImagePlusOutline,
mdiLink,
@@ -403,6 +403,7 @@
const handleShareLink = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
if (sharedLink) {
await refreshAlbum();
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
};
@@ -411,7 +412,7 @@
const changed = await modalManager.show(AlbumUsersModal, { album });
if (changed) {
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
await refreshAlbum();
}
};
@@ -664,7 +665,7 @@
color="secondary"
aria-label={$t('download')}
onclick={handleDownloadAlbum}
icon={mdiFolderDownloadOutline}
icon={mdiDownload}
/>
{/if}

View File

@@ -52,7 +52,7 @@
draggable="false">{$t('view_all')}</a
>
</div>
<SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#snippet children({ itemCount })}
{#each people.slice(0, itemCount) as person (person.id)}
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative">
@@ -86,7 +86,7 @@
draggable="false">{$t('view_all')}</a
>
</div>
<SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4">
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4">
{#snippet children({ itemCount })}
{#each places.slice(0, itemCount) as item (item.data.id)}
<a

View File

@@ -68,7 +68,7 @@
const assetInteraction = new AssetInteraction();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
@@ -164,7 +164,7 @@
try {
const { albums, assets } =
'query' in searchDto && smartSearchEnabled
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
@@ -210,6 +210,7 @@
tagIds: $t('tags'),
originalFileName: $t('file_name'),
description: $t('description'),
queryAssetId: $t('query_asset_id'),
};
return keyMap[key] || key;
}

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { AppRoute } from '$lib/constants';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { stackAssets } from '$lib/utils/asset-utils';
@@ -15,7 +20,16 @@
import type { AssetResponseDto } from '@immich/sdk';
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js';
import {
mdiCheckOutline,
mdiChevronLeft,
mdiChevronRight,
mdiInformationOutline,
mdiKeyboard,
mdiPageFirst,
mdiPageLast,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -47,6 +61,20 @@
};
let duplicates = $state(data.duplicates);
const { isViewing: showAssetViewer } = assetViewingStore;
const correctDuplicatesIndex = (index: number) => {
return Math.max(0, Math.min(index, duplicates.length - 1));
};
let duplicatesIndex = $derived(
(() => {
const indexParam = page.url.searchParams.get('index') ?? '0';
const parsedIndex = Number.parseInt(indexParam, 10);
return correctDuplicatesIndex(Number.isNaN(parsedIndex) ? 0 : parsedIndex);
})(),
);
let hasDuplicates = $derived(duplicates.length > 0);
const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
if (prompt && confirmText) {
@@ -85,6 +113,7 @@
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
deletedNotification(trashIds.length);
await correctDuplicatesIndexAndGo(duplicatesIndex);
},
trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined,
trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined,
@@ -96,6 +125,7 @@
const duplicateAssetIds = assets.map((asset) => asset.id);
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
await correctDuplicatesIndexAndGo(duplicatesIndex);
};
const handleDeduplicateAll = async () => {
@@ -126,6 +156,9 @@
duplicates = [];
deletedNotification(idsToDelete.length);
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
},
prompt,
confirmText,
@@ -144,13 +177,51 @@
message: $t('resolved_all_duplicates'),
type: NotificationType.Info,
});
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
},
$t('bulk_keep_duplicates_confirmation', { values: { count: ids.length } }),
$t('confirm'),
);
};
const handleFirst = async () => {
await correctDuplicatesIndexAndGo(0);
};
const handlePrevious = async () => {
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
};
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => {
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
};
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = async () => {
await correctDuplicatesIndexAndGo(duplicates.length - 1);
};
const correctDuplicatesIndexAndGo = async (index: number) => {
page.url.searchParams.set('index', correctDuplicatesIndex(index).toString());
await goto(`${AppRoute.DUPLICATES}?${page.url.searchParams.toString()}`);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
{#snippet buttons()}
<HStack gap={0}>
@@ -203,13 +274,62 @@
/>
</div>
{#key duplicates[0].duplicateId}
{#key duplicates[duplicatesIndex].duplicateId}
<DuplicatesCompareControl
assets={duplicates[0].assets}
assets={duplicates[duplicatesIndex].assets}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}
handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)}
/>
<div class="max-w-216 mx-auto mb-16">
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between items-center">
<div class="flex text-xs text-black">
<Button
size="small"
leadingIcon={mdiPageFirst}
color="primary"
class="flex place-items-center rounded-s-full gap-2 px-2 sm:px-4"
onclick={handleFirst}
disabled={duplicatesIndex === 0}
>
{$t('first')}
</Button>
<Button
size="small"
leadingIcon={mdiChevronLeft}
color="primary"
class="flex place-items-center rounded-e-full gap-2 px-2 sm:px-4"
onclick={handlePrevious}
disabled={duplicatesIndex === 0}
>
{$t('previous')}
</Button>
</div>
<p>{duplicatesIndex + 1}/{duplicates.length.toLocaleString($locale)}</p>
<div class="flex text-xs text-black">
<Button
size="small"
trailingIcon={mdiChevronRight}
color="primary"
class="flex place-items-center rounded-s-full gap-2 px-2 sm:px-4"
onclick={handleNext}
disabled={duplicatesIndex === duplicates.length - 1}
>
{$t('next')}
</Button>
<Button
size="small"
trailingIcon={mdiPageLast}
color="primary"
class="flex place-items-center rounded-e-full gap-2 px-2 sm:px-4"
onclick={handleLast}
disabled={duplicatesIndex === duplicates.length - 1}
>
{$t('last')}
</Button>
</div>
</div>
</div>
{/key}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
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 GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { setQueryValue } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, updateAssets } from '@immich/sdk';
import { Button, LoadingSpinner, modalManager, Text } from '@immich/ui';
import { mdiMapMarkerMultipleOutline, mdiPencilOutline, mdiSelectRemove } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface 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 locationUpdated = $state(false);
const timelineManager = new TimelineManager();
void timelineManager.updateOptions({
visibility: AssetVisibility.Timeline,
withStacked: true,
withPartners: true,
withCoordinates: true,
});
const handleUpdate = async () => {
const confirmed = await modalManager.show(GeolocationUpdateConfirmModal, {
location: location ?? { latitude: 0, longitude: 0 },
assetCount: assetInteraction.selectedAssets.length,
});
if (!confirmed) {
return;
}
await updateAssets({
assetBulkUpdateDto: {
ids: assetInteraction.selectedAssets.map((asset) => asset.id),
latitude: location?.latitude ?? undefined,
longitude: location?.longitude ?? undefined,
},
});
const updatedAssets = await Promise.all(
assetInteraction.selectedAssets.map(async (asset) => {
const updatedAsset = await getAssetInfo({ ...authManager.params, id: asset.id });
return toTimelineAsset(updatedAsset);
}),
);
timelineManager.updateAssets(updatedAssets);
handleDeselectAll();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
}
if (event.key === 'Escape' && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
}
};
const handleDeselectAll = () => {
cancelMultiselect(assetInteraction);
};
const handlePickOnMap = async () => {
const point = await modalManager.show(ChangeLocation, {
point: {
lat: location.latitude,
lng: location.longitude,
},
});
if (!point) {
return;
}
location = { latitude: point.lat, longitude: point.lng };
};
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
return;
}
};
const hasGps = (asset: TimelineAsset) => {
return !!asset.latitude && !!asset.longitude;
};
const handleThumbnailClick = (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {
locationUpdated = false;
}, 1500);
location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<UserPageLayout title={data.meta.title} scrollbar={true}>
{#snippet buttons()}
<div class="flex gap-2 justify-end place-items-center">
<Text class="hidden md:block text-xs mr-4 text-dark/50">{$t('geolocation_instruction_location')}</Text>
<div class="border flex place-items-center place-content-center px-2 py-1 bg-primary/10 rounded-2xl">
<Text class="hidden md:inline-block text-xs text-gray-500 font-mono mr-5 ml-2 uppercase">
{$t('selected_gps_coordinates')}
</Text>
<Text
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
>
</div>
<Button size="small" color="secondary" variant="ghost" leadingIcon={mdiPencilOutline} onclick={handlePickOnMap}>
<Text class="hidden sm:inline-block">{$t('location_picker_choose_on_map')}</Text>
</Button>
<Button
leadingIcon={mdiSelectRemove}
size="small"
color="secondary"
variant="ghost"
disabled={!assetInteraction.selectionActive}
onclick={handleDeselectAll}
>
{$t('unselect_all')}
</Button>
<Button
leadingIcon={mdiMapMarkerMultipleOutline}
size="small"
color="primary"
disabled={assetInteraction.selectedAssets.length === 0}
onclick={() => handleUpdate()}
>
<Text class="hidden sm:inline-block">
{$t('apply_count', { values: { count: assetInteraction.selectedAssets.length } })}
</Text>
</Button>
</div>
{/snippet}
{#if isLoading}
<div class="h-full w-full flex items-center justify-center">
<LoadingSpinner size="giant" />
</div>
{/if}
<AssetGrid
isSelectionMode={true}
enableRouting={true}
{timelineManager}
{assetInteraction}
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
onThumbnailClick={handleThumbnailClick}
>
{#snippet customLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')}
</div>
{:else}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-danger text-light">
{$t('gps_missing')}
</div>
{/if}
{/snippet}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => {}} />
{/snippet}
</AssetGrid>
</UserPageLayout>

View File

@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const $t = await getFormatter();
return {
meta: {
title: $t('manage_geolocation'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,8 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (({ params }) => {
const photoId = params.photoId;
return redirect(302, `${AppRoute.PHOTOS}/${photoId}`);
}) satisfies PageLoad;

View File

@@ -36,6 +36,8 @@
close: $t('close'),
show_password: $t('show_password'),
hide_password: $t('hide_password'),
confirm: $t('confirm'),
cancel: $t('cancel'),
});
});

View File

@@ -4,9 +4,16 @@
import { AppRoute } from '$lib/constants';
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
import { asyncTimeout } from '$lib/utils';
import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import {
getAllJobsStatus,
JobCommand,
sendJobCommand,
type AllJobStatusResponseDto,
type JobName,
} from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiCog, mdiPlus } from '@mdi/js';
import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -21,6 +28,24 @@
let running = true;
const pausedJobs = $derived(
Object.entries(jobs ?? {})
.filter(([_, jobStatus]) => jobStatus.queueStatus?.isPaused)
.map(([jobName]) => jobName as JobName),
);
const handleResumePausedJobs = async () => {
try {
for (const jobName of pausedJobs) {
await sendJobCommand({ id: jobName, jobCommandDto: { command: JobCommand.Resume, force: false } });
}
// Refresh jobs status immediately after resuming
jobs = await getAllJobsStatus();
} catch (error) {
handleError(error, $t('admin.failed_job_command', { values: { command: 'resume', job: 'paused jobs' } }));
}
};
onMount(async () => {
while (running) {
jobs = await getAllJobsStatus();
@@ -36,6 +61,19 @@
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<HStack gap={0}>
{#if pausedJobs.length > 0}
<Button
leadingIcon={mdiPlay}
onclick={handleResumePausedJobs}
size="small"
variant="ghost"
title={pausedJobs.join(', ')}
>
<Text class="hidden md:block">
{$t('resume_paused_jobs', { values: { count: pausedJobs.length } })}
</Text>
</Button>
{/if}
<Button
leadingIcon={mdiPlus}
onclick={() => modalManager.show(JobCreateModal, {})}

View File

@@ -13,6 +13,7 @@
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { user as authUser } from '$lib/stores/user.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin } from '@immich/sdk';
@@ -70,6 +71,12 @@
let canResetPassword = $derived($authUser.id !== user.id);
let newPassword = $state<string>('');
let editedLocale = $derived(findLocale($locale).code);
let createAtDate: Date = $derived(new Date(user.createdAt));
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
const handleEdit = async () => {
const result = await modalManager.show(UserEditModal, { user: { ...user } });
if (result) {
@@ -266,11 +273,11 @@
</div>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{user.createdAt}</Text>
<Text>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{user.updatedAt}</Text>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>

View File

@@ -30,8 +30,8 @@
eventManager.emit('auth.login', user);
};
const onFirstLogin = async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD);
const onOnboarding = async () => await goto(AppRoute.AUTH_ONBOARDING);
const onFirstLogin = () => goto(AppRoute.AUTH_CHANGE_PASSWORD);
const onOnboarding = () => goto(AppRoute.AUTH_ONBOARDING);
onMount(async () => {
if (!$featureFlags.oauth) {
@@ -54,6 +54,7 @@
console.error('Error [login-form] [oauth.callback]', error);
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
return;
}
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte';
@@ -8,13 +9,12 @@
import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
import { mdiCloudUpload, mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
import { mdiCloudCheckOutline, mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -74,7 +74,7 @@
component: OnboardingBackup,
role: OnboardingRole.SERVER,
title: $t('admin.backup_onboarding_title'),
icon: mdiCloudUpload,
icon: mdiCloudCheckOutline,
},
]);

View File

@@ -13,6 +13,7 @@
let password = $state('');
let confirmPassword = $state('');
let name = $state('');
let loading = $state(false);
let errorMessage = $derived(
password === confirmPassword || confirmPassword.length === 0 ? '' : $t('password_does_not_match'),
);
@@ -27,10 +28,11 @@
const onSubmit = async (event: Event) => {
event.preventDefault();
if (!valid) {
if (!valid || loading) {
return;
}
loading = true;
errorMessage = '';
try {
@@ -40,6 +42,8 @@
} catch (error) {
handleError(error, $t('errors.unable_to_create_admin_account'));
errorMessage = $t('errors.unable_to_create_admin_account');
} finally {
loading = false;
}
};
</script>
@@ -70,6 +74,8 @@
<Alert color="danger" title={errorMessage} size="medium" class="mt-4" />
{/if}
<Button class="mt-4" type="submit" size="giant" shape="round" fullWidth disabled={!valid}>{$t('sign_up')}</Button>
<Button class="mt-4" type="submit" size="giant" shape="round" fullWidth disabled={!valid || loading} {loading}
>{$t('sign_up')}</Button
>
</form>
</AuthPageLayout>

View File

@@ -1,4 +1,4 @@
import { cancelLoad, getCachedOrFetch } from './fetch-event';
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
@@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => {
if (!event.data) {
return;
}
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
if (event.data.type === 'cancel') {
cancelLoad(url.toString());
} else if (event.data.type === 'preload') {
getCachedOrFetch(url);
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};

View File

@@ -1,104 +1,42 @@
import { build, files, version } from '$service-worker';
import { version } from '$service-worker';
const useCache = true;
const CACHE = `cache-${version}`;
export const APP_RESOURCES = [
...build, // the app itself
...files, // everything in `static`
];
let cache: Cache | undefined;
export async function getCache() {
if (cache) {
return cache;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
cache = await caches.open(CACHE);
return cache;
}
_cache = await caches.open(CACHE);
return _cache;
};
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}
export async function deleteOldCaches() {
return cache.match(key);
};
export const put = async (key: string, response: Response) => {
if (response.status !== 200) {
return;
}
const cache = await getCache();
if (!cache) {
return;
}
cache.put(key, response.clone());
};
export const prune = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
const pendingRequests = new Map<string, AbortController>();
const canceledRequests = new Set<string>();
export async function cancelLoad(urlString: string) {
const pending = pendingRequests.get(urlString);
if (pending) {
canceledRequests.add(urlString);
pending.abort();
pendingRequests.delete(urlString);
}
}
export async function getCachedOrFetch(request: URL | Request | string) {
const response = await checkCache(request);
if (response) {
return response;
}
const urlString = getCacheKey(request);
const cancelToken = new AbortController();
try {
pendingRequests.set(urlString, cancelToken);
const response = await fetch(request, {
signal: cancelToken.signal,
});
checkResponse(response);
await setCached(response, urlString);
return response;
} catch (error) {
if (canceledRequests.has(urlString)) {
canceledRequests.delete(urlString);
return new Response(undefined, {
status: 499,
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
});
}
throw error;
} finally {
pendingRequests.delete(urlString);
}
}
export async function checkCache(url: URL | Request | string) {
if (!useCache) {
return;
}
const cache = await getCache();
return await cache.match(url);
}
export async function setCached(response: Response, cacheKey: URL | Request | string) {
if (cache && response.status === 200) {
const cache = await getCache();
cache.put(cacheKey, response.clone());
}
}
function checkResponse(response: Response) {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
}
export function getCacheKey(request: URL | Request | string) {
if (isURL(request)) {
return request.toString();
} else if (isRequest(request)) {
return request.url;
} else {
return request;
}
}
};

View File

@@ -1,110 +0,0 @@
import { version } from '$service-worker';
import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache';
const CACHE = `cache-${version}`;
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
export async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
const pendingLoads = new Map<string, AbortController>();
export async function cancelLoad(urlString: string) {
const pending = pendingLoads.get(urlString);
if (pending) {
pending.abort();
pendingLoads.delete(urlString);
}
}
export async function getCachedOrFetch(request: URL | Request | string) {
const response = await checkCache(request);
if (response) {
return response;
}
try {
return await fetchWithCancellation(request);
} catch {
return new Response(undefined, {
status: 499,
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
});
}
}
async function fetchWithCancellation(request: URL | Request | string) {
const cacheKey = getCacheKey(request);
const cancelToken = new AbortController();
try {
pendingLoads.set(cacheKey, cancelToken);
const response = await fetch(request, {
signal: cancelToken.signal,
});
checkResponse(response);
setCached(response, cacheKey);
return response;
} finally {
pendingLoads.delete(cacheKey);
}
}
function checkResponse(response: Response) {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
}
function isIgnoredFileType(pathname: string): boolean {
return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname);
}
function isIgnoredPath(pathname: string): boolean {
return /^\/(src|api)(\/.*)?$/.test(pathname) || /^\/(node_modules|@vite|@id)(\/.*)?$/.test(pathname);
}
function isAssetRequest(pathname: string): boolean {
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
}
export function handleFetchEvent(event: FetchEvent): void {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
// Only handle requests to the same origin
if (url.origin !== self.location.origin) {
return;
}
// Do not cache app resources
if (APP_RESOURCES.includes(url.pathname)) {
return;
}
// Cache requests for thumbnails
if (isAssetRequest(url.pathname)) {
event.respondWith(getCachedOrFetch(event.request));
return;
}
// Do not cache ignored file types or paths
if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) {
return;
}
// At this point, the only remaining requests for top level routes
// so serve the Svelte SPA fallback page
const slash = new URL('/', url.origin);
event.respondWith(getCachedOrFetch(slash));
}

View File

@@ -3,14 +3,16 @@
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { deleteOldCaches } from './cache';
import { handleFetchEvent } from './fetch-event';
import { prune } from './cache';
import { handleRequest } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(deleteOldCaches());
event.waitUntil(prune());
};
const handleInstall = (event: ExtendableEvent) => {
@@ -18,7 +20,20 @@ const handleInstall = (event: ExtendableEvent) => {
// do not preload app resources
};
const handleFetch = (event: FetchEvent): void => {
if (event.request.method !== 'GET') {
return;
}
// Cache requests for thumbnails
const url = new URL(event.request.url);
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
event.respondWith(handleRequest(event.request));
return;
}
};
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetchEvent, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();

View File

@@ -0,0 +1,73 @@
import { get, put } from './cache';
const pendingRequests = new Map<string, AbortController>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
const assertResponse = (response: Response) => {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
};
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
}
if (isRequest(request)) {
return request.url;
}
throw new Error(`Invalid request: ${request}`);
};
export const handlePreload = async (request: URL | Request) => {
try {
return await handleRequest(request);
} catch (error) {
console.error(`Preload failed: ${error}`);
}
};
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
try {
const cancelToken = new AbortController();
pendingRequests.set(cacheKey, cancelToken);
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
console.log('Not an abort error', error);
throw error;
} finally {
pendingRequests.delete(cacheKey);
}
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
pendingRequest.abort();
pendingRequests.delete(cacheKey);
};

View File

@@ -6,6 +6,7 @@ import { Sync } from 'factory.ts';
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
id: Sync.each(() => faker.string.uuid()),
createdAt: Sync.each(() => faker.date.past().toISOString()),
deviceAssetId: Sync.each(() => faker.string.uuid()),
ownerId: Sync.each(() => faker.string.uuid()),
deviceId: '',

View File

@@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import { defineConfig, type UserConfig } from 'vite';
const upstream = {
target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/',
@@ -59,4 +59,4 @@ export default defineConfig({
hooks: 'list',
},
},
});
} as UserConfig);