mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
+1
-1
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-immich-mono: GoogleSansCode, monospace;
|
||||
--font-mono: 'GoogleSansCode', monospace;
|
||||
|
||||
--spacing-18: 4.5rem;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReleaseEvent } from '$lib/types';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
import type {
|
||||
AlbumResponseDto,
|
||||
AlbumUserRole,
|
||||
ApiKeyResponseDto,
|
||||
AssetResponseDto,
|
||||
IntegrityReportType,
|
||||
@@ -41,6 +42,8 @@ export type Events = {
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
AlbumShare: [];
|
||||
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
|
||||
AlbumUserDelete: [{ albumId: string; userId: string }];
|
||||
|
||||
PersonUpdate: [PersonResponseDto];
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
class="flex items-start gap-3 p-3 rounded-lg text-left bg-light-100 hover:border-primary border text-dark"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Text color="primary" class="font-medium">{title}</Text>
|
||||
<Text color="primary" fontWeight="medium">{title}</Text>
|
||||
{#if description}
|
||||
<Text size="small" class="mt-1">{description}</Text>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { handleAddUsersToAlbum } from '$lib/services/album.service';
|
||||
import { searchUsers, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { FormModal, ListButton, Stack, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { album, onClose }: Props = $props();
|
||||
|
||||
let users: UserResponseDto[] = $state([]);
|
||||
const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]);
|
||||
const filteredUsers = $derived(users.filter(({ id }) => !excludedUserIds.includes(id)));
|
||||
const selectedUsers = new SvelteMap<string, UserResponseDto>();
|
||||
|
||||
const handleToggle = (user: UserResponseDto) => {
|
||||
if (selectedUsers.has(user.id)) {
|
||||
selectedUsers.delete(user.id);
|
||||
} else {
|
||||
selectedUsers.set(user.id, user);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleAddUsersToAlbum(album, [...selectedUsers.values()]);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
users = await searchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title={$t('users')}
|
||||
submitText={$t('add')}
|
||||
cancelText={$t('back')}
|
||||
{onSubmit}
|
||||
disabled={selectedUsers.size === 0}
|
||||
{onClose}
|
||||
>
|
||||
<Stack>
|
||||
{#each filteredUsers as user (user.id)}
|
||||
<ListButton selected={selectedUsers.has(user.id)} onclick={() => handleToggle(user)}>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="text-start grow">
|
||||
<Text fontWeight="medium">{user.name}</Text>
|
||||
<Text size="tiny" color="muted">{user.email}</Text>
|
||||
</div>
|
||||
</ListButton>
|
||||
{:else}
|
||||
<Text class="py-6">{$t('album_share_no_users')}</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
</FormModal>
|
||||
@@ -1,184 +1,158 @@
|
||||
<script lang="ts">
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import type { RenderedOption } from '$lib/elements/Dropdown.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
getAlbumActions,
|
||||
handleRemoveUserFromAlbum,
|
||||
handleUpdateAlbum,
|
||||
handleUpdateUserAlbumRole,
|
||||
} from '$lib/services/album.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumInfo,
|
||||
updateAlbumUser,
|
||||
getAlbumInfo,
|
||||
getAllSharedLinks,
|
||||
type AlbumResponseDto,
|
||||
type SharedLinkResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, Modal, ModalBody, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { findKey } from 'lodash-es';
|
||||
import { Field, HStack, Modal, ModalBody, Select, Stack, Switch, Text, type SelectOption } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
album: AlbumResponseDto;
|
||||
order: AssetOrder | undefined;
|
||||
user: UserResponseDto;
|
||||
onClose: (
|
||||
result?: { action: 'changeOrder'; order: AssetOrder } | { action: 'shareUser' } | { action: 'refreshAlbum' },
|
||||
) => void;
|
||||
}
|
||||
|
||||
let { album, order, user, onClose }: Props = $props();
|
||||
|
||||
const options: Record<AssetOrder, RenderedOption> = {
|
||||
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
|
||||
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
|
||||
let { album, onClose }: Props = $props();
|
||||
|
||||
const handleToggleOrder = async (returnedOption: RenderedOption): Promise<void> => {
|
||||
if (selectedOption === returnedOption) {
|
||||
return;
|
||||
}
|
||||
let order: AssetOrder = AssetOrder.Desc;
|
||||
order = findKey(options, (option) => option === returnedOption) as AssetOrder;
|
||||
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
order,
|
||||
},
|
||||
});
|
||||
onClose({ action: 'changeOrder', order });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActivity = async () => {
|
||||
try {
|
||||
album = await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
isActivityEnabled: !album.isActivityEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
toastManager.success($t('activity_changed', { values: { enabled: album.isActivityEnabled } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_change_activity', { values: { enabled: album.isActivityEnabled } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
|
||||
const confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => {
|
||||
if (role === 'none') {
|
||||
await handleRemoveUserFromAlbum(album, user);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId: user.id });
|
||||
onClose({ action: 'refreshAlbum' });
|
||||
toastManager.success($t('album_user_removed', { values: { user: user.name } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
await handleUpdateUserAlbumRole({ albumId: album.id, userId: user.id, role });
|
||||
};
|
||||
|
||||
const handleUpdateSharedUserRole = async (user: UserResponseDto, role: AlbumUserRole) => {
|
||||
try {
|
||||
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
|
||||
const message = $t('user_role_set', {
|
||||
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
|
||||
});
|
||||
onClose({ action: 'refreshAlbum' });
|
||||
toastManager.success(message);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
const refreshAlbum = async () => {
|
||||
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
|
||||
};
|
||||
|
||||
const onAlbumUserDelete = async ({ userId }: { userId: string }) => {
|
||||
album.albumUsers = album.albumUsers.filter(({ user: { id } }) => id !== userId);
|
||||
await refreshAlbum();
|
||||
};
|
||||
|
||||
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
|
||||
sharedLinks.push(sharedLink);
|
||||
};
|
||||
|
||||
const onSharedLinkDelete = (sharedLink: SharedLinkResponseDto) => {
|
||||
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
|
||||
};
|
||||
|
||||
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
|
||||
|
||||
let sharedLinks: SharedLinkResponseDto[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
sharedLinks = await getAllSharedLinks({ albumId: album.id });
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title={$t('options')} onClose={() => onClose({ action: 'refreshAlbum' })} size="small">
|
||||
<OnEvents
|
||||
{onAlbumUserDelete}
|
||||
onAlbumShare={refreshAlbum}
|
||||
{onSharedLinkCreate}
|
||||
{onSharedLinkDelete}
|
||||
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
|
||||
/>
|
||||
|
||||
<Modal title={$t('options')} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="items-center justify-center">
|
||||
<div class="py-2">
|
||||
<h2 class="uppercase text-gray text-sm mb-2">{$t('settings')}</h2>
|
||||
<div class="grid p-2 gap-y-2">
|
||||
{#if order}
|
||||
<SettingDropdown
|
||||
title={$t('display_order')}
|
||||
options={Object.values(options)}
|
||||
selectedOption={options[order]}
|
||||
onToggle={handleToggleOrder}
|
||||
/>
|
||||
<Stack gap={6}>
|
||||
<div>
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('settings')}</Text>
|
||||
<div class="grid gap-y-3 ps-2 mt-2">
|
||||
{#if album.order}
|
||||
<Field label={$t('display_order')}>
|
||||
<Select
|
||||
value={album.order}
|
||||
options={[
|
||||
{ label: $t('newest_first'), value: AssetOrder.Desc },
|
||||
{ label: $t('oldest_first'), value: AssetOrder.Asc },
|
||||
]}
|
||||
onChange={(value) => handleUpdateAlbum(album, { order: value })}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
<SettingSwitch
|
||||
title={$t('comments_and_likes')}
|
||||
subtitle={$t('let_others_respond')}
|
||||
checked={album.isActivityEnabled}
|
||||
onToggle={handleToggleActivity}
|
||||
/>
|
||||
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')}>
|
||||
<Switch
|
||||
checked={album.isActivityEnabled}
|
||||
onCheckedChange={(checked) => handleUpdateAlbum(album, { isActivityEnabled: checked })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="uppercase text-gray text-sm mb-3">{$t('people')}</div>
|
||||
<div class="p-2">
|
||||
<button type="button" class="flex items-center gap-2" onclick={() => onClose({ action: 'shareUser' })}>
|
||||
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
||||
<div><Icon icon={mdiPlus} size="25" /></div>
|
||||
</div>
|
||||
<div>{$t('invite_people')}</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 py-2 mt-2">
|
||||
<div>
|
||||
<HStack fullWidth class="justify-between mb-2">
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('people')}</Text>
|
||||
<HeaderActionButton action={AddUsers} />
|
||||
</HStack>
|
||||
<div class="ps-2">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
<UserAvatar user={$user} size="md" />
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
<div>{$t('owner')}</div>
|
||||
<Text class="w-full" size="small">{$user.name}</Text>
|
||||
<Field disabled class="w-32 shrink-0">
|
||||
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{#each album.albumUsers as { user, role } (user.id)}
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="flex items-center justify-between gap-4 py-2">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" />
|
||||
</div>
|
||||
<Text size="small">{user.name}</Text>
|
||||
</div>
|
||||
<div class="w-full">{user.name}</div>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
{$t('role_viewer')}
|
||||
{:else}
|
||||
{$t('role_editor')}
|
||||
{/if}
|
||||
{#if user.id !== album.ownerId}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Editor)}
|
||||
text={$t('allow_edits')}
|
||||
/>
|
||||
{:else}
|
||||
<MenuOption
|
||||
onClick={() => handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Allow deletion for non-owners -->
|
||||
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
<Field class="w-32">
|
||||
<Select
|
||||
value={role}
|
||||
options={[
|
||||
{ label: $t('role_editor'), value: AlbumUserRole.Editor },
|
||||
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer },
|
||||
{ label: $t('remove_user'), value: 'none' },
|
||||
] as SelectOption<AlbumUserRole | 'none'>[]}
|
||||
onChange={(value) => handleRoleSelect(user, value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<HStack class="justify-between mb-2">
|
||||
<Text size="medium" fontWeight="semi-bold">{$t('shared_links')}</Text>
|
||||
<HeaderActionButton action={CreateSharedLink} />
|
||||
</HStack>
|
||||
|
||||
<div class="ps-2">
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { handleAddUsersToAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
getAllSharedLinks,
|
||||
searchUsers,
|
||||
type AlbumResponseDto,
|
||||
type SharedLinkResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, Link, Modal, ModalBody, modalManager, Stack, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import UserAvatar from '../components/shared-components/user-avatar.svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { album, onClose }: Props = $props();
|
||||
|
||||
let users: UserResponseDto[] = $state([]);
|
||||
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
|
||||
|
||||
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
||||
{ title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
|
||||
{ title: $t('role_viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
|
||||
{ title: $t('remove_user'), value: 'none' },
|
||||
];
|
||||
|
||||
let sharedLinks: SharedLinkResponseDto[] = $state([]);
|
||||
onMount(async () => {
|
||||
sharedLinks = await getAllSharedLinks({ albumId: album.id });
|
||||
const data = await searchUsers();
|
||||
|
||||
// remove album owner
|
||||
users = data.filter((user) => user.id !== album.ownerId);
|
||||
|
||||
// Remove the existed shared users from the album
|
||||
for (const sharedUser of album.albumUsers) {
|
||||
users = users.filter((user) => user.id !== sharedUser.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggle = (user: UserResponseDto) => {
|
||||
if (Object.keys(selectedUsers).includes(user.id)) {
|
||||
delete selectedUsers[user.id];
|
||||
} else {
|
||||
selectedUsers[user.id] = { user, role: AlbumUserRole.Editor };
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => {
|
||||
if (role === 'none') {
|
||||
delete selectedUsers[user.id];
|
||||
} else {
|
||||
selectedUsers[user.id].role = role;
|
||||
}
|
||||
};
|
||||
|
||||
const onShareUser = async () => {
|
||||
const success = await handleAddUsersToAlbum(
|
||||
album,
|
||||
Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
|
||||
);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onShareLink = () => {
|
||||
void modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('share')} {onClose}>
|
||||
<ModalBody>
|
||||
{#if Object.keys(selectedUsers).length > 0}
|
||||
<div class="mb-2 py-2 sticky">
|
||||
<p class="text-xs font-medium">{$t('selected')}</p>
|
||||
<div class="my-2">
|
||||
{#each Object.values(selectedUsers) as { user } (user.id)}
|
||||
{#key user.id}
|
||||
<div class="flex place-items-center gap-4 p-4">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border bg-green-600 text-3xl text-white"
|
||||
>
|
||||
<Icon icon={mdiCheck} size="24" />
|
||||
</div>
|
||||
|
||||
<!-- <UserAvatar {user} size="md" /> -->
|
||||
<div class="text-start grow">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
title={$t('role')}
|
||||
options={roleOptions}
|
||||
render={({ title, icon }) => ({ title, icon })}
|
||||
onSelect={({ value }) => handleChangeRole(user, value)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if users.length + Object.keys(selectedUsers).length === 0}
|
||||
<p class="p-5 text-sm">
|
||||
{$t('album_share_no_users')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="immich-scrollbar max-h-125 overflow-y-auto">
|
||||
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
|
||||
<Text>{$t('users')}</Text>
|
||||
|
||||
<div class="my-2">
|
||||
{#each users as user (user.id)}
|
||||
{#if !Object.keys(selectedUsers).includes(user.id)}
|
||||
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleToggle(user)}
|
||||
class="flex w-full place-items-center gap-4 p-4"
|
||||
>
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="text-start grow">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg">
|
||||
{user.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if users.length > 0}
|
||||
<div class="py-3">
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
shape="round"
|
||||
disabled={Object.keys(selectedUsers).length === 0}
|
||||
onclick={onShareUser}
|
||||
>
|
||||
{$t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<Stack gap={6}>
|
||||
{#if sharedLinks.length > 0}
|
||||
<div class="flex justify-between items-center">
|
||||
<Text>{$t('shared_links')}</Text>
|
||||
<Link href={AppRoute.SHARED_LINKS} onclick={() => onClose()} class="text-sm">{$t('view_all')}</Link>
|
||||
</div>
|
||||
|
||||
<Stack gap={4}>
|
||||
{#each sharedLinks as sharedLink (sharedLink.id)}
|
||||
<AlbumSharedLink {album} {sharedLink} />
|
||||
{/each}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShareLink}>
|
||||
{$t('create_link')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -1,148 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
getMyUser,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumUser,
|
||||
type AlbumResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, Text, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onClose: (changed?: boolean) => void;
|
||||
}
|
||||
|
||||
let { album, onClose }: Props = $props();
|
||||
|
||||
let currentUser: UserResponseDto | undefined = $state();
|
||||
|
||||
let isOwned = $derived(currentUser?.id == album.ownerId);
|
||||
|
||||
// Build a map of contributor counts by user id; avoid casts/derived
|
||||
const contributorCounts: Record<string, number> = {};
|
||||
if (album.contributorCounts) {
|
||||
for (const { userId, assetCount } of album.contributorCounts) {
|
||||
contributorCounts[userId] = assetCount;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
currentUser = await getMyUser();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_refresh_user'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveUser = async (user: UserResponseDto) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = user.id === currentUser?.id ? 'me' : user.id;
|
||||
let confirmed: boolean | undefined;
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-ternary
|
||||
if (userId === 'me') {
|
||||
confirmed = await modalManager.showDialog({
|
||||
title: $t('album_leave'),
|
||||
prompt: $t('album_leave_confirmation', { values: { album: album.albumName } }),
|
||||
confirmText: $t('leave'),
|
||||
});
|
||||
} else {
|
||||
confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: user.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId });
|
||||
const message =
|
||||
userId === 'me'
|
||||
? $t('album_user_left', { values: { album: album.albumName } })
|
||||
: $t('album_user_removed', { values: { user: user.name } });
|
||||
toastManager.success(message);
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (user: UserResponseDto, role: AlbumUserRole) => {
|
||||
try {
|
||||
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
|
||||
const message = $t('user_role_set', {
|
||||
values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
|
||||
});
|
||||
toastManager.success(message);
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('options')} size="small" {onClose}>
|
||||
<ModalBody>
|
||||
<section class="immich-scrollbar max-h-100 overflow-y-auto pb-4">
|
||||
{#each [{ user: album.owner, role: 'owner' }, ...album.albumUsers] as { user, role } (user.id)}
|
||||
<div class="flex w-full place-items-center justify-between gap-4 p-5 rounded-xl transition-colors">
|
||||
<div class="flex place-items-center gap-4">
|
||||
<UserAvatar {user} size="md" />
|
||||
<div class="flex flex-col">
|
||||
<p class="font-medium">{user.name}</p>
|
||||
<Text color="muted" size="tiny">
|
||||
{#if role === 'owner'}
|
||||
{$t('owner')}
|
||||
{:else if role === AlbumUserRole.Viewer}
|
||||
{$t('role_viewer')}
|
||||
{:else}
|
||||
{$t('role_editor')}
|
||||
{/if}
|
||||
{#if user.id in contributorCounts}
|
||||
<span>-</span>
|
||||
{$t('items_count', { values: { count: contributorCounts[user.id] } })}
|
||||
{/if}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="icon-{user.id}" class="flex place-items-center">
|
||||
{#if isOwned}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="medium" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption onClick={() => handleChangeRole(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||
{:else}
|
||||
<MenuOption
|
||||
onClick={() => handleChangeRole(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<MenuOption onClick={() => handleRemoveUser(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{:else if user.id == currentUser?.id}
|
||||
<Button shape="round" variant="ghost" leadingIcon={undefined} onclick={() => handleRemoveUser(user)}
|
||||
>{$t('leave')}</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -19,8 +19,7 @@
|
||||
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions;
|
||||
const response = await handleCreateApiKey({ name, permissions });
|
||||
if (response) {
|
||||
// no nested modal
|
||||
void modalManager.show(ApiKeySecretModal, { secret: response.secret });
|
||||
await modalManager.show(ApiKeySecretModal, { secret: response.secret });
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<Text size="small" class="mb-4">{$t('api_key_description')}</Text>
|
||||
<Textarea bind:value={secret} readonly class="font-immich-mono" />
|
||||
<Textarea bind:value={secret} readonly class="font-mono" />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import type { MapSettings } from '$lib/stores/preferences.store';
|
||||
import { Button, Field, FormModal, Stack, Switch } from '@immich/ui';
|
||||
import { Button, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
|
||||
import { Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
settings: MapSettings;
|
||||
onClose: (settings?: MapSettings) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { settings: initialValues, onClose }: Props = $props();
|
||||
let settings = $state(initialValues);
|
||||
@@ -73,37 +72,37 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
|
||||
<SettingSelect
|
||||
label={$t('date_range')}
|
||||
name="date-range"
|
||||
bind:value={settings.relativeDate}
|
||||
options={[
|
||||
{
|
||||
value: '',
|
||||
text: $t('all'),
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ hours: 24 }).toISO() || '',
|
||||
text: $t('past_durations.hours', { values: { hours: 24 } }),
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ days: 7 }).toISO() || '',
|
||||
text: $t('past_durations.days', { values: { days: 7 } }),
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ days: 30 }).toISO() || '',
|
||||
text: $t('past_durations.days', { values: { days: 30 } }),
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ years: 1 }).toISO() || '',
|
||||
text: $t('past_durations.years', { values: { years: 1 } }),
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ years: 3 }).toISO() || '',
|
||||
text: $t('past_durations.years', { values: { years: 3 } }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Field label={$t('date_range')}>
|
||||
<Select
|
||||
bind:value={settings.relativeDate}
|
||||
options={[
|
||||
{
|
||||
label: $t('all'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: $t('past_durations.hours', { values: { hours: 24 } }),
|
||||
value: Duration.fromObject({ hours: 24 }).toISO() || '',
|
||||
},
|
||||
{
|
||||
label: $t('past_durations.days', { values: { days: 7 } }),
|
||||
value: Duration.fromObject({ days: 7 }).toISO() || '',
|
||||
},
|
||||
{
|
||||
label: $t('past_durations.days', { values: { days: 30 } }),
|
||||
value: Duration.fromObject({ days: 30 }).toISO() || '',
|
||||
},
|
||||
{
|
||||
label: $t('past_durations.years', { values: { years: 1 } }),
|
||||
value: Duration.fromObject({ years: 1 }).toISO() || '',
|
||||
},
|
||||
{
|
||||
label: $t('past_durations.years', { values: { years: 3 } }),
|
||||
value: Duration.fromObject({ years: 3 }).toISO() || '',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<div class="text-xs">
|
||||
<Button
|
||||
color="primary"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { handleCreateApiKey } from '$lib/services/api-key.service';
|
||||
import { Permission } from '@immich/sdk';
|
||||
import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
|
||||
import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Select, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
let inputUrl = $state(location.origin);
|
||||
let inputApiKey = $state('');
|
||||
@@ -29,6 +28,18 @@
|
||||
<ModalBody>
|
||||
<Text color="muted" size="small">{$t('obtainium_configurator_instructions')}</Text>
|
||||
|
||||
<Field label={$t('app_architecture_variant')} class="mt-4">
|
||||
<Select
|
||||
bind:value={archVariant}
|
||||
options={[
|
||||
{ label: 'arm64-v8a', value: 'arm64-v8a-release' },
|
||||
{ label: 'armeabi-v7a', value: 'armeabi-v7a-release' },
|
||||
{ label: 'universal', value: 'release' },
|
||||
{ label: 'x86_64', value: 'x86_64-release' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={$t('url')} class="mt-4">
|
||||
<Input bind:value={inputUrl} />
|
||||
</Field>
|
||||
@@ -41,17 +52,6 @@
|
||||
<Button size="small" onclick={handleCreate}>{$t('create_api_key')}</Button>
|
||||
</div>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('app_architecture_variant')}
|
||||
bind:value={archVariant}
|
||||
options={[
|
||||
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
|
||||
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
|
||||
{ value: 'release', text: 'universal' },
|
||||
{ value: 'x86_64-release', text: 'x86_64' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if inputUrl && inputApiKey && archVariant}
|
||||
<div class="content-center">
|
||||
<hr />
|
||||
|
||||
@@ -3,7 +3,9 @@ import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
@@ -12,14 +14,17 @@ import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
addAssetsToAlbum,
|
||||
addUsersToAlbum,
|
||||
AlbumUserRole,
|
||||
deleteAlbum,
|
||||
removeUserFromAlbum,
|
||||
updateAlbumInfo,
|
||||
updateAlbumUser,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
type UpdateAlbumDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { type MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@@ -31,16 +36,33 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
|
||||
type: $t('command'),
|
||||
icon: mdiShareVariantOutline,
|
||||
$if: () => isOwned,
|
||||
onAction: () => modalManager.show(AlbumShareModal, { album }),
|
||||
onAction: () => modalManager.show(AlbumOptionsModal, { album }),
|
||||
};
|
||||
|
||||
return { Share };
|
||||
const AddUsers: ActionItem = {
|
||||
title: $t('invite_people'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlus,
|
||||
color: 'primary',
|
||||
onAction: () => modalManager.show(AlbumAddUsersModal, { album }),
|
||||
};
|
||||
|
||||
const CreateSharedLink: ActionItem = {
|
||||
title: $t('create_link'),
|
||||
type: $t('command'),
|
||||
icon: mdiLink,
|
||||
color: 'primary',
|
||||
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
|
||||
};
|
||||
|
||||
return { Share, AddUsers, CreateSharedLink };
|
||||
};
|
||||
|
||||
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
const AddAssets: ActionItem = {
|
||||
title: $t('add_assets'),
|
||||
type: $t('command'),
|
||||
color: 'primary',
|
||||
icon: mdiPlusBoxOutline,
|
||||
$if: () => assets.length > 0,
|
||||
onAction: () => addAssets(album, assets),
|
||||
@@ -72,18 +94,56 @@ const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
|
||||
export const handleUpdateUserAlbumRole = async ({
|
||||
albumId,
|
||||
userId,
|
||||
role,
|
||||
}: {
|
||||
albumId: string;
|
||||
userId: string;
|
||||
role: AlbumUserRole;
|
||||
}) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers } });
|
||||
await updateAlbumUser({ id: albumId, userId, updateAlbumUserDto: { role } });
|
||||
eventManager.emit('AlbumUserUpdate', { albumId, userId, role });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_album_user_role'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers: users.map(({ id }) => ({ userId: id })) } });
|
||||
eventManager.emit('AlbumShare');
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_users_to_album'));
|
||||
}
|
||||
};
|
||||
|
||||
return false;
|
||||
export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUser: UserResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({
|
||||
title: $t('album_remove_user'),
|
||||
prompt: $t('album_remove_user_confirmation', { values: { user: albumUser.name } }),
|
||||
confirmText: $t('remove_user'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeUserFromAlbum({ id: album.id, userId: albumUser.id });
|
||||
eventManager.emit('AlbumUserDelete', { albumId: album.id, userId: albumUser.id });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
|
||||
@@ -508,11 +508,13 @@ export const delay = async (ms: number) => {
|
||||
};
|
||||
|
||||
export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
|
||||
return currentAsset && assets[assets.indexOf(currentAsset) + 1];
|
||||
const index = currentAsset ? assets.findIndex((a) => a.id === currentAsset.id) : -1;
|
||||
return index >= 0 ? assets[index + 1] : undefined;
|
||||
};
|
||||
|
||||
export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
|
||||
return currentAsset && assets[assets.indexOf(currentAsset) - 1];
|
||||
const index = currentAsset ? assets.findIndex((a) => a.id === currentAsset.id) : -1;
|
||||
return index >= 0 ? assets[index - 1] : undefined;
|
||||
};
|
||||
|
||||
export const canCopyImageToClipboard = (): boolean => {
|
||||
@@ -547,3 +549,12 @@ export const copyImageToClipboard = async (source: HTMLImageElement) => {
|
||||
// do not await, so the Safari clipboard write happens in the context of the user gesture
|
||||
await navigator.clipboard.write([new ClipboardItem({ ['image/png']: imgToBlob(source) })]);
|
||||
};
|
||||
|
||||
export const navigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => {
|
||||
if (!targetAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
|
||||
return true;
|
||||
};
|
||||
|
||||
+30
-48
@@ -36,8 +36,6 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import {
|
||||
getAlbumActions,
|
||||
@@ -60,14 +58,7 @@
|
||||
navigate,
|
||||
type AssetGridRouteSearchParams,
|
||||
} from '$lib/utils/navigation';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
AssetVisibility,
|
||||
getAlbumInfo,
|
||||
updateAlbumInfo,
|
||||
type AlbumResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountEye,
|
||||
@@ -101,7 +92,6 @@
|
||||
|
||||
let backUrl: string = $state(AppRoute.ALBUMS);
|
||||
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false);
|
||||
@@ -266,7 +256,7 @@
|
||||
timelineAlbumId: albumId,
|
||||
};
|
||||
}
|
||||
return { albumId, order: albumOrder };
|
||||
return { albumId, order: album.order };
|
||||
});
|
||||
|
||||
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
|
||||
@@ -319,37 +309,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUsers = async () => {
|
||||
const changed = await modalManager.show(AlbumUsersModal, { album });
|
||||
|
||||
if (changed) {
|
||||
await refreshAlbum();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptions = async () => {
|
||||
const result = await modalManager.show(AlbumOptionsModal, { album, order: albumOrder, user: $user });
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'changeOrder': {
|
||||
albumOrder = result.order;
|
||||
break;
|
||||
}
|
||||
case 'shareUser': {
|
||||
await modalManager.show(AlbumShareModal, { album });
|
||||
break;
|
||||
}
|
||||
case 'refreshAlbum': {
|
||||
await refreshAlbum();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumAddAssets = async () => {
|
||||
await refreshAlbum();
|
||||
timelineInteraction.clearMultiselect();
|
||||
@@ -361,12 +320,31 @@
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const onAlbumUserUpdate = ({ albumId, userId, role }: { albumId: string; userId: string; role: AlbumUserRole }) => {
|
||||
if (albumId !== album.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
album.albumUsers = album.albumUsers.map((albumUser) =>
|
||||
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
|
||||
);
|
||||
};
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
const { Share } = $derived(getAlbumActions($t, album));
|
||||
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
|
||||
</script>
|
||||
|
||||
<OnEvents {onSharedLinkCreate} {onAlbumDelete} {onAlbumAddAssets} {onAlbumShare} />
|
||||
<OnEvents
|
||||
{onSharedLinkCreate}
|
||||
onSharedLinkDelete={refreshAlbum}
|
||||
{onAlbumDelete}
|
||||
{onAlbumAddAssets}
|
||||
{onAlbumShare}
|
||||
{onAlbumUserUpdate}
|
||||
onAlbumUserDelete={refreshAlbum}
|
||||
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
|
||||
/>
|
||||
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
|
||||
|
||||
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
|
||||
@@ -417,13 +395,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- owner -->
|
||||
<button type="button" onclick={handleEditUsers}>
|
||||
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
|
||||
<UserAvatar user={album.owner} size="md" />
|
||||
</button>
|
||||
|
||||
<!-- users with write access (collaborators) -->
|
||||
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
|
||||
<button type="button" onclick={handleEditUsers}>
|
||||
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
|
||||
<UserAvatar {user} size="md" />
|
||||
</button>
|
||||
{/each}
|
||||
@@ -436,7 +414,7 @@
|
||||
color="secondary"
|
||||
size="medium"
|
||||
icon={mdiDotsVertical}
|
||||
onclick={handleEditUsers}
|
||||
onclick={() => modalManager.show(AlbumOptionsModal, { album })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -601,7 +579,11 @@
|
||||
text={$t('select_album_cover')}
|
||||
onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)}
|
||||
/>
|
||||
<MenuOption icon={mdiCogOutline} text={$t('options')} onClick={handleOptions} />
|
||||
<MenuOption
|
||||
icon={mdiCogOutline}
|
||||
text={$t('options')}
|
||||
onClick={() => modalManager.show(AlbumOptionsModal, { album })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwned}
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
{#if data.pathAssets && data.pathAssets.length > 0}
|
||||
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
|
||||
<GalleryViewer
|
||||
initialAssetId={data.asset?.id}
|
||||
assets={data.pathAssets}
|
||||
{assetInteraction}
|
||||
{viewport}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||
|
||||
let viewingAssets: string[] = $state([]);
|
||||
let viewingAssetCursor = 0;
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
@@ -36,28 +35,9 @@
|
||||
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
viewingAssets = assetIds;
|
||||
viewingAssetCursor = 0;
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
|
||||
async function navigateNext() {
|
||||
if (viewingAssetCursor < viewingAssets.length - 1) {
|
||||
await setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigatePrevious() {
|
||||
if (viewingAssetCursor > 0) {
|
||||
await setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigateRandom() {
|
||||
if (viewingAssets.length <= 0) {
|
||||
return undefined;
|
||||
@@ -138,13 +118,11 @@
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
{#if $showAssetViewer && assetCursor.current}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
onNext={navigateNext}
|
||||
onPrevious={navigatePrevious}
|
||||
onRandom={navigateRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
@@ -141,12 +141,12 @@
|
||||
<div class="flex gap-2 justify-end place-items-center">
|
||||
<Text class="hidden md:block text-xs mr-4 text-dark/50">{$t('geolocation_instruction_location')}</Text>
|
||||
<div class="border flex place-items-center place-content-center px-2 py-1 bg-primary/10 rounded-2xl">
|
||||
<Text class="hidden md:inline-block text-xs text-gray-500 font-immich-mono mr-5 ml-2 uppercase">
|
||||
<Text class="hidden md:inline-block text-xs text-gray-500 font-mono mr-5 ml-2 uppercase">
|
||||
{$t('selected_gps_coordinates')}
|
||||
</Text>
|
||||
<Text
|
||||
title="latitude, longitude"
|
||||
class="rounded-3xl font-immich-mono text-sm text-primary px-2 py-1 transition-all duration-100 ease-in-out {locationUpdated
|
||||
class="rounded-3xl font-mono text-sm text-primary px-2 py-1 transition-all duration-100 ease-in-out {locationUpdated
|
||||
? 'bg-primary/90 text-light font-semibold scale-105'
|
||||
: ''}">{location.latitude.toFixed(3)}, {location.longitude.toFixed(3)}</Text
|
||||
>
|
||||
|
||||
-22
@@ -20,32 +20,12 @@
|
||||
let assets = $derived(data.assets);
|
||||
let asset = $derived(data.asset);
|
||||
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
||||
|
||||
$effect(() => {
|
||||
if (asset) {
|
||||
setAsset(asset);
|
||||
}
|
||||
});
|
||||
|
||||
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 undefined;
|
||||
@@ -94,8 +74,6 @@
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
{onRandom}
|
||||
{onAction}
|
||||
onClose={() => {
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right hidden sm:block">
|
||||
<Text size="tiny">{$t('created_at')}</Text>
|
||||
<Text size="small" class="font-medium">
|
||||
<Text size="small" fontWeight="medium">
|
||||
{formatTimestamp(workflow.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -214,7 +214,9 @@
|
||||
<!-- Trigger Section -->
|
||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
||||
<div class="mb-3">
|
||||
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('trigger')}</Text>
|
||||
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
|
||||
>{$t('trigger')}</Text
|
||||
>
|
||||
</div>
|
||||
{@render chipItem(getTriggerLabel(workflow.triggerType))}
|
||||
</div>
|
||||
@@ -222,7 +224,9 @@
|
||||
<!-- Filters Section -->
|
||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
||||
<div class="mb-3">
|
||||
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('filters')}</Text>
|
||||
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
|
||||
>{$t('filters')}</Text
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if workflow.filters.length === 0}
|
||||
@@ -240,7 +244,9 @@
|
||||
<!-- Actions Section -->
|
||||
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
|
||||
<div class="mb-3">
|
||||
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('actions')}</Text>
|
||||
<Text class="text-xs uppercase tracking-widest" color="muted" fontWeight="semi-bold"
|
||||
>{$t('actions')}</Text
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
|
||||
{#snippet cardOrder(index: number)}
|
||||
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border bg-light-50">
|
||||
<Text size="small" class="font-immich-mono font-bold">
|
||||
<Text size="small" class="font-mono font-bold">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -350,7 +350,7 @@
|
||||
class="w-full p-8 rounded-lg border-2 border-dashed hover:border-light-400 hover:bg-light-50 transition-all flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<Icon icon={mdiPlus} size="32" />
|
||||
<Text size="small" class="font-medium">{title}</Text>
|
||||
<Text size="small" fontWeight="medium">{title}</Text>
|
||||
<Text size="tiny">{description}</Text>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
@@ -8,7 +8,17 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import {
|
||||
Button,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableRow,
|
||||
} from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -47,6 +57,15 @@
|
||||
};
|
||||
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
|
||||
const classes = {
|
||||
column1: 'w-4/12',
|
||||
column2: 'w-4/12',
|
||||
column3: 'w-2/12',
|
||||
column4: 'w-2/12',
|
||||
column5: 'w-2/12',
|
||||
column6: 'w-2/12',
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onLibraryCreate} {onLibraryUpdate} {onLibraryDelete} />
|
||||
@@ -54,53 +73,38 @@
|
||||
<CommandPaletteDefaultProvider name={$t('library')} actions={[Create, ScanAll]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[ScanAll, Create]}>
|
||||
<section class="my-4">
|
||||
<Container size="large" center class="my-4">
|
||||
<div class="flex flex-col items-center gap-2" in:fade={{ duration: 500 }}>
|
||||
{#if libraries.length > 0}
|
||||
<table class="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="grid grid-cols-6 w-full place-items-center">
|
||||
<th class="text-center text-sm font-medium">{$t('name')}</th>
|
||||
<th class="text-center text-sm font-medium">{$t('owner')}</th>
|
||||
<th class="text-center text-sm font-medium">{$t('photos')}</th>
|
||||
<th class="text-center text-sm font-medium">{$t('videos')}</th>
|
||||
<th class="text-center text-sm font-medium">{$t('size')}</th>
|
||||
<th class="text-center text-sm font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="block overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||
<Table striped size="small" spacing="small">
|
||||
<TableHeader>
|
||||
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
|
||||
<TableHeading class={classes.column2}>{$t('owner')}</TableHeading>
|
||||
<TableHeading class={classes.column3}>{$t('photos')}</TableHeading>
|
||||
<TableHeading class={classes.column4}>{$t('videos')}</TableHeading>
|
||||
<TableHeading class={classes.column5}>{$t('size')}</TableHeading>
|
||||
<TableHeading class={classes.column6}></TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each libraries as library (library.id + library.name)}
|
||||
{@const { photos, usage, videos } = statistics[library.id]}
|
||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
||||
<tr
|
||||
class="grid grid-cols-6 h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
|
||||
>
|
||||
<td class="text-ellipsis px-4 text-sm">{library.name}</td>
|
||||
<td class="text-ellipsis px-4 text-sm">
|
||||
{owners[library.id].name}
|
||||
</td>
|
||||
<td class="text-ellipsis px-4 text-sm">
|
||||
{photos.toLocaleString($locale)}
|
||||
</td>
|
||||
<td class="text-ellipsis px-4 text-sm">
|
||||
{videos.toLocaleString($locale)}
|
||||
</td>
|
||||
<td class="text-ellipsis px-4 text-sm">
|
||||
{diskUsage}
|
||||
{diskUsageUnit}
|
||||
</td>
|
||||
|
||||
<td class="flex gap-2 text-ellipsis px-4 text-sm">
|
||||
<TableRow>
|
||||
<TableCell class={classes.column1}>{library.name}</TableCell>
|
||||
<TableCell class={classes.column2}>{owners[library.id].name}</TableCell>
|
||||
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
||||
<TableCell class={classes.column6}>
|
||||
<Button size="small" onclick={() => handleViewLibrary(library)}>{$t('view')}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{:else}
|
||||
<EmptyPlaceholder
|
||||
fullWidth
|
||||
text={$t('no_libraries_message')}
|
||||
onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)}
|
||||
class="mt-10 mx-auto"
|
||||
@@ -109,5 +113,5 @@
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleCreateLibrary } from '$lib/services/library.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { FormModal, Text } from '@immich/ui';
|
||||
import { Field, FormModal, HelperText, Select } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { type PageData } from './$types';
|
||||
@@ -17,7 +16,6 @@
|
||||
|
||||
let ownerId: string = $state($user.id);
|
||||
const users = $state(data.allUsers);
|
||||
const userOptions = $derived(users.map((user) => ({ value: user.id, text: user.name })));
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(AppRoute.ADMIN_LIBRARIES);
|
||||
@@ -34,11 +32,13 @@
|
||||
<FormModal
|
||||
title={$t('create_library')}
|
||||
icon={mdiFolderSync}
|
||||
{onClose}
|
||||
size="small"
|
||||
{onSubmit}
|
||||
submitText={$t('create')}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
>
|
||||
<SettingSelect label={$t('owner')} bind:value={ownerId} options={userOptions} name="user" />
|
||||
<Text color="warning" size="small">{$t('admin.note_cannot_be_changed_later')}</Text>
|
||||
<Field label={$t('owner')}>
|
||||
<Select bind:value={ownerId} options={users.map((user) => ({ label: user.name, value: user.id }))} />
|
||||
<HelperText color="warning">{$t('admin.note_cannot_be_changed_later')}</HelperText>
|
||||
</Field>
|
||||
</FormModal>
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteDefaultProvider, Container, Icon } from '@immich/ui';
|
||||
import {
|
||||
Button,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableRow,
|
||||
} from '@immich/ui';
|
||||
import { mdiInfinity } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -34,6 +45,13 @@
|
||||
};
|
||||
|
||||
const { Create } = $derived(getUserAdminsActions($t));
|
||||
|
||||
const classes = {
|
||||
column1: 'w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12',
|
||||
column2: 'hidden sm:block w-3/12',
|
||||
column3: 'hidden xl:block w-3/12 2xl:w-2/12',
|
||||
column4: 'w-4/12 lg:w-3/12 xl:w-2/12',
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
@@ -48,28 +66,19 @@
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||
<Container center size="large">
|
||||
<table class="my-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-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">{$t('email')}</th>
|
||||
<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>
|
||||
</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 class={classes.column1}>{$t('email')}</TableHeading>
|
||||
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>
|
||||
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{#each users as user (user.id)}
|
||||
<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'
|
||||
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
|
||||
>
|
||||
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
|
||||
{user.email}
|
||||
</td>
|
||||
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
|
||||
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
|
||||
<TableRow color={user.deletedAt ? 'danger' : undefined}>
|
||||
<TableCell class={classes.column1}>{user.email}</TableCell>
|
||||
<TableCell class={classes.column2}>{user.name}</TableCell>
|
||||
<TableCell class={classes.column3}>
|
||||
<div class="container mx-auto flex flex-wrap justify-center">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
{getByteUnitString(user.quotaSizeInBytes, $locale)}
|
||||
@@ -77,16 +86,14 @@
|
||||
<Icon icon={mdiInfinity} size="16" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<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"
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column4}>
|
||||
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{@render children?.()}
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user