Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-02-24 17:08:35 +00:00
505 changed files with 24540 additions and 10979 deletions
+1 -1
View File
@@ -1 +1 @@
24.13.0
24.13.1
+20 -30
View File
@@ -1,56 +1,46 @@
[tasks.install]
run = "pnpm install --filter immich-web --frozen-lockfile"
[tasks."svelte-kit-sync"]
env._.path = "./node_modules/.bin"
run = "svelte-kit sync"
[tasks.build]
env._.path = "./node_modules/.bin"
run = "vite build"
run = "pnpm run build"
[tasks."build-stats"]
env.BUILD_STATS = "true"
env._.path = "./node_modules/.bin"
run = "vite build"
run = "pnpm run build:stats"
[tasks.preview]
env._.path = "./node_modules/.bin"
run = "vite preview"
run = "pnpm run preview"
[tasks.start]
env._.path = "./node_modules/.bin"
run = "vite dev --host 0.0.0.0 --port 3000"
depends = [":install", "//:sdk:install", "//:sdk:build"]
run = "pnpm run dev"
[tasks."start-demo"]
env.IMMICH_SERVER_URL = "https://demo.immich.app"
run = { task = ":start" }
[tasks.test]
depends = ["svelte-kit-sync"]
env._.path = "./node_modules/.bin"
run = "vitest"
run = "pnpm run test"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
run = "pnpm run format"
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
run = "pnpm run format:fix"
[tasks.lint]
env._.path = "./node_modules/.bin"
run = "eslint . --max-warnings 0 --concurrency 4"
run = "pnpm run lint"
[tasks."lint-fix"]
run = { task = "lint --fix" }
run = "pnpm run lint:fix"
[tasks.check]
depends = ["svelte-kit-sync"]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"
[tasks.check-typescript]
run = "pnpm run check:typescript"
[tasks."check-svelte"]
depends = ["svelte-kit-sync"]
env._.path = "./node_modules/.bin"
run = "svelte-check --no-tsconfig --fail-on-warnings"
run = "pnpm run check:svelte"
[tasks.check]
run = { tasks = [":check-typescript", ":check-svelte"] }
[tasks.checklist]
run = [
+8 -8
View File
@@ -26,9 +26,9 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.62.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.64.0",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
@@ -37,10 +37,10 @@
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/core": "^0.42.0",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"fabric": "^7.0.0",
"geo-coordinates-parser": "^1.7.4",
"geojson": "^0.5.0",
"handlebars": "^4.7.8",
@@ -70,7 +70,7 @@
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.9.0",
"@sveltejs/enhanced-img": "^0.10.0",
"@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.7",
@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.48.0",
"svelte": "5.51.5",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -108,6 +108,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.13.0"
"node": "24.13.1"
}
}
+4
View File
@@ -148,6 +148,10 @@
color: #3a3a3a;
}
body.asset-viewer-open {
background-color: black;
}
input:focus-visible {
outline-offset: 0px !important;
outline: none !important;
@@ -98,7 +98,7 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
const { destroy } = shortcuts(node, [
{ shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
{ shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
{ shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
{ shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event), preventDefault: false },
{ shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
{ shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
]);
+4 -2
View File
@@ -1,3 +1,5 @@
import { on } from 'svelte/events';
interface Options {
onFocusOut?: (event: FocusEvent) => void;
}
@@ -19,11 +21,11 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
}
};
node.addEventListener('focusout', handleFocusOut);
const off = on(node, 'focusout', handleFocusOut);
return {
destroy() {
node.removeEventListener('focusout', handleFocusOut);
off();
},
};
}
+9 -112
View File
@@ -1,112 +1,9 @@
import type { ActionReturn } from 'svelte/action';
export type Shortcut = {
key: string;
alt?: boolean;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
};
export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut;
/** If true, the event handler will not execute if the event comes from an input field */
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
};
export const shortcutLabel = (shortcut: Shortcut) => {
let label = '';
if (shortcut.ctrl) {
label += 'Ctrl ';
}
if (shortcut.alt) {
label += 'Alt ';
}
if (shortcut.meta) {
label += 'Cmd ';
}
if (shortcut.shift) {
label += '⇧';
}
label += shortcut.key.toUpperCase();
return label;
};
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
* - The element dispatching the event is a map canvas
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
}
const type = (event.target as HTMLInputElement).type;
return (
['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type) ||
(event.target instanceof HTMLCanvasElement && event.target.classList.contains('maplibregl-canvas'))
);
};
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
return (
shortcut.key.toLowerCase() === event.key.toLowerCase() &&
Boolean(shortcut.alt) === event.altKey &&
Boolean(shortcut.ctrl) === event.ctrlKey &&
Boolean(shortcut.shift) === event.shiftKey &&
Boolean(shortcut.meta) === event.metaKey
);
};
/** Bind a single keyboard shortcut to node. */
export const shortcut = <T extends HTMLElement>(
node: T,
option: ShortcutOptions<T>,
): ActionReturn<ShortcutOptions<T>> => {
const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]);
return {
update(newOption) {
shortcutsUpdate?.([newOption]);
},
destroy,
};
};
/** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>(
node: T,
options: ShortcutOptions<T>[],
): ActionReturn<ShortcutOptions<T>[]> => {
function onKeydown(event: KeyboardEvent) {
const ignoreShortcut = shouldIgnoreEvent(event);
for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) {
if (ignoreInputFields && ignoreShortcut) {
continue;
}
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
event.preventDefault();
}
onShortcut(event as KeyboardEvent & { currentTarget: T });
return;
}
}
}
node.addEventListener('keydown', onKeydown);
return {
update(newOptions) {
options = newOptions;
},
destroy() {
node.removeEventListener('keydown', onKeydown);
},
};
};
export {
matchesShortcut,
shortcut,
shortcutLabel,
shortcuts,
shouldIgnoreEvent,
type Shortcut,
type ShortcutOptions,
} from '@immich/ui';
@@ -1,15 +0,0 @@
<script lang="ts">
import { isEnabled } from '$lib/utils';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if icon && isEnabled(action)}
<IconButton variant="ghost" shape="round" {color} {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}
@@ -1,21 +1,16 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { Button, DatePicker, Field } from '@immich/ui';
import { locale } from '$lib/stores/preferences.store';
import { minBy, uniqBy } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
createdAt?: string;
expiresAt: string | null;
};
let { createdAt = DateTime.now().toISO(), expiresAt = $bindable() }: Props = $props();
let { expiresAt = $bindable() }: Props = $props();
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
[30, 'minutes'],
[1, 'hour'],
[6, 'hours'],
[1, 'day'],
[7, 'days'],
[30, 'days'],
@@ -26,50 +21,52 @@
const relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
const expiredDateOptions = $derived([
{ text: $t('never'), value: 0 },
...expirationOptions
.map(([value, unit]) => ({
text: relativeTime.format(value, unit),
value: Duration.fromObject({ [unit]: value }).toMillis(),
}))
.filter(({ value: millis }) => DateTime.fromISO(createdAt).plus(millis) > DateTime.now()),
...expirationOptions.map(([value, unit]) => ({
text: relativeTime.format(value, unit),
value: Duration.fromObject({ [unit]: value }).toMillis(),
})),
]);
const getExpirationOption = (createdAt: string, expiresAt: string | null) => {
if (!expiresAt) {
return expiredDateOptions[0];
}
let selectedPresetValue = $state<number | null>(null);
const delta = DateTime.fromISO(expiresAt).diff(DateTime.fromISO(createdAt)).toMillis();
const closestOption = minBy(expiredDateOptions, ({ value }) => Math.abs(delta - value));
if (!closestOption) {
return expiredDateOptions[0];
}
// allow a generous epsilon to compensate for potential API delays
if (Math.abs(closestOption.value - delta) > 10_000) {
const interval = DateTime.fromMillis(closestOption.value) as DateTime<true>;
return { text: interval.toRelative({ locale: $locale }), value: closestOption.value };
}
return closestOption;
const getSelectedDate = (): DateTime | undefined => {
return expiresAt ? DateTime.fromISO(expiresAt) : undefined;
};
const onSelect = (option: number | string) => {
const expirationOption = Number(option);
expiresAt = expirationOption === 0 ? null : DateTime.fromISO(createdAt).plus(expirationOption).toISO();
const setSelectedDate = (value: DateTime | undefined) => {
selectedPresetValue = null; // Clear preset when manually setting date
expiresAt = value ? value.toISO() : null;
};
let expirationOption = $derived(getExpirationOption(createdAt, expiresAt).value);
const selectPreset = (value: number) => {
selectedPresetValue = value;
if (value === 0) {
expiresAt = null;
return;
}
const newDate = DateTime.now().plus(value);
expiresAt = newDate.toISO();
};
const isSelected = (value: number) => {
return selectedPresetValue === value;
};
</script>
<div class="mt-2">
<SettingSelect
bind:value={expirationOption}
{onSelect}
options={uniqBy([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)], 'value')}
label={$t('expire_after')}
number={true}
/>
<Field label={$t('expire_after')}>
<DatePicker bind:value={getSelectedDate, setSelectedDate} />
</Field>
<div class="flex flex-wrap gap-2 mt-2">
{#each expiredDateOptions as option (option.value)}
<Button
size="tiny"
variant={isSelected(option.value) ? 'filled' : 'outline'}
onclick={() => selectPreset(option.value)}
>
{option.text}
</Button>
{/each}
</div>
</div>
@@ -1,4 +1,4 @@
import { render } from '@testing-library/svelte';
import { renderWithTooltips } from '$tests/helpers';
import userEvent from '@testing-library/user-event';
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
@@ -7,16 +7,14 @@ describe('SharedLinkFormFields component', () => {
element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true';
it('turns downloads off when metadata is disabled', async () => {
const { container } = render(SharedLinkFormFields, {
props: {
slug: '',
password: '',
description: '',
allowDownload: true,
allowUpload: false,
showMetadata: true,
expiresAt: null,
},
const { container } = renderWithTooltips(SharedLinkFormFields, {
slug: '',
password: '',
description: '',
allowDownload: true,
allowUpload: false,
showMetadata: true,
expiresAt: null,
});
const user = userEvent.setup();
@@ -11,7 +11,6 @@
allowUpload: boolean;
showMetadata: boolean;
expiresAt: string | null;
createdAt?: string;
};
let {
@@ -22,7 +21,6 @@
allowUpload = $bindable(),
showMetadata = $bindable(),
expiresAt = $bindable(),
createdAt,
}: Props = $props();
$effect(() => {
@@ -50,7 +48,7 @@
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration {createdAt} bind:expiresAt />
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
@@ -23,7 +23,7 @@
<CardHeader>
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
<Text color="primary"
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</Text
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}</Text
>
</CardHeader>
<CardBody>
@@ -1,9 +1,8 @@
<script lang="ts">
import ActionButton from '$lib/components/ActionButton.svelte';
import { getSharedLinkActions } from '$lib/services/shared-link.service';
import { locale } from '$lib/stores/preferences.store';
import type { AlbumResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { Text } from '@immich/ui';
import { ActionButton, Text } from '@immich/ui';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@@ -1,6 +1,5 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
@@ -19,7 +18,7 @@
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { IconButton, Logo } from '@immich/ui';
import { ActionButton, IconButton, Logo } from '@immich/ui';
import { mdiDownload, mdiFileImagePlusOutline, mdiPresentationPlay } from '@mdi/js';
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
@@ -1,6 +1,6 @@
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
@@ -8,11 +8,8 @@ type ActionMap = {
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.ADD]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
@@ -1,49 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
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, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
shared?: boolean;
}
let { asset, onAction, shared = false }: Props = $props();
const onClick = async () => {
const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!albums || albums.length === 0) {
return;
}
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>
<svelte:document use:shortcut={{ shortcut: { key: 'l', shift: shared }, onShortcut: onClick }} />
<MenuOption
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
{onClick}
/>
@@ -1,9 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ActionButton from '$lib/components/ActionButton.svelte';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
@@ -35,7 +33,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
import {
mdiArrowLeft,
mdiCompare,
@@ -92,54 +90,11 @@
shortcuts: [{ key: 'Escape' }],
});
const {
Share,
Download,
DownloadOriginal,
SharedLinkDownload,
Offline,
Favorite,
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Info,
Edit,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
} = $derived(getAssetActions($t, asset));
const Actions = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink();
</script>
<CommandPaletteDefaultProvider
name={$t('assets')}
actions={withoutIcons([
Close,
Cast,
Share,
Download,
DownloadOriginal,
SharedLinkDownload,
Offline,
Favorite,
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Info,
Edit,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
])}
/>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
@@ -150,23 +105,23 @@
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<ActionButton action={Cast} />
<ActionButton action={Share} />
<ActionButton action={Offline} />
<ActionButton action={PlayMotionPhoto} />
<ActionButton action={StopMotionPhoto} />
<ActionButton action={ZoomIn} />
<ActionButton action={ZoomOut} />
<ActionButton action={Copy} />
<ActionButton action={SharedLinkDownload} />
<ActionButton action={Info} />
<ActionButton action={Favorite} />
<ActionButton action={Unfavorite} />
<ActionButton action={Actions.Share} />
<ActionButton action={Actions.Offline} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.ZoomIn} />
<ActionButton action={Actions.ZoomOut} />
<ActionButton action={Actions.Copy} />
<ActionButton action={Actions.SharedLinkDownload} />
<ActionButton action={Actions.Info} />
<ActionButton action={Actions.Favorite} />
<ActionButton action={Actions.Unfavorite} />
{#if isOwner}
<RatingAction {asset} {onAction} />
{/if}
<ActionButton action={Edit} />
<ActionButton action={Actions.Edit} />
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
@@ -178,18 +133,15 @@
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
<ActionMenuItem action={Download} />
<ActionMenuItem action={DownloadOriginal} />
<ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} />
{#if !isLocked}
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{/if}
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
@@ -251,10 +203,10 @@
{/if}
{#if isOwner}
<hr />
<ActionMenuItem action={RefreshFacesJob} />
<ActionMenuItem action={RefreshMetadataJob} />
<ActionMenuItem action={RegenerateThumbnailJob} />
<ActionMenuItem action={TranscodeVideoJob} />
<ActionMenuItem action={Actions.RefreshFacesJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
<ActionMenuItem action={Actions.TranscodeVideoJob} />
{/if}
</ButtonContextMenu>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
@@ -147,6 +148,7 @@
};
onMount(async () => {
syncAssetViewerOpenClass(true);
unsubscribes.push(
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
@@ -165,9 +167,7 @@
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
await onAlbumAddAssets();
});
onDestroy(() => {
@@ -177,9 +177,10 @@
activityManager.reset();
assetViewerManager.closeEditor();
syncAssetViewerOpenClass(false);
});
const handleGetAllAlbums = async () => {
const onAlbumAddAssets = async () => {
if (authManager.isSharedLink) {
return;
}
@@ -300,10 +301,6 @@
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
break;
}
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
@@ -336,7 +333,6 @@
};
break;
}
case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: {
closeViewer();
break;
@@ -359,9 +355,15 @@
}
});
const syncAssetViewerOpenClass = (isOpen: boolean) => {
if (browser) {
document.body.classList.toggle('asset-viewer-open', isOpen);
}
};
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
await onAlbumAddAssets();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
@@ -389,7 +391,7 @@
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
cursor.current = update;
cursor = { ...cursor, current: update };
}
};
@@ -425,16 +427,21 @@
const showOcrButton = $derived(
$slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image &&
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!assetViewerManager.isShowEditor &&
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
const showDetailPanel = $derived(
asset.hasMetadata &&
$slideshowState === SlideshowState.None &&
assetViewerManager.isShowDetailPanel &&
!assetViewerManager.isShowEditor,
);
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<OnEvents {onAssetReplace} {onAssetUpdate} {onAlbumAddAssets} />
<svelte:document bind:fullscreenElement />
@@ -570,25 +577,22 @@
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
{#if showDetailPanel || assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{/if}
{#if assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="editor-panel"
class="row-start-1 row-span-4 w-100 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<EditorPanel {asset} onClose={closeEditor} />
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,65 @@
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import DetailPanelDescription from './detail-panel-description.svelte';
describe('DetailPanelDescription', () => {
it('clears unsaved draft on asset change', async () => {
const user = userEvent.setup();
const assetA = assetFactory.build({
id: 'asset-a',
exifInfo: { description: '' },
});
const assetB = assetFactory.build({
id: 'asset-b',
exifInfo: { description: '' },
});
const { rerender } = render(DetailPanelDescription, {
props: {
asset: assetA,
isOwner: true,
},
});
const textarea = screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement;
await user.type(textarea, 'unsaved draft');
expect(textarea).toHaveValue('unsaved draft');
await rerender({
asset: assetB,
isOwner: true,
});
expect(screen.getByTestId('autogrow-textarea')).toHaveValue('');
});
it('updates description on asset switch', async () => {
const assetA = assetFactory.build({
id: 'asset-a',
exifInfo: { description: 'first description' },
});
const assetB = assetFactory.build({
id: 'asset-b',
exifInfo: { description: 'second description' },
});
const { rerender } = render(DetailPanelDescription, {
props: {
asset: assetA,
isOwner: true,
},
});
expect(screen.getByTestId('autogrow-textarea')).toHaveValue('first description');
await rerender({
asset: assetB,
isOwner: true,
});
expect(screen.getByTestId('autogrow-textarea')).toHaveValue('second description');
});
});
@@ -13,10 +13,10 @@
let { asset, isOwner }: Props = $props();
let currentDescription = $derived(asset.exifInfo?.description ?? '');
let description = $derived(currentDescription);
let description = $derived(asset.exifInfo?.description ?? '');
const handleFocusOut = async () => {
const currentDescription = asset.exifInfo?.description ?? '';
if (description === currentDescription) {
return;
}
@@ -12,6 +12,8 @@
let { asset }: Props = $props();
const assetId = $derived(asset.id);
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
return URL.createObjectURL(data);
@@ -19,7 +21,7 @@
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
@@ -1,6 +1,6 @@
<script lang="ts">
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxDimensions } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix } from '$lib/utils/ocr-utils';
type Props = {
ocrBox: OcrBox;
@@ -8,28 +8,19 @@
let { ocrBox }: Props = $props();
const dimensions = $derived(calculateBoundingBoxDimensions(ocrBox.points));
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(
`translate(${dimensions.minX}px, ${dimensions.minY}px) rotate(${dimensions.rotation}deg) skew(${dimensions.skewX}deg, ${dimensions.skewY}deg)`,
);
const transformOrigin = $derived(
`${dimensions.centerX - dimensions.minX}px ${dimensions.centerY - dimensions.minY}px`,
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
// Fits almost all strings within the box, depends on font family
const fontSize = $derived(
`max(var(--text-sm), min(var(--text-6xl), ${(1.4 * dimensions.width) / ocrBox.text.length}px))`,
);
</script>
<div class="absolute group left-0 top-0 pointer-events-none">
<!-- Bounding box with CSS transforms -->
<div class="absolute left-0 top-0">
<div
class="absolute border-2 border-blue-500 bg-blue-500/10 cursor-pointer pointer-events-auto transition-all group-hover:bg-blue-500/30 group-hover:border-blue-600 group-hover:border-[3px]"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
></div>
<!-- Text overlay - always rendered but invisible, allows text selection and copy -->
<div
class="absolute flex items-center justify-center text-transparent text-sm px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text group-hover:text-white group-hover:bg-black/75 group-hover:z-10"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
>
{ocrBox.text}
</div>
@@ -2,8 +2,10 @@
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils';
import {
EquirectangularAdapter,
Viewer,
@@ -27,6 +29,17 @@
strokeLinejoin: 'round',
};
// Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3'
const OCR_BOX_SVG_STYLE = {
fill: 'var(--color-blue-500)',
fillOpacity: '0.1',
stroke: 'var(--color-blue-500)',
strokeWidth: '2px',
};
const OCR_TOOLTIP_HTML_CLASS =
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
type Props = {
panorama: string | { source: string };
originalPanorama?: string | { source: string };
@@ -96,6 +109,59 @@
}
});
$effect(() => {
updateOcrBoxes(ocrManager.showOverlay, ocrManager.data);
});
/** Use updateOnly=true on zoom, pan, or resize. */
const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => {
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
return;
}
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
if (!showOverlay) {
markersPlugin.clearMarkers();
return;
}
if (!updateOnly) {
markersPlugin.clearMarkers();
}
const boxes = getOcrBoundingBoxesAtSize(ocrData, {
width: viewer.state.textureData.panoData.croppedWidth,
height: viewer.state.textureData.panoData.croppedHeight,
});
for (const [index, box] of boxes.entries()) {
const points = box.points.map((p) => texturePointToViewerPoint(viewer, p));
const { matrix, width, height } = calculateBoundingBoxMatrix(points);
const fontSize = (1.4 * width) / box.text.length; // fits almost all strings within the box, depends on font family
const transform = `matrix3d(${matrix.join(',')})`;
const content = `<div class="${OCR_TOOLTIP_HTML_CLASS}" style="font-size: ${fontSize}px; width: ${width}px; height: ${height}px; transform: ${transform}; transform-origin: 0 0;">${box.text}</div>`;
if (updateOnly) {
markersPlugin.updateMarker({
id: `box_${index}`,
polygonPixels: box.points.map((b) => [b.x, b.y]),
tooltip: { content },
});
} else {
markersPlugin.addMarker({
id: `box_${index}`,
polygonPixels: box.points.map((b) => [b.x, b.y]),
svgStyle: OCR_BOX_SVG_STYLE,
tooltip: { content, trigger: 'click' },
});
}
}
};
const texturePointToViewerPoint = (viewer: Viewer, point: Point) => {
const spherical = viewer.dataHelper.textureCoordsToSphericalCoords({ textureX: point.x, textureY: point.y });
return viewer.dataHelper.sphericalCoordsToViewerCoords(spherical);
};
const onZoom = () => {
viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 });
};
@@ -160,7 +226,20 @@
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
}
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
const onReadyHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false);
const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true);
viewer.addEventListener(events.ReadyEvent.type, onReadyHandler);
viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler);
viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler);
viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true });
return () => {
viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler);
viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler);
viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler);
viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler);
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
};
});
onDestroy(() => {
@@ -176,3 +255,25 @@
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
<style>
/* Reset the default tooltip styling */
:global(.psv-tooltip) {
top: 0 !important;
left: 0 !important;
background: none;
box-shadow: none;
width: 0;
height: 0;
}
:global(.psv-tooltip-content) {
font: var(--font-normal);
padding: 0;
text-shadow: none;
}
:global(.psv-tooltip-arrow) {
display: none;
}
</style>
@@ -57,7 +57,10 @@
$effect.pre(() => {
void asset.id;
untrack(() => assetViewerManager.resetZoomState());
untrack(() => {
assetViewerManager.resetZoomState();
$boundingBoxesArray = [];
});
});
onDestroy(() => {
@@ -223,6 +223,7 @@
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
data-selected={selected ? true : undefined}
tabindex={0}
role="link"
>
@@ -10,7 +10,6 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -25,6 +24,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
@@ -34,7 +34,7 @@
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { ActionButton, IconButton, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown,
@@ -48,7 +48,6 @@
mdiImageSearch,
mdiPause,
mdiPlay,
mdiPlus,
mdiSelectAll,
mdiVolumeHigh,
mdiVolumeOff,
@@ -68,7 +67,8 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived([
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
let viewerAssets = $derived([
...(current?.previousMemory?.assets ?? []),
...(current?.memory.assets ?? []),
...(current?.nextMemory?.assets ?? []),
@@ -328,6 +328,7 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CreateSharedLink />
<IconButton
shape="round"
@@ -338,10 +339,7 @@
onclick={handleSelectAll}
/>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
@@ -657,6 +655,7 @@
>
<GalleryViewer
assets={currentTimelineAssets}
{viewerAssets}
viewport={galleryViewport}
{assetInteraction}
slidingWindowOffset={viewerHeight}
@@ -8,7 +8,7 @@
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Logo, PasswordInput } from '@immich/ui';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
@@ -39,7 +39,7 @@
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key, slug });
sharedLink = await sharedLinkLogin({ key, slug, sharedLinkLoginDto: { password } });
setSharedLink(sharedLink);
passwordRequired = false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
@@ -28,15 +28,15 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
});
describe('Album Modal', () => {
it('non-shared with no albums configured yet shows message and new', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
it('no albums configured yet shows message and new', () => {
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
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);
it('no matching albums shows message and new', () => {
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const modalRows = converter.toModalRows(
'matches_nothing',
[],
@@ -48,8 +48,8 @@ describe('Album Modal', () => {
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
});
it('non-shared displays single albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
it('displays single albums', () => {
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
@@ -60,8 +60,8 @@ describe('Album Modal', () => {
]);
});
it('non-shared displays multiple albums and recents', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
it('displays multiple albums and recents', () => {
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' });
@@ -87,31 +87,8 @@ describe('Album Modal', () => {
]);
});
it('shared only displays albums and no recents', () => {
const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' });
const christmasAlbum = albumFactory.build({ albumName: 'Christmas' });
const modalRows = converter.toModalRows(
'',
[holidayAlbum, constructionAlbum],
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
-1,
[],
);
expect(modalRows).toStrictEqual([
createNewAlbumRow(false),
createAlbumRow(holidayAlbum, false),
createAlbumRow(constructionAlbum, false),
createAlbumRow(birthdayAlbum, false),
createAlbumRow(christmasAlbum, false),
]);
});
it('search changes messaging and removes recent and non-matching albums', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' });
@@ -132,7 +109,7 @@ describe('Album Modal', () => {
});
it('selection can select new album row', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
@@ -148,7 +125,7 @@ describe('Album Modal', () => {
});
it('selection can select recent row', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
@@ -164,7 +141,7 @@ describe('Album Modal', () => {
});
it('selection can select last row', () => {
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
@@ -27,12 +27,10 @@ export const isSelectableRowType = (type: AlbumModalRowType) =>
const $t = get(t);
export class AlbumModalRowConverter {
private readonly shared: boolean;
private readonly sortBy: string;
private readonly orderBy: string;
constructor(shared: boolean, sortBy: string, orderBy: string) {
this.shared = shared;
constructor(sortBy: string, orderBy: string) {
this.sortBy = sortBy;
this.orderBy = orderBy;
}
@@ -44,8 +42,8 @@ export class AlbumModalRowConverter {
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 : [];
// only show recent albums if no search was entered
const recentAlbumsToShow = search.length === 0 ? recentAlbums : [];
const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }];
const filteredAlbums = sortAlbums(
@@ -71,12 +69,10 @@ export class AlbumModalRowConverter {
}
}
if (!this.shared) {
rows.push({
type: AlbumModalRowType.SECTION,
text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(),
});
}
rows.push({
type: AlbumModalRowType.SECTION,
text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(),
});
const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length;
for (const [i, album] of filteredAlbums.entries()) {
@@ -34,6 +34,7 @@
type Props = {
assets: AssetResponseDto[];
viewerAssets?: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
@@ -48,6 +49,7 @@
let {
assets = $bindable(),
viewerAssets,
assetInteraction,
disableAssetSelect = false,
showArchiveIcon = false,
@@ -61,6 +63,7 @@
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
const navigationAssets = $derived(viewerAssets ?? assets);
const geometry = $derived(
getJustifiedLayoutFromAssets(assets, {
@@ -282,12 +285,12 @@
);
const handleRandom = async (): Promise<{ id: string } | undefined> => {
if (assets.length === 0) {
if (navigationAssets.length === 0) {
return;
}
try {
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
const randomIndex = Math.floor(Math.random() * navigationAssets.length);
const asset = navigationAssets[randomIndex];
await navigateToAsset(asset);
return asset;
@@ -344,8 +347,8 @@
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
});
</script>
@@ -1,5 +1,5 @@
<script lang="ts" module>
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url';
import { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
import { Protocol } from 'pmtiles';
@@ -5,7 +5,6 @@
<script lang="ts">
import { page } from '$app/state';
import { clickOutside } from '$lib/actions/click-outside';
import ActionButton from '$lib/components/ActionButton.svelte';
import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import SkipLink from '$lib/elements/SkipLink.svelte';
@@ -17,7 +16,7 @@
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { user } from '$lib/stores/user.store';
import { Button, IconButton, Logo } from '@immich/ui';
import { ActionButton, Button, IconButton, Logo } from '@immich/ui';
import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -1,5 +1,5 @@
<script lang="ts">
import { purchaseStore } from '$lib/stores/purchase.store';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { activateProduct, getActivationKey } from '$lib/utils/license-utils';
import { Button, Heading, LoadingSpinner } from '@immich/ui';
@@ -26,7 +26,7 @@
await activateProduct(productKey, activationKey);
onActivate();
purchaseStore.setPurchaseStatus(true);
authManager.isPurchased = true;
} catch (error) {
handleError(error, $t('purchase_failed_activation'));
} finally {
@@ -242,7 +242,6 @@
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
@@ -2,9 +2,9 @@
import { goto } from '$app/navigation';
import { OpenQueryParam } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import PurchaseModal from '$lib/modals/PurchaseModal.svelte';
import { Route } from '$lib/route';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences } from '$lib/stores/user.store';
import { getAccountAge } from '$lib/utils/auth';
import { handleError } from '$lib/utils/handle-error';
@@ -22,8 +22,6 @@
let showBuyButton = $state(getButtonVisibility());
const { isPurchased } = purchaseStore;
const openPurchaseModal = async () => {
await modalManager.show(PurchaseModal);
showMessage = false;
@@ -72,7 +70,7 @@
</script>
<div class="license-status ps-4 text-sm">
{#if $isPurchased && $preferences.purchase.showSupportBadge}
{#if authManager.isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(Route.userSettings({ isOpen: OpenQueryParam.PURCHASE_SETTINGS }))}
class="w-full mt-2"
@@ -80,7 +78,7 @@
>
<SupporterBadge size="small" effect="always" />
</button>
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
{:else if !authManager.isPurchased && showBuyButton && getAccountAge() > 14}
<button
type="button"
onclick={openPurchaseModal}
@@ -1,10 +1,8 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { defaultLang, langs, Theme } from '$lib/constants';
import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { lang } from '$lib/stores/preferences.store';
import { ThemeSwitcher } from '@immich/ui';
import { get } from 'svelte/store';
const handleToggleTheme = () => {
if (themeManager.theme.system) {
@@ -18,14 +16,9 @@
<svelte:window use:shortcut={{ shortcut: { key: 't', alt: true }, onShortcut: () => handleToggleTheme() }} />
{#if !themeManager.theme.system}
{#await langs
.find((item) => item.code === get(lang))
?.loader() ?? defaultLang.loader() then { default: translations }}
<ThemeSwitcher
size="medium"
color="secondary"
{translations}
onChange={(theme) => themeManager.setTheme(theme == 'dark' ? Theme.DARK : Theme.LIGHT)}
/>
{/await}
<ThemeSwitcher
size="medium"
color="secondary"
onChange={(theme) => themeManager.setTheme(theme == 'dark' ? Theme.DARK : Theme.LIGHT)}
/>
{/if}
@@ -1,11 +1,10 @@
<script lang="ts">
import ActionButton from '$lib/components/ActionButton.svelte';
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
import { Route } from '$lib/route';
import { getSharedLinkActions } from '$lib/services/shared-link.service';
import { locale } from '$lib/stores/preferences.store';
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { ContextMenuButton, MenuItemType, Text } from '@immich/ui';
import { ActionButton, ContextMenuButton, MenuItemType, Text } from '@immich/ui';
import { DateTime, type ToRelativeUnit } from 'luxon';
import { t } from 'svelte-i18n';
+32 -41
View File
@@ -419,11 +419,6 @@
}
onSelect(asset);
if (singleSelect) {
timelineManager.scrollTo(0);
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
@@ -443,54 +438,50 @@
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
const startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
const endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
if (!startBucket || !endBucket) {
return;
}
const monthGroups = timelineManager.months;
const startBucketIndex = monthGroups.indexOf(startBucket);
const endBucketIndex = monthGroups.indexOf(endBucket);
if (startBucketIndex === -1 || endBucketIndex === -1) {
return;
}
const rangeStartIndex = Math.min(startBucketIndex, endBucketIndex);
const rangeEndIndex = Math.max(startBucketIndex, endBucketIndex);
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
for (let index = rangeStartIndex + 1; index < rangeEndIndex; index++) {
const monthGroup = monthGroups[index];
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const monthAsset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(monthAsset.id);
} else {
handleSelectAsset(monthAsset);
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
for (let index = rangeStartIndex; index <= rangeEndIndex; index++) {
const monthGroup = monthGroups[index];
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
if (monthGroup === endBucket) {
break;
}
}
}
@@ -127,8 +127,7 @@
const handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.ADD: {
case AssetAction.UNARCHIVE: {
timelineManager.upsertAssets([action.asset]);
break;
}
@@ -1,46 +0,0 @@
<script lang="ts">
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, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { modalManager } from '@immich/ui';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
shared?: boolean;
onAddToAlbum?: OnAddToAlbum;
}
let { shared = false, onAddToAlbum = () => {} }: Props = $props();
const { getAssets } = getAssetControlContext();
const onClick = async () => {
const albums = await modalManager.show(AlbumPickerModal, { shared });
if (!albums || albums.length === 0) {
return;
}
const assetIds = [...getAssets()].map(({ id }) => 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>
<MenuOption
{onClick}
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
shortcut={{ key: 'l', shift: shared }}
/>
@@ -20,8 +20,10 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
const didUpdate = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (didUpdate) {
clearSelect();
}
};
</script>
@@ -21,7 +21,7 @@
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { isModalOpen, modalManager } from '@immich/ui';
type Props = {
timelineManager: TimelineManager;
@@ -142,7 +142,7 @@
};
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
if (searchStore.isSearchEnabled || $showAssetViewer || isModalOpen()) {
return [];
}
@@ -41,7 +41,7 @@
/>
<Label label={title} for="permission-{title}" class="font-mono text-primary text-lg" />
</div>
<div class="mx-6 mt-3 grid grid-cols-3 gap-2">
<div class="mx-6 mt-3 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{#each subItems as item (item)}
<div class="flex items-center gap-2">
<Checkbox
@@ -4,8 +4,8 @@
import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { dateFormats } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences, user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
@@ -22,7 +22,6 @@
import { mdiKey } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
const { isPurchased } = purchaseStore;
let isServerProduct = $state(false);
let serverPurchaseInfo: LicenseResponseDto | null = $state(null);
@@ -53,7 +52,7 @@
};
onMount(async () => {
if (!$isPurchased) {
if (!authManager.isPurchased) {
return;
}
@@ -73,7 +72,7 @@
}
await deleteIndividualProductKey();
purchaseStore.setPurchaseStatus(false);
authManager.isPurchased = false;
} catch (error) {
handleError(error, $t('errors.failed_to_remove_product_key'));
}
@@ -92,21 +91,21 @@
}
await deleteServerProductKey();
purchaseStore.setPurchaseStatus(false);
authManager.isPurchased = false;
} catch (error) {
handleError(error, $t('errors.failed_to_remove_product_key'));
}
};
const onProductActivated = async () => {
purchaseStore.setPurchaseStatus(true);
authManager.isPurchased = true;
await checkPurchaseInfo();
};
</script>
<section class="my-4">
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
{#if $isPurchased}
{#if authManager.isPurchased}
<!-- BADGE TOGGLE -->
<div class="mb-4">
<SettingSwitch
@@ -42,7 +42,7 @@
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
const albums = await modalManager.show(AlbumPickerModal);
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
onchange(newValue);
+2 -5
View File
@@ -6,11 +6,8 @@ export enum AssetAction {
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
ADD = 'add',
ADD_TO_ALBUM = 'add-to-album',
STACK = 'stack',
UNSTACK = 'unstack',
KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
@@ -340,8 +337,8 @@ export const langs: Lang[] = [
{
name: 'Chinese (Simplified)',
code: 'zh-CN',
weblateCode: 'zh_SIMPLIFIED',
loader: () => import('$i18n/zh_SIMPLIFIED.json'),
weblateCode: 'zh_Hans',
loader: () => import('$i18n/zh_Hans.json'),
},
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
];
@@ -1,25 +1,23 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
import { getAssetInfo, getAssetOcr } from '@immich/sdk';
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
class AsyncCache<V> {
class AsyncCache<K, V> {
#cache = new Map<string, V>();
async getOrFetch<K>(
params: K,
fetcher: (params: K) => Promise<V>,
keySerializer: (params: K) => string = defaultSerializer,
updateCache: boolean,
): Promise<V> {
const cacheKey = keySerializer(params);
constructor(private fetcher: (params: K) => Promise<V>) {}
async getOrFetch(params: K, updateCache: boolean): Promise<V> {
const cacheKey = defaultSerializer(params);
const cached = this.#cache.get(cacheKey);
if (cached) {
return cached;
}
const value = await fetcher(params);
const value = await this.fetcher(params);
if (value && updateCache) {
this.#cache.set(cacheKey, value);
}
@@ -27,30 +25,43 @@ class AsyncCache<V> {
return value;
}
clearKey(params: K) {
const cacheKey = defaultSerializer(params);
this.#cache.delete(cacheKey);
}
clear() {
this.#cache.clear();
}
}
class AssetCacheManager {
#assetCache = new AsyncCache<AssetResponseDto>();
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
#assetCache = new AsyncCache(getAssetInfo);
#ocrCache = new AsyncCache(getAssetOcr);
constructor() {
eventManager.on({
AssetEditsApplied: () => {
this.#assetCache.clear();
this.#ocrCache.clear();
AssetEditsApplied: (assetId) => {
this.invalidateAsset(assetId);
},
AssetUpdate: (asset) => {
this.invalidateAsset(asset.id);
},
});
}
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
async getAsset({ id, key, slug }: { id: string; key?: string; slug?: string }, updateCache = true) {
return this.#assetCache.getOrFetch({ id, key, slug }, updateCache);
}
async getAssetOcr(id: string) {
return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true);
return this.#ocrCache.getOrFetch({ id }, true);
}
invalidateAsset(id: string) {
const { key, slug } = authManager.params;
this.#assetCache.clearKey({ id, key, slug });
this.#ocrCache.clearKey({ id });
}
clearAssetCache() {
+21 -1
View File
@@ -3,12 +3,31 @@ import { page } from '$app/state';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { Route } from '$lib/route';
import { isSharedLinkRoute } from '$lib/utils/navigation';
import { logout } from '@immich/sdk';
import { getAboutInfo, logout, type UserAdminResponseDto } from '@immich/sdk';
class AuthManager {
isPurchased = $state(false);
isSharedLink = $derived(isSharedLinkRoute(page.route?.id));
params = $derived(this.isSharedLink ? { key: page.params.key, slug: page.params.slug } : {});
constructor() {
eventManager.on({
AuthUserLoaded: (user) => this.onAuthUserLoaded(user),
});
}
private async onAuthUserLoaded(user: UserAdminResponseDto) {
if (user.license?.activatedAt) {
authManager.isPurchased = true;
return;
}
const serverInfo = await getAboutInfo().catch(() => undefined);
if (serverInfo?.licensed) {
authManager.isPurchased = true;
}
}
async logout() {
let redirectUri;
@@ -30,6 +49,7 @@ class AuthManager {
globalThis.location.href = redirectUri;
}
} finally {
this.isPurchased = false;
eventManager.emit('AuthLogout');
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ export type Events = {
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
AlbumShare: [];
@@ -102,25 +102,21 @@ export class DayGroup {
}
runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
processedIds: new SvelteSet<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
const unprocessedIds = new SvelteSet<string>(ids);
const processedIds = new SvelteSet<string>();
const moveAssets: MoveAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
if (index === -1) {
if (ids.size === 0) {
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
}
for (let index = this.viewerAssets.length - 1; index >= 0; index--) {
const { id: assetId, asset } = this.viewerAssets[index];
if (!ids.has(assetId)) {
continue;
}
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
const callbackResult = callback(asset);
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
+1 -2
View File
@@ -3,7 +3,6 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
@@ -56,7 +55,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
+4 -5
View File
@@ -21,14 +21,13 @@
let selectedRowIndex: number = $state(-1);
interface Props {
shared: boolean;
onClose: (albums?: AlbumResponseDto[]) => void;
}
let { shared, onClose }: Props = $props();
let { onClose }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({ shared: shared || undefined });
albums = await getAllAlbums({});
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
loading = false;
});
@@ -36,7 +35,7 @@
const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
const rowConverter = new AlbumModalRowConverter($albumViewSettings.sortBy, $albumViewSettings.sortOrder);
const albumModalRows = $derived(
rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds),
);
@@ -146,7 +145,7 @@
};
</script>
<Modal title={shared ? $t('add_to_shared_album') : $t('add_to_album')} {onClose} size="small">
<Modal title={$t('add_to_album')} {onClose} size="small">
<ModalBody>
<div class="mb-2 flex max-h-100 flex-col">
{#if loading}
+14 -29
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge, Text } from '@immich/ui';
import { appStoreBadge, BasicModal, fdroidBadge, playStoreBadge } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
@@ -7,33 +7,18 @@
let { onClose }: Props = $props();
</script>
<Modal title={$t('app_download_links')} size="large" {onClose}>
<ModalBody>
<div class="sm:grid sm:grid-cols-2 gap-5">
<div class="flex flex-col place-items-start">
<Text>Google Play</Text>
<a
href="https://play.google.com/store/apps/details?id=app.alextran.immich"
target="_blank"
id="play-store-link"
>
<img class="w-50 mt-2" alt="Get it on Google Play" src={playStoreBadge} />
</a>
</div>
<BasicModal title={$t('app_download_links')} size="tiny" {onClose}>
<div class="flex flex-col gap-4 max-w-50 mx-auto">
<a href="https://play.google.com/store/apps/details?id=app.alextran.immich" target="_blank" id="play-store-link">
<img class="w-full mt-2" alt="Get it on Google Play" src={playStoreBadge} />
</a>
<div class="flex flex-col place-items-start">
<Text>App Store</Text>
<a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
<img class="w-50 mt-2" alt="Download on the App Store" src={appStoreBadge} />
</a>
</div>
<a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
<img class="w-full mt-2" alt="Download on the App Store" src={appStoreBadge} />
</a>
<div class="flex flex-col place-items-start">
<Text>F-Droid</Text>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="w-50 mt-2" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
</div>
</ModalBody>
</Modal>
<a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
<img class="w-full mt-2" alt="Get it on F-Droid" src={fdroidBadge} />
</a>
</div>
</BasicModal>
@@ -0,0 +1,27 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { type AlbumResponseDto } from '@immich/sdk';
type Props = {
assetIds: string[];
onClose: () => void;
};
const { assetIds, onClose }: Props = $props();
const handleClose = async (albums?: AlbumResponseDto[]) => {
const albumIds = (albums ?? []).map(({ id }) => id);
if (albumIds.length === 0) {
onClose();
return;
}
const success = await addAssetsToAlbums(albumIds, assetIds, { notify: true });
if (success) {
onClose();
}
};
</script>
<AlbumPickerModal onClose={handleClose} />
+3 -2
View File
@@ -10,7 +10,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: () => void;
onClose: (updated?: boolean) => void;
assetIds: string[];
}
@@ -33,7 +33,7 @@
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
onClose();
onClose(true);
};
const handleSelect = async (option?: ComboBoxOption) => {
@@ -62,6 +62,7 @@
{onClose}
{onSubmit}
submitText={$t('tag_assets')}
onOpenAutoFocus={(event) => event.preventDefault()}
{disabled}
>
<div class="my-4 flex flex-col gap-2">
+5 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleResetPinCode } from '$lib/services/user.service';
import { Field, FormModal, HelperText, Modal, ModalBody, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { BasicModal, Field, FormModal, HelperText, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { mdiLockReset } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -23,7 +23,7 @@
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
</script>
{#if featureFlagsManager.value.passwordLogin === false}
{#if featureFlagsManager.value.passwordLogin}
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
<Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div>
@@ -37,9 +37,7 @@
</Stack>
</FormModal>
{:else}
<Modal {...common} closeOnBackdropClick>
<ModalBody>
<div>{$t('reset_pin_code_description')}</div>
</ModalBody>
</Modal>
<BasicModal {...common}>
<div>{$t('reset_pin_code_description')}</div>
</BasicModal>
{/if}
-1
View File
@@ -40,7 +40,6 @@
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['l'], action: $t('add_to_album') },
{ key: ['t'], action: $t('tag_assets') },
{ key: ['⇧', 'l'], action: $t('add_to_shared_album') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
+77 -21
View File
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
@@ -11,22 +11,27 @@ import { user } from '$lib/stores/user.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import { downloadArchive } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
addAssetsToAlbum,
addAssetsToAlbum as addToAlbum,
addAssetsToAlbums as addToAlbums,
addUsersToAlbum,
AlbumUserRole,
BulkIdErrorReason,
deleteAlbum,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type BulkIdResponseDto,
type UpdateAlbumDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -40,7 +45,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -67,16 +72,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, v
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
return { Share, AddUsers, CreateSharedLink };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
@@ -86,7 +82,12 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
color: 'primary',
icon: mdiPlusBoxOutline,
$if: () => assets.length > 0,
onAction: () => addAssets(album, assets),
onAction: () =>
addAssetsToAlbums(
[album.id],
assets.map(({ id }) => id),
{ notify: true },
).then(() => undefined),
};
const Upload: ActionItem = {
@@ -100,18 +101,73 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
return { AddAssets, Upload };
};
const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => {
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => {
const $t = await getFormatter();
const assetIds = assets.map(({ id }) => id);
try {
const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } });
if (albumIds.length === 1) {
const albumId = albumIds[0];
const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } });
if (notify) {
notifyAddToAlbum($t, albumId, assetIds, results);
}
}
const count = results.filter(({ success }) => success).length;
toastManager.success($t('assets_added_count', { values: { count } }));
eventManager.emit('AlbumAddAssets');
if (albumIds.length > 1) {
const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } });
if (notify) {
notifyAddToAlbums($t, albumIds, assetIds, results);
}
}
eventManager.emit('AlbumAddAssets', { assetIds, albumIds });
return true;
} catch (error) {
handleError(error, $t('errors.error_adding_assets_to_album'));
return false;
}
};
const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => {
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
if (successCount > 0) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (duplicateCount > 0) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
}
toastManager.custom(
{
component: ToastAction,
props: {
title: $t('info'),
color: 'primary',
description,
button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) },
},
},
{ timeout: 5000 },
);
};
const notifyAddToAlbums = (
$t: MessageFormatter,
albumIds: string[],
assetIds: string[],
results: AlbumsAddAssetsResponseDto,
) => {
if (results.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.success(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
);
}
};
+19 -1
View File
@@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
@@ -42,6 +43,7 @@ import {
mdiMagnifyPlusOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiPlus,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
@@ -59,6 +61,13 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte
ctx.clearSelect();
};
const AddToAlbum: ActionItem = {
title: $t('add_to_album'),
icon: mdiPlus,
shortcuts: [{ key: 'l' }],
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -84,7 +93,7 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte
$if: () => isAllVideos,
};
return { RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
};
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
@@ -161,6 +170,14 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'f' }],
};
const AddToAlbum: ActionItem = {
title: $t('add_to_album'),
icon: mdiPlus,
shortcuts: [{ key: 'l' }],
$if: () => asset.visibility !== AssetVisibility.Locked && !asset.isTrashed,
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }),
};
const Offline: ActionItem = {
title: $t('asset_offline'),
icon: mdiAlertOutline,
@@ -260,6 +277,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
AddToAlbum,
ZoomIn,
ZoomOut,
Copy,
+17 -12
View File
@@ -1,14 +1,16 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { user } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store';
export class AssetInteraction {
selectedAssets = $state<TimelineAsset[]>([]);
private selectedAssetsMap = new SvelteMap<string, TimelineAsset>();
selectedAssets = $derived(Array.from(this.selectedAssetsMap.values()));
selectAll = $state(false);
hasSelectedAsset(assetId: string) {
return this.selectedAssets.some((asset) => asset.id === assetId);
return this.selectedAssetsMap.has(assetId);
}
selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state<TimelineAsset[]>([]);
@@ -16,20 +18,26 @@ export class AssetInteraction {
return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
}
assetSelectionStart = $state<TimelineAsset | null>(null);
selectionActive = $derived(this.selectedAssets.length > 0);
selectionActive = $derived(this.selectedAssetsMap.size > 0);
private user = fromStore<UserAdminResponseDto | undefined>(user);
private userId = $derived(this.user.current?.id);
asControlContext(): AssetControlContext {
return {
getOwnedAssets: () => this.selectedAssets.filter((asset) => asset.ownerId === this.userId),
getAssets: () => this.selectedAssets,
clearSelect: () => this.clearMultiselect(),
};
}
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive));
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
selectAsset(asset: TimelineAsset) {
if (!this.hasSelectedAsset(asset.id)) {
this.selectedAssets.push(asset);
}
this.selectedAssetsMap.set(asset.id, asset);
}
selectAssets(assets: TimelineAsset[]) {
@@ -39,10 +47,7 @@ export class AssetInteraction {
}
removeAssetFromMultiselectGroup(assetId: string) {
const index = this.selectedAssets.findIndex((a) => a.id == assetId);
if (index !== -1) {
this.selectedAssets.splice(index, 1);
}
this.selectedAssetsMap.delete(assetId);
}
addGroupToMultiselectGroup(group: string) {
@@ -69,7 +74,7 @@ export class AssetInteraction {
this.selectAll = false;
// Multi-selection
this.selectedAssets = [];
this.selectedAssetsMap.clear();
this.selectedGroup.clear();
// Range selection
+1
View File
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the SDK
vi.mock('@immich/sdk', () => ({
getAssetInfo: vi.fn(),
getAssetOcr: vi.fn(),
}));
-16
View File
@@ -1,16 +0,0 @@
import { readonly, writable } from 'svelte/store';
function createPurchaseStore() {
const isPurcharsed = writable(false);
function setPurchaseStatus(status: boolean) {
isPurcharsed.set(status);
}
return {
isPurchased: readonly(isPurcharsed),
setPurchaseStatus,
};
}
export const purchaseStore = createPurchaseStore();
-2
View File
@@ -1,5 +1,4 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { purchaseStore } from '$lib/stores/purchase.store';
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store';
@@ -13,7 +12,6 @@ export const preferences = writable<UserPreferencesResponseDto>();
export const resetSavedUser = () => {
user.set(undefined as unknown as UserAdminResponseDto);
preferences.set(undefined as unknown as UserPreferencesResponseDto);
purchaseStore.setPurchaseStatus(false);
};
eventManager.on({
+1
View File
@@ -77,6 +77,7 @@ websocket
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
.on('on_session_delete', () => authManager.logout())
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
.on('on_notification', () => notificationManager.refresh())
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
+5 -80
View File
@@ -1,11 +1,8 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, withError } from '$lib/utils';
@@ -14,10 +11,7 @@ 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,
@@ -42,77 +36,6 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => {
const result = await addAssets({
...authManager.params,
id: albumId,
bulkIdsDto: {
ids: assetIds,
},
});
const count = result.filter(({ success }) => success).length;
const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length;
const $t = get(t);
if (showNotification) {
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
if (count > 0) {
description = $t('assets_added_to_album_count', { values: { count } });
} else if (duplicateErrorCount > 0) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateErrorCount } });
}
toastManager.custom(
{
component: ToastAction,
props: {
title: $t('info'),
color: 'primary',
description,
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(Route.viewAlbum({ id: albumId }));
},
},
},
},
{ timeout: 5000 },
);
}
};
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) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
return result;
}
if (result.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
return result;
}
toastManager.success(
$t('assets_added_to_albums_count', { values: { albumTotal: albumIds.length, assetTotal: assetIds.length } }),
);
return result;
}
};
export const tagAssets = async ({
assetIds,
tagIds,
@@ -213,7 +136,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
const { data } = await downloadRequest({
method: 'POST',
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
data: { assetIds: archive.assetIds },
data: { assetIds: archive.assetIds, edited: true },
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
});
@@ -443,13 +366,15 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
try {
for (const monthGroup of timelineManager.months) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
if (!monthGroup.isLoaded) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
}
if (!assetInteraction.selectAll) {
assetInteraction.clearMultiselect();
break; // Cancelled
}
assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()]));
assetInteraction.selectAssets([...monthGroup.assetsIterator()]);
for (const dateGroup of monthGroup.dayGroups) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
+2 -10
View File
@@ -1,10 +1,9 @@
import { browser } from '$app/environment';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { Route } from '$lib/route';
import { purchaseStore } from '$lib/stores/purchase.store';
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { get } from 'svelte/store';
@@ -18,19 +17,12 @@ export const loadUser = async () => {
try {
let user = get(user$);
let preferences = get(preferences$);
let serverInfo;
if ((!user || !preferences) && hasAuthCookie()) {
[user, preferences, serverInfo] = await Promise.all([getMyUser(), getMyPreferences(), getAboutInfo()]);
[user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
user$.set(user);
preferences$.set(preferences);
eventManager.emit('AuthUserLoaded', user);
// Check for license status
if (serverInfo.licensed || user.license?.activatedAt) {
purchaseStore.setPurchaseStatus(true);
}
}
return user;
} catch {
@@ -15,7 +15,7 @@ const nextId = () => count++;
const noop = () => {};
export class BaseEventManager<Events extends EventsBase> {
#callbacks: EventItem<Events>[] = $state([]);
#callbacks: EventItem<Events>[] = $state.raw([]);
on(subscriptions: EventMap<Events>): () => void {
const cleanups = Object.entries(subscriptions).map(([event, callback]) =>
@@ -36,7 +36,7 @@ export class BaseEventManager<Events extends EventsBase> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const item = { id: nextId(), event, callback } as EventItem<Events, any>;
this.#callbacks.push(item);
this.#callbacks = [...this.#callbacks, item];
return () => {
this.#callbacks = this.#callbacks.filter((current) => current.id !== item.id);
@@ -115,12 +115,17 @@ export class GCastDestination implements ICastDestination {
// build the authenticated media request and send it to the cast device
const authenticatedUrl = `${mediaUrl}&sessionKey=${sessionKey}`;
const mediaInfo = new chrome.cast.media.MediaInfo(authenticatedUrl, contentType);
const request = new chrome.cast.media.LoadRequest(mediaInfo);
// Create a queue with a single item and set it to repeat
const queueItem = new chrome.cast.media.QueueItem(mediaInfo);
const queueLoadRequest = new chrome.cast.media.QueueLoadRequest([queueItem]);
queueLoadRequest.repeatMode = chrome.cast.media.RepeatMode.SINGLE;
const successCallback = this.onMediaDiscovered.bind(this, SESSION_DISCOVERY_CAUSE.LOAD_MEDIA);
this.currentUrl = mediaUrl;
return this.session.loadMedia(request, successCallback, this.onError.bind(this));
return this.session.queueLoad(queueLoadRequest, successCallback, this.onError.bind(this));
}
///
+2 -2
View File
@@ -1,10 +1,10 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { uploadManager } from '$lib/managers/upload-manager.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { uploadAssetsStore } from '$lib/stores/upload';
import { user } from '$lib/stores/user.store';
import { UploadState } from '$lib/types';
import { uploadRequest } from '$lib/utils';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { ExecutorQueue } from '$lib/utils/executor-queue';
import { asQueryString } from '$lib/utils/shared-links';
import {
@@ -213,7 +213,7 @@ async function fileUploader({
if (albumId) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbum(albumId, [responseData.id], false);
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
}
+59 -63
View File
@@ -12,70 +12,58 @@ const getContainedSize = (img: HTMLImageElement): { width: number; height: numbe
return { width, height };
};
export type Point = {
x: number;
y: number;
};
export interface OcrBox {
id: string;
points: { x: number; y: number }[];
points: Point[];
text: string;
confidence: number;
}
export interface BoundingBoxDimensions {
minX: number;
maxX: number;
minY: number;
maxY: number;
width: number;
height: number;
centerX: number;
centerY: number;
rotation: number;
skewX: number;
skewY: number;
}
/**
* Calculate bounding box dimensions and properties from OCR points
* Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d.
* @param points - Array of 4 corner points of the bounding box
* @returns Dimensions, rotation, and skew values for the bounding box
* @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div.
*/
export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => {
export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => {
const [topLeft, topRight, bottomRight, bottomLeft] = points;
const minX = Math.min(...points.map(({ x }) => x));
const maxX = Math.max(...points.map(({ x }) => x));
const minY = Math.min(...points.map(({ y }) => y));
const maxY = Math.max(...points.map(({ y }) => y));
const width = maxX - minX;
const height = maxY - minY;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
// Calculate rotation angle from the bottom edge (bottomLeft to bottomRight)
const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI);
// Approximate width and height to prevent text distortion as much as possible
const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight));
const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight));
// Calculate skew angles to handle perspective distortion
// SkewX: compare left and right edges
const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x);
const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x);
const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI);
const dx1 = topRight.x - bottomRight.x;
const dx2 = bottomLeft.x - bottomRight.x;
const dx3 = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x;
// SkewY: compare top and bottom edges
const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x);
const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x);
const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI);
const dy1 = topRight.y - bottomRight.y;
const dy2 = bottomLeft.y - bottomRight.y;
const dy3 = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y;
return {
minX,
maxX,
minY,
maxY,
width,
height,
centerX,
centerY,
rotation,
skewX,
skewY,
};
const det = dx1 * dy2 - dx2 * dy1;
const a13 = (dx3 * dy2 - dx2 * dy3) / det;
const a23 = (dx1 * dy3 - dx3 * dy1) / det;
const a11 = (1 + a13) * topRight.x - topLeft.x;
const a21 = (1 + a23) * bottomLeft.x - topLeft.x;
const a12 = (1 + a13) * topRight.y - topLeft.y;
const a22 = (1 + a23) * bottomLeft.y - topLeft.y;
// prettier-ignore
const matrix = [
a11 / width, a12 / width, 0, a13 / width,
a21 / height, a22 / height, 0, a23 / height,
0, 0, 1, 0,
topLeft.x, topLeft.y, 0, 1,
];
return { matrix, width, height };
};
/**
@@ -87,18 +75,32 @@ export const getOcrBoundingBoxes = (
zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | null,
): OcrBox[] => {
const boxes: OcrBox[] = [];
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
return boxes;
return [];
}
const clientHeight = photoViewer.clientHeight;
const clientWidth = photoViewer.clientWidth;
const { width, height } = getContainedSize(photoViewer);
const imageWidth = photoViewer.naturalWidth;
const imageHeight = photoViewer.naturalHeight;
const offset = {
x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX,
y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY,
};
return getOcrBoundingBoxesAtSize(
ocrData,
{ width: width * zoom.currentZoom, height: height * zoom.currentZoom },
offset,
);
};
export const getOcrBoundingBoxesAtSize = (
ocrData: OcrBoundingBox[],
targetSize: { width: number; height: number },
offset?: Point,
) => {
const boxes: OcrBox[] = [];
for (const ocr of ocrData) {
// Convert normalized coordinates (0-1) to actual pixel positions
@@ -109,14 +111,8 @@ export const getOcrBoundingBoxes = (
{ x: ocr.x3, y: ocr.y3 },
{ x: ocr.x4, y: ocr.y4 },
].map((point) => ({
x:
(width / imageWidth) * zoom.currentZoom * point.x * imageWidth +
((clientWidth - width) / 2) * zoom.currentZoom +
zoom.currentPositionX,
y:
(height / imageHeight) * zoom.currentZoom * point.y * imageHeight +
((clientHeight - height) / 2) * zoom.currentZoom +
zoom.currentPositionY,
x: targetSize.width * point.x + (offset?.x ?? 0),
y: targetSize.height * point.y + (offset?.y ?? 0),
}));
boxes.push({
+1 -1
View File
@@ -49,7 +49,7 @@ export const loadSharedLink = async ({
},
};
} catch (error) {
if (isHttpError(error) && error.data.message === 'Invalid password') {
if (isHttpError(error) && error.data.message === 'Password required') {
return {
...common,
passwordRequired: true,
+7 -9
View File
@@ -1,14 +1,12 @@
const broadcast = new BroadcastChannel('immich');
import { ServiceWorkerMessenger } from './sw-messenger';
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
// eslint-disable-next-line compat/compat
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
if (!url || !messenger) {
return;
}
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
messenger.send('cancel', { url });
}
+17
View File
@@ -0,0 +1,17 @@
export class ServiceWorkerMessenger {
readonly #serviceWorker: ServiceWorkerContainer;
constructor(serviceWorker: ServiceWorkerContainer) {
this.#serviceWorker = serviceWorker;
}
/**
* Send a one-way message to the service worker.
*/
send(type: string, data: Record<string, unknown>) {
this.#serviceWorker.controller?.postMessage({
type,
...data,
});
}
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
@@ -14,7 +13,6 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -45,6 +43,7 @@
handleDownloadAlbum,
} from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -54,7 +53,14 @@
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
ActionButton,
CommandPaletteDefaultProvider,
Icon,
IconButton,
modalManager,
toastManager,
} from '@immich/ui';
import {
mdiAccountEye,
mdiAccountEyeOutline,
@@ -121,10 +127,6 @@
await handleCloseSelectAssets();
return;
}
if (viewMode === AlbumPageViewMode.OPTIONS) {
viewMode = AlbumPageViewMode.VIEW;
return;
}
if ($showAssetViewer) {
return;
}
@@ -132,7 +134,7 @@
cancelMultiselect(assetInteraction);
return;
}
return;
await goto(Route.albums());
};
const refreshAlbum = async () => {
@@ -299,14 +301,24 @@
return;
}
album.albumUsers = album.albumUsers.map((albumUser) =>
const albumUsers = album.albumUsers.map((albumUser) =>
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
);
album = { ...album, albumUsers };
};
const { Cast } = $derived(getGlobalActions($t));
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
const { Share } = $derived(getAlbumActions($t, album));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
const Close = $derived({
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: handleEscape,
$if: () => !$showAssetViewer,
shortcuts: { key: 'Escape' },
});
</script>
<OnEvents
@@ -346,7 +358,7 @@
id={album.id}
albumName={album.albumName}
{isOwned}
onUpdate={(albumName) => (album.albumName = albumName)}
onUpdate={(albumName) => (album = { ...album, albumName })}
/>
{#if album.assetCount > 0}
@@ -395,8 +407,11 @@
<ActionButton action={Share} />
</div>
{/if}
<!-- ALBUM DESCRIPTION -->
<AlbumDescription id={album.id} bind:description={album.description} {isOwned} />
<AlbumDescription
id={album.id}
{isOwned}
bind:description={() => album.description, (description) => (album = { ...album, description })}
/>
</section>
{/if}
@@ -438,12 +453,11 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
{#if assetInteraction.isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
@@ -2,7 +2,6 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
@@ -15,9 +14,11 @@
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -64,16 +65,15 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<ArchiveAction
unarchive
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
+3 -4
View File
@@ -4,8 +4,8 @@
import LicenseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte';
import LicenseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Route } from '$lib/route';
import { purchaseStore } from '$lib/stores/purchase.store';
import { Alert, Container, Stack } from '@immich/ui';
import { mdiAlertCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -17,17 +17,16 @@
let { data }: Props = $props();
let showLicenseActivated = $state(false);
const { isPurchased } = purchaseStore;
</script>
<UserPageLayout title={$t('buy')}>
<UserPageLayout title={data.meta.title}>
<Container size="medium" center>
<Stack gap={4} class="mt-4">
{#if data.isActivated === false}
<Alert icon={mdiAlertCircleOutline} color="danger" title={$t('purchase_failed_activation')} />
{/if}
{#if $isPurchased}
{#if authManager.isPurchased}
<SupporterBadge logoSize="lg" centered />
{/if}
+2 -2
View File
@@ -1,4 +1,4 @@
import { purchaseStore } from '$lib/stores/purchase.store';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { activateProduct, getActivationKey } from '$lib/utils/license-utils';
@@ -21,7 +21,7 @@ export const load = (async ({ url }) => {
const response = await activateProduct(licenseKey, activationKey);
if (response.activatedAt !== '') {
isActivated = true;
purchaseStore.setPurchaseStatus(true);
authManager.isPurchased = true;
}
}
} catch (error) {
@@ -2,7 +2,6 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -17,9 +16,11 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -68,13 +69,12 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<FavoriteAction removeFavorite onFavorite={(assetIds) => timelineManager.removeAssets(assetIds)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
@@ -8,7 +8,6 @@
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -27,11 +26,10 @@
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { joinPaths } from '$lib/utils/tree-utils';
import { IconButton, Text } from '@immich/ui';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider, IconButton, Text } from '@immich/ui';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -119,8 +117,8 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
>
{@const Actions = getAssetBulkActions($t, getAssetControlContext())}
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<IconButton
shape="round"
@@ -130,10 +128,7 @@
icon={mdiSelectAll}
onclick={handleSelectAllAssets}
/>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={function handleFavoriteUpdate(ids, isFavorite) {
@@ -1,16 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
import { mdiArrowLeft, mdiPlus } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -45,18 +45,17 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<DownloadAction />
</AssetSelectControlBar>
{:else}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
{#snippet leading()}
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
{data.partner.name}'s photos
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
</p>
{/snippet}
</ControlAppBar>
@@ -12,7 +12,6 @@
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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -31,6 +30,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getPersonActions } from '$lib/services/person.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { locale } from '$lib/stores/preferences.store';
@@ -40,14 +40,16 @@
import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation';
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline,
mdiArrowLeft,
mdiDotsVertical,
mdiPlus,
} from '@mdi/js';
ActionButton,
CommandPaletteDefaultProvider,
ContextMenuButton,
LoadingSpinner,
modalManager,
toastManager,
type ActionItem,
} from '@immich/ui';
import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -461,12 +463,11 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
@@ -4,7 +4,6 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -36,13 +35,12 @@
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { getAssetControlContext } from '$lib/utils/context';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility } from '@immich/sdk';
import { ImageCarousel } from '@immich/ui';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -130,14 +128,12 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, getAssetControlContext())}
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
{#if isAllUserOwned}
<FavoriteAction
@@ -2,11 +2,11 @@
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -28,7 +28,6 @@
import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
@@ -43,8 +42,8 @@
searchSmart,
type SmartSearchDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiSelectAll } from '@mdi/js';
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
@@ -232,7 +231,7 @@
return tagNames.join(', ');
}
const onAddToAlbum = (assetIds: string[]) => {
const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => {
cancelMultiselect(assetInteraction);
if (terms.isNotInAlbum.toString() == 'true') {
@@ -248,6 +247,8 @@
<svelte:window bind:scrollY />
<OnEvents {onAlbumAddAssets} />
{#if terms}
<section
id="search-chips"
@@ -328,7 +329,8 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
>
{@const Actions = getAssetBulkActions($t, getAssetControlContext())}
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<IconButton
@@ -339,10 +341,7 @@
icon={mdiSelectAll}
onclick={handleSelectAll}
/>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
{#if isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
@@ -357,6 +356,7 @@
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<ActionMenuItem action={Actions.AddToAlbum} />
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
@@ -69,6 +69,5 @@
bind:allowUpload
bind:showMetadata
bind:expiresAt
createdAt={sharedLink.createdAt}
/>
</FormModal>
@@ -9,7 +9,6 @@
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -25,13 +24,14 @@
import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getTagActions } from '$lib/services/tag.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences, user } from '$lib/stores/user.store';
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { Text } from '@immich/ui';
import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js';
import { ActionButton, CommandPaletteDefaultProvider, Text } from '@immich/ui';
import { mdiDotsVertical, mdiTag, mdiTagMultiple } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -120,12 +120,11 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<ActionButton action={Actions.AddToAlbum} />
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
+19 -4
View File
@@ -40,17 +40,32 @@
$effect(() => {
setTranslations({
cancel: $t('cancel'),
close: $t('close'),
confirm: $t('confirm'),
expand: $t('expand'),
collapse: $t('collapse'),
search_placeholder: $t('search'),
search_no_results: $t('no_results'),
prompt_default: $t('are_you_sure_to_do_this'),
show_password: $t('show_password'),
hide_password: $t('hide_password'),
confirm: $t('confirm'),
cancel: $t('cancel'),
dark_theme: $t('dark_theme'),
open_menu: $t('open'),
command_palette_prompt_default: $t('command_palette_prompt'),
command_palette_to_select: $t('command_palette_to_select'),
command_palette_to_navigate: $t('command_palette_to_navigate'),
command_palette_to_close: $t('command_palette_to_close'),
command_palette_to_show_all: $t('command_palette_to_show_all'),
navigate_next: $t('next'),
navigate_previous: $t('previous'),
open_calendar: $t('open_calendar'),
toast_success_title: $t('success'),
toast_info_title: $t('info'),
toast_warning_title: $t('warning'),
toast_danger_title: $t('error'),
navigate_next: $t('next'),
navigate_previous: $t('previous'),
save: $t('save'),
supporter: $t('supporter'),
});
});
@@ -1,25 +0,0 @@
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};
-42
View File
@@ -1,42 +0,0 @@
import { version } from '$service-worker';
const CACHE = `cache-${version}`;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
_cache = await caches.open(CACHE);
return _cache;
};
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}
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);
}
}
};
+5 -7
View File
@@ -2,9 +2,9 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { prune } from './cache';
import { handleRequest } from './request';
import { installMessageListener } from './messaging';
import { handleFetch as handleAssetFetch } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
@@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(prune());
};
const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(sw.skipWaiting());
// do not preload app resources
};
const handleFetch = (event: FetchEvent): void => {
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
// 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));
event.respondWith(handleAssetFetch(event.request));
return;
}
};
@@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => {
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();
installMessageListener();
+33
View File
@@ -0,0 +1,33 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { handleCancel } from './request';
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
export const installMessageListener = () => {
sw.addEventListener('message', (event) => {
if (!event.data?.type) {
return;
}
switch (event.data.type) {
case 'cancel': {
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
if (!url) {
return;
}
const client = event.source;
if (!client) {
return;
}
handleCancel(url);
break;
}
}
});
};
+57 -61
View File
@@ -1,73 +1,69 @@
import { get, put } from './cache';
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
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');
}
type PendingRequest = {
controller: AbortController;
promise: Promise<Response>;
cleanupTimeout?: ReturnType<typeof setTimeout>;
};
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
const pendingRequests = new Map<string, PendingRequest>();
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
const CANCELATION_MESSAGE = 'Request canceled by application';
const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const handleFetch = (request: URL | Request): Promise<Response> => {
const requestKey = getRequestKey(request);
const existing = pendingRequests.get(requestKey);
if (existing) {
// Clone the response since response bodies can only be read once
// Each caller gets an independent clone they can consume
return existing.promise.then((response) => response.clone());
}
if (isRequest(request)) {
return request.url;
}
const pendingRequest: PendingRequest = {
controller: new AbortController(),
promise: undefined as unknown as Promise<Response>,
cleanupTimeout: undefined,
};
pendingRequests.set(requestKey, pendingRequest);
throw new Error(`Invalid request: ${request}`);
};
// NOTE: fetch returns after headers received, not the body
pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal })
.catch((error: unknown) => {
const standardError = error instanceof Error ? error : new Error(String(error));
if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
throw standardError;
})
.finally(() => {
// Schedule cleanup after timeout to allow response body streaming to complete
const cleanupTimeout = setTimeout(() => {
pendingRequests.delete(requestKey);
}, CLEANUP_TIMEOUT_MS);
pendingRequest.cleanupTimeout = cleanupTimeout;
});
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);
}
// Clone for the first caller to keep the original response unconsumed for future callers
return pendingRequest.promise.then((response) => response.clone());
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
const requestKey = getRequestKey(url);
pendingRequest.abort();
pendingRequests.delete(cacheKey);
const pendingRequest = pendingRequests.get(requestKey);
if (pendingRequest) {
pendingRequest.controller.abort(CANCELATION_MESSAGE);
if (pendingRequest.cleanupTimeout) {
clearTimeout(pendingRequest.cleanupTimeout);
}
pendingRequests.delete(requestKey);
}
};
+1 -1
View File
@@ -2,7 +2,7 @@ import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import dotenv from 'dotenv';
dotenv.config();
dotenv.config({ quiet: true });
process.env.PUBLIC_IMMICH_BUY_HOST = process.env.PUBLIC_IMMICH_BUY_HOST || 'https://buy.immich.app';
process.env.PUBLIC_IMMICH_PAY_HOST = process.env.PUBLIC_IMMICH_PAY_HOST || 'https://pay.futo.org';