mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'immich-main' into feat/shared-album-owner-labels
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.11.1
|
||||
|
||||
+8
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+9
-1
@@ -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 />
|
||||
|
||||
+5
-1
@@ -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')}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user