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

This commit is contained in:
izzy
2026-01-15 17:08:06 +00:00
114 changed files with 11993 additions and 1358 deletions
@@ -32,7 +32,7 @@
.filter(Boolean)
.join(' • ');
const { ViewQrCode, Copy } = $derived(getSharedLinkActions($t, sharedLink));
const { ViewQrCode, Copy, Delete } = $derived(getSharedLinkActions($t, sharedLink));
</script>
<div class="flex justify-between items-center">
@@ -43,5 +43,6 @@
<div class="flex">
<ActionButton action={ViewQrCode} />
<ActionButton action={Copy} />
<ActionButton action={Delete} />
</div>
</div>
@@ -5,7 +5,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
import {
AlbumFilter,
@@ -202,7 +202,7 @@
}
case 'share': {
await modalManager.show(AlbumShareModal, { album: selectedAlbum });
await modalManager.show(AlbumOptionsModal, { album: selectedAlbum });
break;
}
@@ -19,6 +19,7 @@
import { user } from '$lib/stores/user.store';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
@@ -52,8 +53,6 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
@@ -72,9 +71,7 @@
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
@@ -90,8 +87,6 @@
onAction,
onUndoDelete,
onClose,
onNext,
onPrevious,
onRandom,
copyImage = $bindable(),
}: Props = $props();
@@ -108,6 +103,8 @@
const stackSelectedThumbnailSize = 65;
const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
@@ -235,14 +232,15 @@
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
@@ -383,7 +381,6 @@
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
@@ -406,6 +403,42 @@
cursor.current = update;
}
};
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer';
}
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
}
if (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer';
}
if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea';
}
return 'PhotoViewer';
});
const showActivityStatus = $derived(
$slideshowState === SlideshowState.None &&
isShared &&
((album && album.isActivityEnabled) || activityManager.commentCount > 0) &&
!activityManager.isLoading,
);
const showOcrButton = $derived(
$slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image &&
!isShowEditor &&
ocrManager.hasOcrData,
);
</script>
<OnEvents {onAssetReplace} {onAssetUpdate} />
@@ -442,7 +475,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex">
<div class="absolute w-full flex justify-center">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
@@ -454,109 +487,97 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
cacheKey={previewStackedAsset.thumbhash}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{/key}
{:else}
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
{#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{/if}
{:else}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
/>
</div>
{/if}
{#if showActivityStatus}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{#if showOcrButton}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
@@ -22,7 +22,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -164,11 +164,7 @@
imageError = imageLoaded = true;
};
onMount(() => {
return () => {
preloadManager.cancelPreloadUrl(imageLoaderUrl);
};
});
onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
@@ -181,9 +177,11 @@
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
});
}
lastUrl = imageLoaderUrl;
});
@@ -26,7 +26,7 @@
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
@@ -651,8 +651,6 @@
bind:this={memoryGallery}
>
<GalleryViewer
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets}
viewport={galleryViewport}
{assetInteraction}
@@ -27,13 +27,13 @@
{#if icon}
<Icon {icon} size="40" />
{/if}
<Text size="giant" class="font-medium">{title}</Text>
<Text size="giant" fontWeight="medium">{title}</Text>
</div>
<div class="relative mx-auto font-immich-mono text-2xl font-medium">
<div class="relative mx-auto font-mono text-2xl font-medium">
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
{#if unit}
<Code color="muted" class="font-immich-mono absolute -top-5 end-1 font-light p-0">{unit}</Code>
<Code color="muted" class="font-mono absolute -top-5 end-1 font-light p-0">{unit}</Code>
{/if}
</div>
@@ -1,9 +1,20 @@
<script lang="ts">
import StatsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { ServerStatsResponseDto } from '@immich/sdk';
import { Code, Icon, Text } from '@immich/ui';
import {
Code,
FormatBytes,
Icon,
Table,
TableBody,
TableCell,
TableHeader,
TableHeading,
TableRow,
Text,
} from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -26,7 +37,7 @@
<div class="flex flex-col gap-5 my-4">
<div>
<Text class="mb-2 font-medium">{$t('total_usage')}</Text>
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
<div class="hidden justify-between lg:flex gap-4">
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
@@ -39,34 +50,34 @@
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<Text class="font-medium" size="medium">{$t('photos')}</Text>
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
</div>
<div class="relative text-center font-immich-mono text-2xl font-medium">
<div class="relative text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<Text class="font-medium" size="medium">{$t('videos')}</Text>
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
</div>
<div class="relative text-center font-immich-mono text-2xl font-medium">
<div class="relative text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
</div>
</div>
<div class="flex flex-wrap gap-x-5">
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<Text class="font-medium" size="medium">{$t('storage')}</Text>
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
</div>
<div class="relative flex text-center font-immich-mono text-2xl font-medium">
<div class="relative flex text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(statsUsage)}</span><span class="text-primary">{statsUsage}</span>
<div class="absolute -right-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-immich-mono">{statsUsageUnit}</Code>
<div class="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
</div>
</div>
</div>
@@ -75,34 +86,28 @@
</div>
<div>
<Text class="mt-6 mb-2 font-medium">{$t('user_usage_detail')}</Text>
<table class="mt-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-1/4 text-center text-sm font-medium">{$t('user')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('photos')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('videos')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('usage')}</th>
</tr>
</thead>
<tbody
class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<Text class="mb-2 mt-4" fontWeight="medium">{$t('user_usage_detail')}</Text>
<Table striped size="small">
<TableHeader>
<TableHeading class="w-1/4">{$t('user')}</TableHeading>
<TableHeading class="w-1/4">{$t('photos')}</TableHeading>
<TableHeading class="w-1/4">{$t('videos')}</TableHeading>
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
</TableHeader>
<TableBody class="block max-h-80 overflow-y-auto">
{#each stats.usageByUser as user (user.userId)}
<tr class="flex h-12.5 w-full place-items-center text-center even:bg-subtle/20 odd:bg-subtle/80">
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td
<TableRow>
<TableCell class="w-1/4">{user.userName}</TableCell>
<TableCell class="w-1/4">
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
>
<td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.videos.toLocaleString($locale)} ({getByteUnitString(user.usageVideos, $locale, 0)})</td
<TableCell class="w-1/4">
{user.videos.toLocaleString($locale)} (<FormatBytes bytes={user.usageVideos} precision={0} />)</TableCell
>
<td class="w-1/4 text-ellipsis px-2 text-sm">
{getByteUnitString(user.usage, $locale, 0)}
<TableCell class="w-1/4">
<FormatBytes bytes={user.usage} precision={0} />
{#if user.quotaSizeInBytes !== null}
/ {getByteUnitString(user.quotaSizeInBytes, $locale, 0)}
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
{/if}
<span class="text-primary">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
@@ -114,10 +119,10 @@
({$t('unlimited')})
{/if}
</span>
</td>
</tr>
</TableCell>
</TableRow>
{/each}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
@@ -144,13 +144,7 @@
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={{ current: asset }}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
/>
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
{/await}
{/await}
{/if}
@@ -14,7 +14,13 @@
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import {
archiveAssets,
cancelMultiselect,
getNextAsset,
getPreviousAsset,
navigateToAsset,
} from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
@@ -26,7 +32,6 @@
import { t } from 'svelte-i18n';
type Props = {
initialAssetId?: string;
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
@@ -34,9 +39,6 @@
viewport: Viewport;
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
@@ -44,7 +46,6 @@
};
let {
initialAssetId = undefined,
assets = $bindable(),
assetInteraction,
disableAssetSelect = false,
@@ -52,16 +53,13 @@
viewport,
onIntersected = undefined,
showAssetName = false,
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
arrowNavigation = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
const geometry = $derived(
getJustifiedLayoutFromAssets(assets, {
@@ -84,14 +82,6 @@
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
};
let currentIndex = 0;
if (initialAssetId && assets.length > 0) {
const index = assets.findIndex(({ id }) => id === initialAssetId);
if (index !== -1) {
currentIndex = index;
}
}
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0);
@@ -105,7 +95,8 @@
});
const updateCurrentAsset = (asset: AssetResponseDto) => {
assets[currentIndex] = asset;
const index = assets.findIndex((oldAsset) => oldAsset.id === asset.id);
assets[index] = asset;
};
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
@@ -124,11 +115,6 @@
}
}
});
const viewAssetHandler = async (asset: TimelineAsset) => {
currentIndex = assets.findIndex((a) => a.id == asset.id);
await setAssetId(assets[currentIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
const selectAllAssets = () => {
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
@@ -294,47 +280,13 @@
})(),
);
const handleNext = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onNext) {
asset = await onNext();
} else {
if (currentIndex >= assets.length - 1) {
return false;
}
currentIndex = currentIndex + 1;
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
}
if (!asset) {
return false;
}
await navigateToAsset(asset);
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return false;
}
};
const handleRandom = async (): Promise<{ id: string } | undefined> => {
if (assets.length === 0) {
return;
}
try {
let asset: { id: string } | undefined;
if (onRandom) {
asset = await onRandom();
} else {
if (assets.length > 0) {
const randomIndex = Math.floor(Math.random() * assets.length);
asset = assets[randomIndex];
}
}
if (!asset) {
return;
}
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
@@ -344,39 +296,6 @@
}
};
const handlePrevious = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onPrevious) {
asset = await onPrevious();
} else {
if (currentIndex <= 0) {
return false;
}
currentIndex = currentIndex - 1;
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
}
if (!asset) {
return false;
}
await navigateToAsset(asset);
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_previous_asset'));
return false;
}
};
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) {
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
@@ -387,11 +306,12 @@
1,
);
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentIndex === assets.length) {
await handlePrevious();
} else {
await setAssetId(assets[currentIndex].id);
return await goto(AppRoute.PHOTOS);
}
if (assetCursor.nextAsset) {
await navigateToAsset(assetCursor.nextAsset);
} else if (assetCursor.previousAsset) {
await navigateToAsset(assetCursor.previousAsset);
}
break;
}
@@ -454,7 +374,7 @@
handleSelectAssets(currentAsset);
return;
}
void viewAssetHandler(currentAsset);
void navigateToAsset(asset);
}}
onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
@@ -467,7 +387,7 @@
/>
{#if showAssetName && !isTimelineAsset(asset)}
<div
class="absolute text-center p-1 text-xs font-immich-mono font-semibold w-full bottom-0 bg-linear-to-t bg-slate-50/75 dark:bg-slate-800/75 overflow-clip text-ellipsis whitespace-pre-wrap"
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-linear-to-t bg-slate-50/75 dark:bg-slate-800/75 overflow-clip text-ellipsis whitespace-pre-wrap"
>
{asset.originalFileName}
</div>
@@ -485,8 +405,6 @@
<AssetViewer
cursor={assetCursor}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
@@ -361,7 +361,7 @@
>
{#snippet children({ feature })}
<div
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-immich-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
@@ -118,7 +118,7 @@
</div>
<Stack class="text-left" gap={1}>
<Text size="tiny" class="text-black dark:text-white font-semibold text-base">{notification.title}</Text>
<Text size="tiny" class="text-black dark:text-white text-base" fontWeight="semi-bold">{notification.title}</Text>
{#if notification.description}
<Text class="overflow-hidden text-gray-600 dark:text-gray-300">{notification.description}</Text>
{/if}
@@ -71,7 +71,7 @@
>
<Stack class="max-h-125">
<div class="flex justify-between items-center mt-4 mx-4">
<Text size="medium" color="secondary" class="font-semibold">{$t('notifications')}</Text>
<Text size="medium" color="secondary" fontWeight="semi-bold">{$t('notifications')}</Text>
<div>
<Button
variant="ghost"
@@ -60,7 +60,7 @@
{disabled}
onCheckedChange={() => handleCheckboxChange(option.value)}
/>
<Label label={option.text} for="{option.value}-checkbox" />
<Label label={option.text} for="{option.value}-checkbox" size="small" />
</div>
{/each}
</div>
@@ -31,7 +31,7 @@
<div>
<div class="flex h-6.5 place-items-center gap-1">
<Label>{title}</Label>
<Label size="small">{title}</Label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
@@ -101,7 +101,7 @@
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={mdiNewBox} size="16" class="text-immich-primary dark:text-immich-dark-primary opacity-80" />
<Text size="tiny" class="font-medium text-gray-700 dark:text-gray-300">
<Text size="tiny" fontWeight="medium" class="text-gray-700 dark:text-gray-300">
{releaseInfo.availableVersion}
</Text>
</div>
@@ -87,7 +87,7 @@
bind:isSelected={isSharingSelected}
></SideBarLink>
<p class="text-xs p-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
<p class="text-xs py-5 ps-6 dark:text-immich-dark-fg uppercase">{$t('library')}</p>
<SideBarLink
title={$t('favorites')}
@@ -51,7 +51,7 @@
/>
</li>
{#each parents as parent (parent)}
<li class="flex gap-2 items-center font-immich-mono text-sm text-nowrap text-primary">
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
<a class="underline hover:font-semibold whitespace-pre-wrap" href={getLink(parent.path)}>
{parent.value}
@@ -59,7 +59,7 @@
</li>
{/each}
<li class="flex gap-2 items-center font-immich-mono text-sm text-nowrap text-primary">
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
<p class="cursor-default whitespace-pre-wrap">{node.value}</p>
</li>
@@ -42,9 +42,7 @@
size="20"
/>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-immich-mono ps-1 pt-1 whitespace-pre-wrap"
>{node.value}</span
>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</a>
{#if isOpen}
@@ -68,7 +68,7 @@
<div class="flex flex-col gap-4 justify-between">
<div class="flex flex-col">
<Text size="tiny" color={isExpired ? 'danger' : 'muted'} class="font-medium">
<Text size="tiny" color={isExpired ? 'danger' : 'muted'} fontWeight="medium">
{#if isExpired}
{$t('expired')}
{:else if expiresAt}
@@ -78,7 +78,7 @@
{/if}
</Text>
<Text size="large" color="primary" class="flex place-items-center gap-2 break-all font-medium">
<Text size="large" color="primary" class="flex place-items-center gap-2 break-all" fontWeight="medium">
{#if sharedLink.type === SharedLinkType.Album}
{sharedLink.album?.albumName}
{:else if sharedLink.type === SharedLinkType.Individual}
@@ -93,7 +93,7 @@
<div class="flex flex-wrap items-center gap-2">
{#each capabilities as capability, index (index)}
<Text size="small" color="primary" class="font-medium">
<Text size="small" color="primary" fontWeight="medium">
{capability}
</Text>
{#if index < capabilities.length - 1}
@@ -588,7 +588,7 @@
>
{#if !usingMobileDevice}
{#if segment.hasLabel}
<div class="absolute end-5 text-[13px] dark:text-immich-dark-fg font-immich-mono bottom-0">
<div class="absolute end-5 text-[13px] dark:text-immich-dark-fg font-mono bottom-0">
{segment.year}
</div>
{/if}
@@ -10,6 +10,7 @@
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
@@ -24,7 +25,6 @@
isShared?: boolean;
album?: AlbumResponseDto;
person?: PersonResponseDto;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
}
@@ -41,7 +41,7 @@
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getNextAsset(asset, false);
@@ -52,9 +52,8 @@
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getPreviousAsset(asset, false);
@@ -86,15 +85,6 @@
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset)));
});
const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => {
if (!targetAsset) {
return false;
}
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
return true;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
@@ -124,8 +114,8 @@
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNavigateToAsset(assetCursor?.nextAsset)) ||
(await handleNavigateToAsset(assetCursor?.previousAsset)) ||
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset));
break;
@@ -197,18 +187,17 @@
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
}
};
onDestroy(() => {
assetCacheManager.invalidate();
});
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
const handleUpdateOrUpload = (asset: AssetResponseDto) => {
if (asset.id === assetCursor.current.id) {
void loadCloseAssets(asset);
}
};
onMount(() => {
const unsubscribes = [
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => onAssetUpdate({ event: 'update', asset })),
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
];
return () => {
for (const unsubscribe of unsubscribes) {
@@ -216,6 +205,10 @@
}
};
});
onDestroy(() => {
assetCacheManager.invalidate();
});
</script>
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
@@ -234,8 +227,6 @@
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
onRandom={handleRandom}
onClose={handleClose}
/>
@@ -128,7 +128,7 @@
maxlength="1"
bind:this={pinCodeInputElements[index]}
id="pin-code-{index}"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-immich-mono bg-white dark:bg-light"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
bind:value={pinValues[index]}
onkeydown={handleKeydown}
oninput={(event) => handleInput(event, index)}
@@ -68,7 +68,7 @@
<Field label={$t('default_locale')} description={$t('default_locale_description')}>
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
<Text size="small" class="mt-2 font-immich-mono text-sm">{selectedDate}</Text>
<Text size="small" class="mt-2 font-mono text-sm">{selectedDate}</Text>
</Field>
{#if $locale !== 'default'}
@@ -1,10 +1,9 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { preferences } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
import { Button, Field, NumberInput, Switch, toastManager } from '@immich/ui';
import { Button, Field, NumberInput, Select, Switch, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -71,15 +70,15 @@
<div class="ms-4 mt-4 flex flex-col">
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<SettingSelect
label={$t('albums_default_sort_order')}
desc={$t('albums_default_sort_order_description')}
options={[
{ value: AssetOrder.Asc, text: $t('oldest_first') },
{ value: AssetOrder.Desc, text: $t('newest_first') },
]}
bind:value={defaultAssetOrder}
/>
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
<Select
options={[
{ label: $t('oldest_first'), value: AssetOrder.Asc },
{ label: $t('newest_first'), value: AssetOrder.Desc },
]}
bind:value={defaultAssetOrder}
/>
</Field>
</div>
</SettingAccordion>
@@ -167,7 +166,7 @@
</div>
</SettingAccordion>
<div class="flex justify-end">
<div class="flex justify-end mt-4">
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
</div>
</div>
@@ -39,7 +39,7 @@
checked={selectAllSubItems}
onCheckedChange={handleSelectAllSubItems}
/>
<Label label={title} for="permission-{title}" class="font-immich-mono text-primary text-lg" />
<Label label={title} for="permission-{title}" class="font-mono text-primary text-lg" />
</div>
<div class="mx-6 mt-3 grid grid-cols-3 gap-2">
{#each subItems as item (item)}
@@ -50,7 +50,7 @@
checked={selectedItems.includes(item)}
onCheckedChange={() => handleToggleItem(item)}
/>
<Label label={item} for="permission-{item}" class="text-sm font-immich-mono" />
<Label label={item} for="permission-{item}" class="text-sm font-mono" />
</div>
{/each}
</div>
@@ -5,7 +5,7 @@
import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
import { locale } from '$lib/stores/preferences.store';
import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { Button, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -20,15 +20,11 @@
};
const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
for (const key of keys) {
if (key.id === update.id) {
Object.assign(key, update);
}
}
keys = keys.map((key) => (key.id === update.id ? update : key));
};
const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
keys = keys.filter((apiKey) => apiKey.id !== id);
keys = keys.filter((key) => key.id !== id);
};
const { Create } = $derived(getApiKeysActions($t));
@@ -39,45 +35,41 @@
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}
>{Create.title}</Button
>
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
{Create.title}
</Button>
</div>
{#if keys.length > 0}
<table class="w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-1/4 text-center text-sm font-medium">{$t('name')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('permission')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('created')}</th>
<th class="w-1/4 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">
<Table class="mt-4" striped spacing="small" size="small">
<TableHeader>
<TableHeading>{$t('name')}</TableHeading>
<TableHeading>{$t('permission')}</TableHeading>
<TableHeading>{$t('created')}</TableHeading>
<TableHeading>{$t('action')}</TableHeading>
</TableHeader>
<TableBody>
{#each keys as key (key.id)}
{@const { Update, Delete } = getApiKeyActions($t, key)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/4 text-ellipsis px-4 text-sm overflow-hidden">{key.name}</td>
<td
class="w-1/4 text-ellipsis px-4 text-xs overflow-hidden line-clamp-3 break-all font-immich-mono"
title={JSON.stringify(key.permissions, undefined, 2)}>{key.permissions}</td
>
<td class="w-1/4 text-ellipsis px-4 text-sm overflow-hidden"
>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
</td>
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4">
<TableRow>
<TableCell>{key.name}</TableCell>
<TableCell>
<Text
class="font-mono overflow-hidden line-clamp-3"
size="small"
title={JSON.stringify(key.permissions, null, 2)}>{key.permissions}</Text
>
</TableCell>
<TableCell>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}</TableCell>
<TableCell class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1">
<TableButton action={Update} size="small" />
<TableButton action={Delete} size="small" />
</td>
</tr>
</TableCell>
</TableRow>
{/each}
</tbody>
</table>
</TableBody>
</Table>
{/if}
</div>
</section>
@@ -7,6 +7,7 @@
type AlbumStatisticsResponseDto,
type AssetStatsResponseDto,
} from '@immich/sdk';
import { Heading, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -56,56 +57,42 @@
</script>
{#snippet row(viewName: string, stats: AssetStatsResponseDto)}
<tr
class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/4 px-4 text-sm">{viewName}</td>
<td class="w-1/4 px-4 text-sm">{stats.images.toLocaleString($locale)}</td>
<td class="w-1/4 px-4 text-sm">{stats.videos.toLocaleString($locale)}</td>
<td class="w-1/4 px-4">{stats.total.toLocaleString($locale)}</td>
</tr>
<TableRow>
<TableCell class="w-1/4">{viewName}</TableCell>
<TableCell class="w-1/4">{stats.images.toLocaleString($locale)}</TableCell>
<TableCell class="w-1/4">{stats.videos.toLocaleString($locale)}</TableCell>
<TableCell class="w-1/4">{stats.total.toLocaleString($locale)}</TableCell>
</TableRow>
{/snippet}
<section class="my-6">
<p class="text-xs dark:text-white uppercase">{$t('photos_and_videos')}</p>
<div class="overflow-x-auto">
<table class="w-full text-start mt-4">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center text-sm font-medium text-center">
<th class="w-1/4">{$t('view_name')}</th>
<th class="w-1/4">{$t('photos')}</th>
<th class="w-1/4">{$t('videos')}</th>
<th class="w-1/4">{$t('total')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{@render row($t('timeline'), timelineStats)}
{@render row($t('favorites'), favoriteStats)}
{@render row($t('archive'), archiveStats)}
{@render row($t('trash'), trashStats)}
</tbody>
</table>
</div>
<section class="my-6 w-full">
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>
<TableHeading class="w-1/4">{$t('view_name')}</TableHeading>
<TableHeading class="w-1/4">{$t('photos')}</TableHeading>
<TableHeading class="w-1/4">{$t('videos')}</TableHeading>
<TableHeading class="w-1/4">{$t('total')}</TableHeading>
</TableHeader>
<TableBody>
{@render row($t('timeline'), timelineStats)}
{@render row($t('favorites'), favoriteStats)}
{@render row($t('archive'), archiveStats)}
{@render row($t('trash'), trashStats)}
</TableBody>
</Table>
<div class="mt-6">
<p class="text-xs dark:text-white uppercase">{$t('albums')}</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-start mt-4">
<thead class="mb-4 flex h-12 w-full rounded-md border text-primary dark:border-immich-dark-gray bg-subtle">
<tr class="flex w-full place-items-center text-sm font-medium text-center">
<th class="w-1/2">{$t('owned')}</th>
<th class="w-1/2">{$t('shared')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
<tr class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg bg-subtle/20">
<td class="w-1/2 px-4 text-sm">{albumStats.owned.toLocaleString($locale)}</td>
<td class="w-1/2 px-4 text-sm">{albumStats.shared.toLocaleString($locale)}</td>
</tr>
</tbody>
</table>
</div>
<Heading size="tiny" class="mt-8">{$t('albums')}</Heading>
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>
<TableHeading class="w-1/2">{$t('owned')}</TableHeading>
<TableHeading class="w-1/2">{$t('shared')}</TableHeading>
</TableHeader>
<TableBody>
<TableRow>
<TableCell class="w-1/2">{albumStats.owned.toLocaleString($locale)}</TableCell>
<TableCell class="w-1/2">{albumStats.shared.toLocaleString($locale)}</TableCell>
</TableRow>
</TableBody>
</Table>
</section>
@@ -11,6 +11,6 @@
</script>
<div class="flex justify-between items-center">
<Text class="text-sm font-medium">{title}</Text>
<Text class="text-sm" fontWeight="medium">{title}</Text>
<Icon icon={state ? mdiCheck : mdiClose} class={state ? 'text-primary' : 'text-danger'} size="24" />
</div>
@@ -23,7 +23,6 @@
let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedAssetIds = $state(new SvelteSet<string>());
@@ -44,24 +43,6 @@
assetViewingStore.showAssetViewer(false);
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
return false;
}
await onViewAsset(assets[index]);
return true;
};
const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
return false;
}
await onViewAsset(assets[index]);
return true;
};
const onRandom = async () => {
if (assets.length <= 0) {
return;
@@ -191,8 +172,6 @@
<AssetViewer
cursor={assetCursor}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
@@ -16,7 +16,11 @@
<div class="grid grid-cols-[25px_1fr] w-full px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
<div class="justify-self-end text-end rounded px-1 transition-colors w-full overflow-hidden">
<Text size="tiny" class={`${highlight ? 'font-semibold text-primary' : ''} text-ellipsis w-full overflow-hidden`}>
<Text
size="tiny"
fontWeight={highlight ? 'semi-bold' : 'normal'}
class={`${highlight ? 'text-primary' : ''} text-ellipsis w-full overflow-hidden`}
>
{@render children?.()}
</Text>
</div>
@@ -81,16 +81,13 @@
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValue = actualConfig[key]}
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
<Select {options} onChange={(value) => updateConfig(key, value)} value={actualConfig[key] as string} />
</Field>
{/if}
@@ -107,9 +104,6 @@
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValues = (actualConfig[key] as string[]) ?? []}
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
<Field
{label}
required={component.required}
@@ -117,13 +111,9 @@
requiredIndicator={component.required}
>
<MultiSelect
data={options}
onChange={(opt) =>
updateConfig(
key,
opt.map((o) => o.value),
)}
values={selectedItems}
{options}
values={(actualConfig[key] as string[]) ?? []}
onChange={(values) => updateConfig(key, values)}
/>
</Field>
{/if}
@@ -32,7 +32,7 @@
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="font-semibold truncate">
<Text class="truncate" fontWeight="semi-bold">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</Text>
{#if isAlbum && 'assetCount' in item}
@@ -91,7 +91,7 @@
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
>
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
<Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={mdiClose}
@@ -71,7 +71,7 @@
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
<Text fontWeight="semi-bold" class="mb-1">{getTriggerName(trigger.type)}</Text>
{#if getTriggerDescription(trigger.type)}
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
{/if}