Merge branch 'immich-main' into feat/shared-album-owner-labels

This commit is contained in:
CJPeckover
2025-11-25 22:59:30 -05:00
131 changed files with 5921 additions and 3168 deletions
+1 -1
View File
@@ -1 +1 @@
24.11.0
24.11.1
+8 -8
View File
@@ -11,13 +11,13 @@
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript",
"check:all": "npm run check:code && npm run test:cov",
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
"check:all": "pnpm run check:code && pnpm run test:cov",
"lint": "eslint . --max-warnings 0 --concurrency 4",
"lint:fix": "npm run lint -- --fix",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --check .",
"format:fix": "prettier --write . && npm run format:i18n",
"format:fix": "prettier --write . && pnpm run format:i18n",
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
"test": "vitest --run",
"test:cov": "vitest --coverage",
@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.43.0",
"@immich/ui": "^0.49.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -97,7 +97,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.43.0",
"svelte": "5.43.12",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -107,6 +107,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}
-8
View File
@@ -76,14 +76,6 @@
--immich-dark-gray: 33 33 33;
}
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: rgb(var(--immich-ui-default-border));
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
+3 -5
View File
@@ -1,16 +1,14 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { IconButton, type IconButtonProps } from '@immich/ui';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<IconButton variant="ghost" {color} shape="round" {...other as IconButtonProps} {icon} aria-label={title} {onclick} />
<IconButton variant="ghost" shape="round" {color} {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}
+5 -6
View File
@@ -1,18 +1,17 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { Button, type ButtonProps, Text } from '@immich/ui';
import { type ActionItem, Button, Text } from '@immich/ui';
type Props = {
action: ActionItem;
title?: string;
};
const { action }: Props = $props();
const { title, icon, color = 'secondary', props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
const { action, title: titleAttr }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button variant="ghost" size="small" {color} {...other as ButtonProps} leadingIcon={icon} {onclick}>
<Button variant="ghost" size="small" {color} leadingIcon={icon} onclick={() => onAction(action)} title={titleAttr}>
<Text class="hidden md:block">{title}</Text>
</Button>
{/if}
+3 -5
View File
@@ -1,16 +1,14 @@
<script lang="ts">
import type { ActionItem } from '$lib/types';
import { IconButton, type IconButtonProps } from '@immich/ui';
import { IconButton, type ActionItem } from '@immich/ui';
type Props = {
action: ActionItem;
};
const { action }: Props = $props();
const { title, icon, props: other = {}, onSelect } = $derived(action);
const onclick = (event: Event) => onSelect?.({ event, item: action });
const { title, icon, onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<IconButton shape="round" color="primary" {...other as IconButtonProps} {icon} aria-label={title} {onclick} />
<IconButton shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}
@@ -32,7 +32,7 @@
.filter(Boolean)
.join(' • ');
const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink));
const { ViewQrCode, Copy } = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div class="flex justify-between items-center">
@@ -41,7 +41,7 @@
<Text size="tiny" color="muted">{getShareProperties()}</Text>
</div>
<div class="flex">
<ActionButton action={SharedLinkActions.ViewQrCode} />
<ActionButton action={SharedLinkActions.Copy} />
<ActionButton action={ViewQrCode} />
<ActionButton action={Copy} />
</div>
</div>
@@ -33,7 +33,6 @@
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
ownedAlbums?: AlbumResponseDto[];
@@ -128,65 +127,45 @@
},
};
let albums: AlbumResponseDto[] = $state([]);
let filteredAlbums: AlbumResponseDto[] = $state([]);
let groupedAlbums: AlbumGroup[] = $state([]);
let albums = $derived.by(() => {
switch (userSettings.filter) {
case AlbumFilter.Owned: {
return ownedAlbums;
}
case AlbumFilter.Shared: {
return sharedAlbums;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== $user.id);
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
});
const normalizedSearchQuery = $derived(normalizeSearchString(searchQuery));
let filteredAlbums = $derived(
normalizedSearchQuery
? albums.filter(({ albumName }) => normalizeSearchString(albumName).includes(normalizedSearchQuery))
: albums,
);
let albumGroupOption: string = $state(AlbumGroupBy.None);
let albumGroupOption = $derived(getSelectedAlbumGroupOption(userSettings));
let groupedAlbums = $derived.by(() => {
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
const groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
let albumToShare: AlbumResponseDto | null = $state(null);
return groupedAlbums.map((group) => ({
id: group.id,
name: group.name,
albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }),
}));
});
let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 });
let selectedAlbum: AlbumResponseDto | undefined = $state();
let isOpen = $state(false);
// Step 1: Filter between Owned and Shared albums, or both.
run(() => {
switch (userSettings.filter) {
case AlbumFilter.Owned: {
albums = ownedAlbums;
break;
}
case AlbumFilter.Shared: {
albums = sharedAlbums;
break;
}
default: {
const userId = $user.id;
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== userId);
albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
});
// Step 2: Filter using the given search query.
run(() => {
if (searchQuery) {
const searchAlbumNormalized = normalizeSearchString(searchQuery);
filteredAlbums = albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(searchAlbumNormalized);
});
} else {
filteredAlbums = albums;
}
});
// Step 3: Group albums.
run(() => {
albumGroupOption = getSelectedAlbumGroupOption(userSettings);
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
});
// Step 4: Sort albums amongst each group.
run(() => {
groupedAlbums = groupedAlbums.map((group) => ({
id: group.id,
name: group.name,
albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }),
}));
// TODO get rid of this
$effect(() => {
albumGroupIds = groupedAlbums.map(({ id }) => id);
});
@@ -231,7 +210,7 @@
const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum });
switch (result?.action) {
case 'sharedUsers': {
await handleAddUsers(result.data);
await handleAddUsers(selectedAlbum, result.data);
break;
}
@@ -300,22 +279,17 @@
updateRecentAlbumInfo(album);
};
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
if (!albumToShare) {
return;
}
const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
try {
const album = await addUsersToAlbum({
id: albumToShare.id,
const updatedAlbum = await addUsersToAlbum({
id: album.id,
addUsersDto: {
albumUsers,
},
});
updateAlbumInfo(album);
updateAlbumInfo(updatedAlbum);
} catch (error) {
handleError(error, $t('errors.unable_to_add_album_users'));
} finally {
albumToShare = null;
}
};
@@ -52,7 +52,7 @@
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
let chatHeight: number = $state(0);
let divHeight: number = $state(0);
let divHeight = $derived(innerHeight - activityHeight);
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
@@ -96,11 +96,7 @@
}
isSendingMessage = false;
};
$effect(() => {
if (innerHeight && activityHeight) {
divHeight = innerHeight - activityHeight;
}
});
$effect(() => {
if (assetId && previousAssetId != assetId) {
previousAssetId = assetId;
@@ -35,15 +35,13 @@
});
};
let albumNameArray: string[] = $state(['', '', '']);
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
// It is used to highlight the search query in the album name
$effect(() => {
const albumNameArray: string[] = $derived.by(() => {
let { albumName } = album;
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
let findLength = searchQuery.length;
albumNameArray = [
return [
albumName.slice(0, findIndex),
albumName.slice(findIndex, findIndex + findLength),
albumName.slice(findIndex + findLength),
@@ -395,12 +395,12 @@
}
});
let currentAssetId = $derived(asset.id);
// primarily, this is reactive on `asset`
$effect(() => {
if (currentAssetId) {
untrack(() => handlePromiseError(handleGetAllAlbums()));
ocrManager.clear();
handlePromiseError(ocrManager.getAssetOcr(currentAssetId));
handlePromiseError(handleGetAllAlbums());
ocrManager.clear();
if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id));
}
});
</script>
@@ -23,6 +23,7 @@
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
@@ -372,9 +373,9 @@
</div>
</div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div><Icon icon={mdiCamera} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
@@ -395,20 +396,34 @@
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.lensModel}
<div class="flex gap-2 text-sm">
<p>
<a
href={resolve(
`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`,
)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
</div>
<p>
<a
href={resolve(`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
@@ -416,19 +431,9 @@
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>
{`ISO ${asset.exifInfo.iso}`}
</p>
{/if}
</div>
</div>
</div>
@@ -171,7 +171,6 @@
$effect(() => {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
}
});
@@ -43,7 +43,9 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let assetFileUrl = $derived(
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -53,11 +55,9 @@
});
$effect(() => {
assetFileUrl = playOriginalVideo
? getAssetOriginalUrl({ id: assetId, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
videoPlayer.load();
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
videoPlayer?.load();
}
});
@@ -35,7 +35,6 @@
$effect(() => {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
}
});
@@ -268,7 +268,7 @@
<!-- Favorite asset star -->
{#if !authManager.isSharedLink && asset.isFavorite}
<div class="absolute bottom-2 start-2">
<Icon icon={mdiHeart} size="24" class="text-white" />
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
@@ -282,14 +282,14 @@
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiRotate360} size="24" />
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
</span>
</div>
{/if}
@@ -297,7 +297,7 @@
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiFileGifBox} size="24" />
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
</span>
</div>
{/if}
@@ -312,7 +312,7 @@
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon icon={mdiCameraBurst} size="24" />
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
@@ -378,7 +378,7 @@
/>
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiMotionPauseOutline} size="24" />
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
</span>
</div>
</div>
@@ -418,13 +418,13 @@
{disabled}
>
{#if disabled}
<Icon icon={mdiCheckCircle} size="24" class="text-zinc-800" />
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-zinc-800" />
{:else if selected}
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon icon={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
<Icon data-icon-select icon={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Badge from '$lib/elements/Badge.svelte';
import { locale } from '$lib/stores/preferences.store';
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusDto } from '@immich/sdk';
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusLegacyDto } from '@immich/sdk';
import { Icon, IconButton } from '@immich/ui';
import {
mdiAlertCircle,
@@ -23,7 +23,7 @@
subtitle: string | undefined;
description: Component | undefined;
statistics: QueueStatisticsDto;
queueStatus: QueueStatusDto;
queueStatus: QueueStatusLegacyDto;
icon: string;
disabled?: boolean;
allText: string | undefined;
+2 -2
View File
@@ -6,7 +6,7 @@
QueueCommand,
type QueueCommandDto,
QueueName,
type QueuesResponseDto,
type QueuesResponseLegacyDto,
runQueueCommandLegacy,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
@@ -29,7 +29,7 @@
import StorageMigrationDescription from './StorageMigrationDescription.svelte';
interface Props {
jobs: QueuesResponseDto;
jobs: QueuesResponseLegacyDto;
}
let { jobs = $bindable() }: Props = $props();
@@ -4,16 +4,16 @@
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable } from '@immich/ui';
import { AppShell, AppShellHeader, AppShellSidebar, Scrollable, type BreadcrumbItem } from '@immich/ui';
import type { Snippet } from 'svelte';
type Props = {
title: string;
breadcrumbs: BreadcrumbItem[];
buttons?: Snippet;
children?: Snippet;
};
let { title, buttons, children }: Props = $props();
let { breadcrumbs, buttons, children }: Props = $props();
</script>
<AppShell>
@@ -24,7 +24,7 @@
<AdminSidebar />
</AppShellSidebar>
<TitleLayout {title} {buttons}>
<TitleLayout {breadcrumbs} {buttons}>
<Scrollable class="grow">
<PageContent>
{@render children?.()}
@@ -1,26 +1,20 @@
<script lang="ts">
import { Text } from '@immich/ui';
import { Breadcrumbs, type BreadcrumbItem } from '@immich/ui';
import { mdiSlashForward } from '@mdi/js';
import type { Snippet } from 'svelte';
interface Props {
id?: string;
title?: string;
description?: string;
type Props = {
breadcrumbs: BreadcrumbItem[];
buttons?: Snippet;
children?: Snippet;
}
};
let { id, title, description, buttons, children }: Props = $props();
let { breadcrumbs, buttons, children }: Props = $props();
</script>
<div class="h-full flex flex-col">
<div class="flex h-16 w-full place-items-center justify-between border-b p-2">
<div class="flex gap-1">
<div class="font-medium outline-none" tabindex="-1" {id}>{title}</div>
{#if description}
<Text color="muted">{description}</Text>
{/if}
</div>
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
{@render buttons?.()}
</div>
{@render children?.()}
@@ -9,7 +9,6 @@
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
import { Icon } from '@immich/ui';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
places?: AssetResponseDto[];
@@ -70,39 +69,27 @@
},
};
let filteredPlaces: AssetResponseDto[] = $state([]);
let groupedPlaces: PlacesGroup[] = $state([]);
const filteredPlaces = $derived.by(() => {
const searchQueryNormalized = normalizeSearchString(searchQuery);
return searchQueryNormalized
? places.filter((place) => normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized))
: places;
});
let placesGroupOption: string = $state(PlacesGroupBy.None);
let hasPlaces = $derived(places.length > 0);
// Step 1: Filter using the given search query.
run(() => {
if (searchQuery) {
const searchQueryNormalized = normalizeSearchString(searchQuery);
filteredPlaces = places.filter((place) => {
return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized);
});
} else {
filteredPlaces = places;
}
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
$effect(() => {
searchResultCount = filteredPlaces.length;
});
// Step 2: Group places.
run(() => {
placesGroupOption = getSelectedPlacesGroupOption(userSettings);
const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None];
groupedPlaces = groupFunc(filteredPlaces);
$effect(() => {
placesGroupIds = groupedPlaces.map(({ id }) => id);
});
</script>
{#if hasPlaces}
{#if places.length > 0}
<!-- Album Cards -->
{#if placesGroupOption === PlacesGroupBy.None}
<PlacesCardGroup places={groupedPlaces[0].places} />
@@ -7,20 +7,11 @@
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
stats?: ServerStatsResponseDto;
}
type Props = {
stats: ServerStatsResponseDto;
};
let {
stats = {
photos: 0,
videos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [],
},
}: Props = $props();
const { stats }: Props = $props();
const zeros = (value: number) => {
const maxLength = 13;
@@ -27,7 +27,7 @@
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
let searchWord: string = $state('');
let latestSearchTimeout: number;
let showLoadingSpinner = $state(false);
@@ -52,9 +52,6 @@
});
$effect(() => {
if (places) {
suggestedPlaces = places.slice(0, 5);
}
if (searchWord === '') {
suggestedPlaces = [];
}
@@ -33,37 +33,36 @@
children,
}: Props = $props();
let left: number = $state(0);
let top: number = $state(0);
const swap = (direction: string) => (direction === 'left' ? 'right' : 'left');
const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction);
const position = $derived.by(() => {
if (!menuElement) {
return { left: 0, top: 0 };
}
const rect = menuElement.getBoundingClientRect();
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
return { left, top };
});
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
$effect(() => {
if (menuElement) {
let layoutDirection = direction;
if (languageManager.rtl) {
layoutDirection = direction === 'left' ? 'right' : 'left';
}
const rect = menuElement.getBoundingClientRect();
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
}
});
</script>
<div
bind:clientHeight={height}
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1"
style:left="{left}px"
style:top="{top}px"
style:left="{position.left}px"
style:top="{position.top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {
@@ -64,10 +64,11 @@
}
}
let makeFilter = $derived(filters.make);
let modelFilter = $derived(filters.model);
let lensModelFilter = $derived(filters.lensModel);
const makeFilter = $derived(filters.make);
const modelFilter = $derived(filters.model);
const lensModelFilter = $derived(filters.lensModel);
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => {
handlePromiseError(updateMakes());
});
@@ -7,11 +7,10 @@
</script>
<script lang="ts">
import { run } from 'svelte/legacy';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -66,15 +65,12 @@
}
let countryFilter = $derived(filters.country);
let stateFilter = $derived(filters.state);
run(() => {
handlePromiseError(updateCountries());
});
run(() => {
handlePromiseError(updateStates(countryFilter));
});
run(() => {
handlePromiseError(updateCities(countryFilter, stateFilter));
});
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => handlePromiseError(updateStates(countryFilter)));
$effect(() => handlePromiseError(updateCities(countryFilter, stateFilter)));
onMount(() => updateCountries());
</script>
<div id="location-selection">
@@ -15,7 +15,7 @@
let { sharedLink, preload = false, class: className = '' }: Props = $props();
</script>
<div class="relative shrink-0 size-24">
<div class="relative shrink-0 size-22">
{#if sharedLink?.album}
<AlbumCover album={sharedLink.album} class={className} {preload} />
{:else if sharedLink.assets[0]}
@@ -2,10 +2,10 @@
import ActionButton from '$lib/components/ActionButton.svelte';
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
import { AppRoute } from '$lib/constants';
import Badge from '$lib/elements/Badge.svelte';
import { getSharedLinkActions } from '$lib/services/shared-link.service';
import { locale } from '$lib/stores/preferences.store';
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { Badge, ContextMenuButton, MenuItemType, Text } from '@immich/ui';
import { DateTime, type ToRelativeUnit } from 'luxon';
import { t } from 'svelte-i18n';
@@ -31,7 +31,7 @@
}
};
const SharedLinkActions = $derived(getSharedLinkActions($t, sharedLink));
const { Edit, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div
@@ -44,64 +44,65 @@
>
<ShareCover class="transition-all duration-300 hover:shadow-lg" {sharedLink} />
<div class="flex flex-col justify-between">
<div class="info-top">
<div class="font-mono text-xs font-semibold text-gray-500 dark:text-gray-400">
{#if isExpired}
<p class="font-bold text-red-600 dark:text-red-400">{$t('expired')}</p>
{:else if expiresAt}
<p>
{$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })}
</p>
{:else}
<p>{$t('expires_date', { values: { date: '∞' } })}</p>
{/if}
</div>
<div class="flex flex-col gap-2">
<Text size="large" color="primary" class="flex place-items-center gap-2 break-all">
{#if sharedLink.type === SharedLinkType.Album}
{sharedLink.album?.albumName}
{:else if sharedLink.type === SharedLinkType.Individual}
{$t('individual_share')}
{/if}
</Text>
<div class="text-sm pb-2">
<p class="flex place-items-center gap-2 text-primary break-all uppercase">
{#if sharedLink.type === SharedLinkType.Album}
{sharedLink.album?.albumName}
{:else if sharedLink.type === SharedLinkType.Individual}
{$t('individual_share')}
{/if}
</p>
<p class="text-sm">{sharedLink.description ?? ''}</p>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xl">
{#if sharedLink.allowUpload}
<Badge rounded="full"><span class="text-xs px-1">{$t('upload')}</span></Badge>
<div class="flex flex-wrap gap-1">
{#if isExpired}
<Badge size="small" color="danger">{$t('expired')}</Badge>
{:else if expiresAt}
<Badge size="small" color="secondary">
{$t('expires_date', { values: { date: getCountDownExpirationDate(expiresAt, now) } })}
</Badge>
{:else}
<Badge size="small" color="secondary">{$t('expires_date', { values: { date: '∞' } })}</Badge>
{/if}
{#if sharedLink.allowDownload}
<Badge rounded="full"><span class="text-xs px-1">{$t('download')}</span></Badge>
{#if sharedLink.slug}
<Badge size="small" color="secondary">{$t('custom_url')}</Badge>
{/if}
{#if sharedLink.allowUpload}
<Badge size="small" color="secondary">{$t('upload')}</Badge>
{/if}
{#if sharedLink.showMetadata && sharedLink.allowDownload}
<Badge size="small" color="secondary">{$t('download')}</Badge>
{/if}
{#if sharedLink.showMetadata}
<Badge rounded="full"><span class="uppercase text-xs px-1">{$t('exif')}</span></Badge>
<Badge size="small" color="secondary">{$t('exif')}</Badge>
{/if}
{#if sharedLink.password}
<Badge rounded="full"><span class="text-xs px-1">{$t('password')}</span></Badge>
{/if}
{#if sharedLink.slug}
<Badge rounded="full"><span class="text-xs px-1">{$t('custom_url')}</span></Badge>
<Badge size="small" color="secondary">{$t('password')}</Badge>
{/if}
</div>
{#if sharedLink.description}
<Text size="small" class="line-clamp-1">{sharedLink.description}</Text>
{/if}
</div>
</svelte:element>
<div class="flex flex-auto flex-col place-content-center place-items-end text-end ms-4">
<div class="sm:flex hidden">
<ActionButton action={SharedLinkActions.Edit} />
<ActionButton action={SharedLinkActions.Copy} />
<ActionButton action={SharedLinkActions.Delete} />
<ActionButton action={Edit} />
<ActionButton action={Copy} />
<ActionButton action={Delete} />
</div>
<div class="sm:hidden">
<ActionButton action={SharedLinkActions.ContextMenu} />
<ContextMenuButton
aria-label={$t('shared_link_options')}
position="top-right"
items={[Edit, Copy, MenuItemType.Divider, Delete]}
/>
</div>
</div>
</div>
@@ -0,0 +1,66 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
type Props = {
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
asset: TimelineAsset;
position: CommonPosition;
},
]
>;
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
</div>
<style>
[data-image-grid] {
user-select: none;
}
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
type Props = {
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
let { isUploading } = uploadAssetsStore;
let hoveredDayGroup = $state<string | null>(null);
const isMouseOverGroup = $derived(hoveredDayGroup !== null);
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
onmouseleave={() => (hoveredDayGroup = null)}
>
<!-- Month title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<AssetLayout
{manager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{/snippet}
</AssetLayout>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
</style>
@@ -351,7 +351,10 @@
void onScrub?.(scrubData);
};
const getTouch = (event: TouchEvent) => {
// desktop safari does not support this since Apple does not have desktop touch devices
// eslint-disable-next-line tscompat/tscompat
if (event.touches.length === 1) {
// eslint-disable-next-line tscompat/tscompat
return event.touches[0];
}
return null;
@@ -362,6 +365,8 @@
isHover = false;
return;
}
// desktop safari does not support this since Apple does not have desktop touch devices
// eslint-disable-next-line tscompat/tscompat
const elements = document.elementsFromPoint(touch.clientX, touch.clientY);
const isHoverScrollbar =
findElementBestY(elements, 0, 'scrubber', 'time-label', 'lead-in', 'lead-out') !== undefined;
@@ -370,6 +375,7 @@
if (isHoverScrollbar) {
handleMouseEvent({
// eslint-disable-next-line tscompat/tscompat
clientY: touch.clientY,
isDragging: true,
});
@@ -388,6 +394,7 @@
const touch = getTouch(event);
if (touch && isDragging) {
handleMouseEvent({
// eslint-disable-next-line tscompat/tscompat
clientY: touch.clientY,
});
} else {
+81 -18
View File
@@ -2,6 +2,8 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Month from '$lib/components/timeline/Month.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
@@ -19,13 +21,12 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import TimelineDateGroup from './TimelineDateGroup.svelte';
interface Props {
isSelectionMode?: boolean;
@@ -55,7 +56,7 @@
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
@@ -88,7 +89,7 @@
onEscape = () => {},
children,
empty,
customLayout,
customThumbnailLayout,
onThumbnailClick,
}: Props = $props();
@@ -400,7 +401,8 @@
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
@@ -420,7 +422,7 @@
}
};
const handleSelectAssets = async (asset: TimelineAsset) => {
const onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
@@ -542,6 +544,40 @@
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
void onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter(({ id }) => assetInteraction.hasSelectedAsset(id)).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount === assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
};
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -651,21 +687,48 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<TimelineDateGroup
{withStacked}
{showArchiveIcon}
<Month
{assetInteraction}
{timelineManager}
{isSelectionMode}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
{albumUsers}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
{customLayout}
{onThumbnailClick}
/>
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
return;
}
void onSelectAssets(asset);
}}
onMouseEvent={() => handleSelectAssetCandidates(asset)}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
</div>
{/if}
{/each}
@@ -1,250 +0,0 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import type { UserResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { type Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
interface Props {
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
albumUsers?: UserResponseDto[];
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
timelineManager,
albumUsers = [],
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onThumbnailClick,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
}
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
onmouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
>
<!-- Date group title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
>
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<div
data-image-grid
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- {#if viewerAsset.intersecting} -->
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div>
<!-- {/if} -->
{/each}
</div>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>
@@ -1,6 +1,5 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { DayGroup } from './day-group.svelte';
import type { MonthGroup } from './month-group.svelte';
import type { TimelineAsset } from './types';
@@ -10,8 +9,10 @@ export class GroupInsertionCache {
[year: number]: { [month: number]: { [day: number]: DayGroup } };
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDayGroups = new SvelteSet<DayGroup>();
newDayGroups = new SvelteSet<DayGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
changedDayGroups = new Set<DayGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
newDayGroups = new Set<DayGroup>();
getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined {
return this.#lookupCache[year]?.[month]?.[day];
@@ -32,7 +33,8 @@ export class GroupInsertionCache {
}
get updatedBuckets() {
const updated = new SvelteSet<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.changedDayGroups) {
updated.add(group.monthGroup);
}
@@ -40,7 +42,8 @@ export class GroupInsertionCache {
}
get bucketsWithNewDayGroups() {
const updated = new SvelteSet<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.newDayGroups) {
updated.add(group.monthGroup);
}
@@ -46,7 +46,7 @@ export async function loadFromTimeBuckets(
}
}
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
const unprocessedAssets = monthGroup.addAssets(bucketResponse, true);
if (unprocessedAssets.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(
@@ -153,7 +153,7 @@ export class MonthGroup {
};
}
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
addAssets(bucketAssets: TimeBucketAssetResponseDto, preSorted: boolean) {
const addContext = new GroupInsertionCache();
for (let i = 0; i < bucketAssets.id.length; i++) {
const { localDateTime, fileCreatedAt } = getTimes(
@@ -194,6 +194,9 @@ export class MonthGroup {
}
this.addTimelineAsset(timelineAsset, addContext);
}
if (preSorted) {
return addContext.unprocessedAssets;
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(this.#sortOrder);
+30 -67
View File
@@ -2,7 +2,7 @@
import { type ServerAboutResponseDto } from '@immich/sdk';
import { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js';
import { siDiscord } from 'simple-icons';
import { type SimpleIcon, siDiscord } from 'simple-icons';
import { t } from 'svelte-i18n';
interface Props {
@@ -13,94 +13,57 @@
let { onClose, info }: Props = $props();
</script>
{#snippet link(url: string, icon: string | SimpleIcon, text: string)}
<div>
<a href={url} target="_blank" rel="noreferrer">
<Icon {icon} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block">
{text}
</p>
</a>
</div>
{/snippet}
<Modal title={$t('support_and_feedback')} {onClose} size="small">
<ModalBody>
<p>{$t('official_immich_resources')}</p>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-2 mt-5">
<div>
<a href="https://docs.{info.version}.archive.immich.app/overview/introduction" target="_blank" rel="noreferrer">
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
{$t('documentation')}
</p>
</a>
</div>
<div class="flex flex-col gap-2 mt-5">
{@render link(
`https://docs.${info.version}.archive.immich.app/overview/introduction`,
mdiInformationOutline,
$t('documentation'),
)}
<div>
<a href="https://github.com/immich-app/immich/" target="_blank" rel="noreferrer">
<Icon icon={mdiGithub} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('source')}
</p>
</a>
</div>
{@render link('https://github.com/immich-app/immich/', mdiGithub, $t('source'))}
<div>
<a href="https://discord.immich.app" target="_blank" rel="noreferrer">
<Icon icon={siDiscord} class="inline-block" size="1.5em" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('discord')}
</p>
</a>
</div>
{@render link('https://discord.immich.app', siDiscord, $t('discord'))}
<div>
<a href="https://github.com/immich-app/immich/issues/new/choose" target="_blank" rel="noreferrer">
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('bugs_and_feature_requests')}
</p>
</a>
</div>
{@render link(
'https://github.com/immich-app/immich/issues/new/choose',
mdiBugOutline,
$t('bugs_and_feature_requests'),
)}
</div>
{#if info.thirdPartyBugFeatureUrl || info.thirdPartySourceUrl || info.thirdPartyDocumentationUrl || info.thirdPartySupportUrl}
<p class="mt-5">{$t('third_party_resources')}</p>
<p class="text-sm mt-1">
{$t('support_third_party_description')}
</p>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-2 mt-5">
<div class="flex flex-col gap-2 mt-5">
{#if info.thirdPartyDocumentationUrl}
<div>
<a href={info.thirdPartyDocumentationUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
{$t('documentation')}
</p>
</a>
</div>
{@render link(info.thirdPartyDocumentationUrl, mdiInformationOutline, $t('documentation'))}
{/if}
{#if info.thirdPartySourceUrl}
<div>
<a href={info.thirdPartySourceUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiGit} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('source')}
</p>
</a>
</div>
{@render link(info.thirdPartySourceUrl, mdiGit, $t('source'))}
{/if}
{#if info.thirdPartySupportUrl}
<div>
<a href={info.thirdPartySupportUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiFaceAgent} class="inline-block" size="1.5em" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('support')}
</p>
</a>
</div>
{@render link(info.thirdPartySupportUrl, mdiFaceAgent, $t('support'))}
{/if}
{#if info.thirdPartyBugFeatureUrl}
<div>
<a href={info.thirdPartyBugFeatureUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('bugs_and_feature_requests')}
</p>
</a>
</div>
{@render link(info.thirdPartyBugFeatureUrl, mdiBugOutline, $t('bugs_and_feature_requests'))}
{/if}
</div>
{/if}
+12 -13
View File
@@ -7,7 +7,6 @@ import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
import type { ActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -20,7 +19,7 @@ import {
updateLibrary,
type LibraryResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
@@ -28,13 +27,13 @@ export const getLibrariesActions = ($t: MessageFormatter) => {
const ScanAll: ActionItem = {
title: $t('scan_all_libraries'),
icon: mdiSync,
onSelect: () => void handleScanAllLibraries(),
onAction: () => void handleScanAllLibraries(),
};
const Create: ActionItem = {
title: $t('create_library'),
icon: mdiPlusBoxOutline,
onSelect: () => void handleCreateLibrary(),
onAction: () => void handleCreateLibrary(),
};
return { ScanAll, Create };
@@ -44,32 +43,32 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
const Rename: ActionItem = {
icon: mdiPencilOutline,
title: $t('rename'),
onSelect: () => void modalManager.show(LibraryRenameModal, { library }),
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
color: 'danger',
onSelect: () => void handleDeleteLibrary(library),
onAction: () => void handleDeleteLibrary(library),
};
const AddFolder: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }),
onAction: () => void modalManager.show(LibraryFolderAddModal, { library }),
};
const AddExclusionPattern: ActionItem = {
icon: mdiPlusBoxOutline,
title: $t('add'),
onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
};
const Scan: ActionItem = {
icon: mdiSync,
title: $t('scan_library'),
onSelect: () => void handleScanLibrary(library),
onAction: () => void handleScanLibrary(library),
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
@@ -79,13 +78,13 @@ export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryRe
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteLibraryFolder(library, folder),
onAction: () => void handleDeleteLibraryFolder(library, folder),
};
return { Edit, Delete };
@@ -99,13 +98,13 @@ export const getLibraryExclusionPatternActions = (
const Edit: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern),
onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern),
};
return { Edit, Delete };
+13 -24
View File
@@ -16,48 +16,37 @@ import {
type SharedLinkEditDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { MenuItemType, menuManager, modalManager, toastManager, type MenuItem } from '@immich/ui';
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiDotsVertical, mdiQrcode } from '@mdi/js';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiPencilOutline, mdiQrcode, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLinkResponseDto) => {
const Edit: MenuItem = {
const Edit: ActionItem = {
title: $t('edit_link'),
icon: mdiCircleEditOutline,
onSelect: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
icon: mdiPencilOutline,
onAction: () => void goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
};
const Delete: MenuItem = {
const Delete: ActionItem = {
title: $t('delete_link'),
icon: mdiDelete,
icon: mdiTrashCanOutline,
color: 'danger',
onSelect: () => void handleDeleteSharedLink(sharedLink),
onAction: () => void handleDeleteSharedLink(sharedLink),
};
const Copy: MenuItem = {
const Copy: ActionItem = {
title: $t('copy_link'),
icon: mdiContentCopy,
onSelect: () => void copyToClipboard(asUrl(sharedLink)),
onAction: () => void copyToClipboard(asUrl(sharedLink)),
};
const ViewQrCode: MenuItem = {
const ViewQrCode: ActionItem = {
title: $t('view_qr_code'),
icon: mdiQrcode,
onSelect: () => void handleShowSharedLinkQrCode(sharedLink),
onAction: () => void handleShowSharedLinkQrCode(sharedLink),
};
const ContextMenu: MenuItem = {
title: $t('shared_link_options'),
icon: mdiDotsVertical,
onSelect: ({ event }) =>
void menuManager.show({
target: event.currentTarget as HTMLElement,
position: 'top-right',
items: [Edit, Copy, MenuItemType.Divider, Delete],
}),
};
return { Edit, Delete, Copy, ViewQrCode, ContextMenu };
return { Edit, Delete, Copy, ViewQrCode };
};
const asUrl = (sharedLink: SharedLinkResponseDto) => {
@@ -1,12 +1,11 @@
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { ActionItem } from '$lib/types';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { getConfig, updateConfig, type ServerFeaturesDto, type SystemConfigDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import { isEqual } from 'lodash-es';
import type { MessageFormatter } from 'svelte-i18n';
@@ -19,20 +18,20 @@ export const getSystemConfigActions = (
const CopyToClipboard: ActionItem = {
title: $t('copy_to_clipboard'),
icon: mdiContentCopy,
onSelect: () => void handleCopyToClipboard(config),
onAction: () => void handleCopyToClipboard(config),
};
const Download: ActionItem = {
title: $t('export_as_json'),
icon: mdiDownload,
onSelect: () => handleDownloadConfig(config),
onAction: () => handleDownloadConfig(config),
};
const Upload: ActionItem = {
title: $t('import_from_json'),
icon: mdiUpload,
$if: () => !featureFlags.configFile,
onSelect: () => handleUploadConfig(),
onAction: () => handleUploadConfig(),
};
return { CopyToClipboard, Download, Upload };
+12 -46
View File
@@ -1,13 +1,11 @@
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import type { ActionItem } from '$lib/types';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -21,45 +19,33 @@ import {
type UserAdminResponseDto,
type UserAdminUpdateDto,
} from '@immich/sdk';
import { MenuItemType, menuManager, modalManager, toastManager } from '@immich/ui';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiDeleteRestore,
mdiDotsVertical,
mdiEyeOutline,
mdiLockReset,
mdiLockSmart,
mdiPencilOutline,
mdiPlusBoxOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
export const getUserAdminsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_user'),
icon: mdiPlusBoxOutline,
onSelect: () => void modalManager.show(UserCreateModal, {}),
onAction: () => void modalManager.show(UserCreateModal, {}),
};
return { Create };
};
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
const View: ActionItem = {
icon: mdiEyeOutline,
title: $t('view'),
onSelect: () => void goto(`/admin/users/${user.id}`),
};
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onSelect: () => void modalManager.show(UserEditModal, { user }),
onAction: () => void modalManager.show(UserEditModal, { user }),
};
const Delete: ActionItem = {
@@ -67,7 +53,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
title: $t('delete'),
color: 'danger',
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onSelect: () => void modalManager.show(UserDeleteConfirmModal, { user }),
onAction: () => void modalManager.show(UserDeleteConfirmModal, { user }),
};
const Restore: ActionItem = {
@@ -75,47 +61,23 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
title: $t('restore'),
color: 'primary',
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
onSelect: () => void modalManager.show(UserRestoreConfirmModal, { user }),
props: {
title: $t('admin.user_restore_scheduled_removal', {
values: { date: getDeleteDate(user.deletedAt!) },
}),
},
onAction: () => void modalManager.show(UserRestoreConfirmModal, { user }),
};
const ResetPassword: ActionItem = {
icon: mdiLockReset,
title: $t('reset_password'),
$if: () => get(authUser).id !== user.id,
onSelect: () => void handleResetPasswordUserAdmin(user),
onAction: () => void handleResetPasswordUserAdmin(user),
};
const ResetPinCode: ActionItem = {
icon: mdiLockSmart,
title: $t('reset_pin_code'),
onSelect: () => void handleResetPinCodeUserAdmin(user),
onAction: () => void handleResetPinCodeUserAdmin(user),
};
const ContextMenu: ActionItem = {
icon: mdiDotsVertical,
title: $t('actions'),
onSelect: ({ event }) =>
void menuManager.show({
target: event.currentTarget as HTMLElement,
position: 'top-right',
items: [
View,
Update,
ResetPassword,
ResetPinCode,
get(authUser).id === user.id ? undefined : MenuItemType.Divider,
Restore,
Delete,
].filter(Boolean),
}),
};
return { View, Update, Delete, Restore, ResetPassword, ResetPinCode, ContextMenu };
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
};
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
@@ -172,6 +134,10 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
}
};
export const handleNavigateUserAdmin = async (user: UserAdminResponseDto) => {
await goto(`/admin/users/${user.id}`);
};
// TODO move password reset server-side
const generatePassword = (length: number = 16) => {
let generatedPassword = '';
+5 -3
View File
@@ -19,21 +19,23 @@ export type OcrBoundingBox = {
class OcrManager {
#data = $state<OcrBoundingBox[]>([]);
showOverlay = $state(false);
hasOcrData = $state(false);
#hasOcrData = $derived(this.#data.length > 0);
get data() {
return this.#data;
}
get hasOcrData() {
return this.#hasOcrData;
}
async getAssetOcr(id: string) {
this.#data = await getAssetOcr({ id });
this.hasOcrData = this.#data.length > 0;
}
clear() {
this.#data = [];
this.showOverlay = false;
this.hasOcrData = false;
}
toggleOcrBoundingBox() {
-4
View File
@@ -1,8 +1,4 @@
import type { ServerVersionResponseDto } from '@immich/sdk';
import type { MenuItem } from '@immich/ui';
import type { HTMLAttributes } from 'svelte/elements';
export type ActionItem = MenuItem & { props?: Omit<HTMLAttributes<HTMLElement>, 'color'> };
export interface ReleaseEvent {
isAvailable: boolean;
+5 -7
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { page } from '$app/state';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
@@ -15,13 +13,13 @@
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
run(() => {
if ($page.data.asset) {
setAsset($page.data.asset);
$effect.pre(() => {
if (page.data.asset) {
setAsset(page.data.asset);
} else {
$showAssetViewer = false;
}
const asset = $page.url.searchParams.get('at');
const asset = page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
});
</script>
@@ -575,7 +575,15 @@
onClick={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
@@ -85,7 +85,11 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
@@ -511,7 +511,11 @@
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
@@ -146,7 +146,14 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
<ArchiveAction
menuItem
onArchive={(ids, visibility) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
@@ -44,7 +44,7 @@
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -71,11 +71,10 @@
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// we want this to *only* be reactive on `terms`
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
terms;
setTimeout(() => {
handlePromiseError(onSearchQueryUpdate());
});
untrack(() => handlePromiseError(onSearchQueryUpdate()));
});
const onEscape = () => {
@@ -196,7 +196,7 @@
withStacked
onThumbnailClick={handleThumbnailClick}
>
{#snippet customLayout(asset: TimelineAsset)}
{#snippet customThumbnailLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')}
+2 -2
View File
@@ -22,7 +22,6 @@
import { modalManager, setTranslations } from '@immich/ui';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
import '../app.css';
interface Props {
@@ -69,7 +68,8 @@
afterNavigate(() => {
showNavigationLoadingBar = false;
});
run(() => {
$effect.pre(() => {
if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
openWebsocketConnection();
} else {
@@ -5,7 +5,13 @@
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
import { asyncTimeout } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getQueuesLegacy, QueueCommand, QueueName, runQueueCommandLegacy, type QueuesResponseDto } from '@immich/sdk';
import {
getQueuesLegacy,
QueueCommand,
QueueName,
runQueueCommandLegacy,
type QueuesResponseLegacyDto,
} from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
@@ -18,7 +24,7 @@
let { data }: Props = $props();
let jobs: QueuesResponseDto | undefined = $state();
let jobs: QueuesResponseLegacyDto | undefined = $state();
let running = true;
@@ -52,7 +58,7 @@
});
</script>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={0}>
{#if pausedJobs.length > 0}
@@ -58,7 +58,7 @@
onLibraryDelete={handleDeleteLibrary}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
{#if libraries.length > 0}
@@ -39,7 +39,12 @@
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout
breadcrumbs={[
{ title: $t('admin.external_library_management'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT },
{ title: library.name },
]}
>
{#snippet buttons()}
<div class="flex justify-end gap-2">
<HeaderButton action={Scan} />
+14 -16
View File
@@ -1,35 +1,33 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
import { asyncTimeout } from '$lib/utils';
import { getServerStatistics } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import type { PageData } from './$types';
interface Props {
type Props = {
data: PageData;
}
};
let { data = $bindable() }: Props = $props();
const { data }: Props = $props();
let running = true;
let stats = $state(data.stats);
onMount(async () => {
while (running) {
data.stats = await getServerStatistics();
await asyncTimeout(5000);
}
});
const updateStatistics = async () => {
stats = await getServerStatistics();
};
onDestroy(() => {
running = false;
onMount(() => {
const interval = setInterval(() => void updateStatistics(), 5000);
return () => clearInterval(interval);
});
</script>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
<ServerStatisticsPanel stats={data.stats} />
<ServerStatisticsPanel {stats} />
</section>
</section>
</AdminPageLayout>
@@ -215,7 +215,7 @@
);
</script>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<div class="hidden lg:block">
+16 -22
View File
@@ -2,12 +2,11 @@
import HeaderButton from '$lib/components/HeaderButton.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { HStack, Icon } from '@immich/ui';
import { Button, HStack, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -18,24 +17,22 @@
let { data }: Props = $props();
let allUsers: UserAdminResponseDto[] = $derived(data.allUsers);
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
const refresh = async () => {
allUsers = await searchUsersAdmin({ withDeleted: true });
};
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
const user = allUsers.find(({ id }) => id === userId);
if (user) {
allUsers = allUsers.filter((user) => user.id !== userId);
const onUpdate = async (user: UserAdminResponseDto) => {
const index = allUsers.findIndex(({ id }) => id === user.id);
if (index === -1) {
allUsers = await searchUsersAdmin({ withDeleted: true });
} else {
allUsers[index] = user;
}
};
const UserAdminsActions = $derived(getUserAdminsActions($t));
const onUpdate = async () => {
await refresh();
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
allUsers = allUsers.filter(({ id }) => id !== userId);
};
const { Create } = $derived(getUserAdminsActions($t));
</script>
<OnEvents
@@ -46,10 +43,10 @@
{onUserAdminDeleted}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>
<HeaderButton action={UserAdminsActions.Create} />
<HeaderButton action={Create} />
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
@@ -64,12 +61,10 @@
>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
<th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">{$t('action')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each allUsers as user (user.id)}
{@const UserAdminActions = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
@@ -91,8 +86,7 @@
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<TableButton action={UserAdminActions.View} />
<TableButton action={UserAdminActions.ContextMenu} />
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
</td>
</tr>
{/each}
+28 -10
View File
@@ -8,6 +8,7 @@
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
import { AppRoute } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { getUserAdminActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
@@ -15,6 +16,7 @@
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Alert,
Badge,
Card,
CardBody,
CardHeader,
@@ -39,6 +41,7 @@
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -77,7 +80,7 @@
return 'bg-primary';
};
const UserAdminActions = $derived(getUserAdminActions($t, user));
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
@@ -90,6 +93,9 @@
await goto(AppRoute.ADMIN_USERS);
}
};
const getDeleteDate = (deletedAt: string): Date =>
DateTime.fromISO(deletedAt).plus({ days: serverConfigManager.value.userDeleteDelay }).toJSDate();
</script>
<OnEvents
@@ -99,14 +105,19 @@
{onUserAdminDeleted}
/>
<AdminPageLayout title={data.meta.title}>
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
>
{#snippet buttons()}
<HStack gap={0}>
<HeaderButton action={UserAdminActions.ResetPassword} />
<HeaderButton action={UserAdminActions.ResetPinCode} />
<HeaderButton action={UserAdminActions.Update} />
<HeaderButton action={UserAdminActions.Restore} />
<HeaderButton action={UserAdminActions.Delete} />
<HeaderButton action={ResetPassword} />
<HeaderButton action={ResetPinCode} />
<HeaderButton action={Update} />
<HeaderButton
action={Restore}
title={$t('admin.user_restore_scheduled_removal', { values: { date: getDeleteDate(user.deletedAt!) } })}
/>
<HeaderButton action={Delete} />
</HStack>
{/snippet}
<div>
@@ -116,9 +127,16 @@
{/if}
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex gap-4 items-center my-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
<div class="col-span-full flex flex-col gap-4 my-4">
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
{#if user.isAdmin}
<div>
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
</div>
{/if}
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
+6 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
@@ -95,7 +95,11 @@
},
]);
let index = $state(0);
const index = $derived.by(() => {
const stepState = page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
return temporaryIndex === -1 ? 0 : temporaryIndex;
});
let userRole = $derived(
$user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER,
);
@@ -114,12 +118,6 @@
);
};
$effect(() => {
const stepState = $page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
index = temporaryIndex === -1 ? 0 : temporaryIndex;
});
const previousStepIndex = $derived(
onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
);