mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
24.13.0
|
||||
24.13.1
|
||||
|
||||
+20
-30
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+12
-35
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
///
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+31
-17
@@ -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))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+7
-8
@@ -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>
|
||||
|
||||
+13
-12
@@ -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))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user