mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
feat: find large files utility (#18040)
feat: large asset utility Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onViewAsset: (asset: AssetResponseDto) => void;
|
||||
}
|
||||
|
||||
let { asset, onViewAsset }: Props = $props();
|
||||
|
||||
let assetData = $derived(JSON.stringify(asset, null, 2));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<button type="button" onclick={() => onViewAsset(asset)} class="block relative w-full" aria-label={$t('keep')}>
|
||||
<!-- THUMBNAIL-->
|
||||
<img
|
||||
src={getAssetThumbnailUrl(asset.id)}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
title={assetData}
|
||||
class="h-60 object-cover rounded-t-xl w-full"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- OVERLAY CHIP -->
|
||||
{#if !!asset.libraryId}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-300/90">External</div>
|
||||
{/if}
|
||||
|
||||
<!-- FAVORITE ICON -->
|
||||
{#if asset.isFavorite}
|
||||
<div class="absolute bottom-2 start-2">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pl-2 pr-4 gap-2">
|
||||
<div class="grid gap-y-2 py-2 text-xs transition-colors dark:text-white">
|
||||
<div class="text-left text-ellipsis truncate">{asset.originalFileName}</div>
|
||||
<span>{getAssetResolution(asset)}</span>
|
||||
</div>
|
||||
<div class="dark:text-white text-lg font-bold whitespace-nowrap w-max">
|
||||
{getFileSize(asset, 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { mdiContentDuplicate } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { mdiContentDuplicate, mdiImageSizeSelectLarge } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
@@ -17,4 +17,13 @@
|
||||
</span>
|
||||
{$t('review_duplicates')}
|
||||
</a>
|
||||
<a
|
||||
href={AppRoute.LARGE_FILES}
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
|
||||
>
|
||||
<span
|
||||
><Icon path={mdiImageSizeSelectLarge} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
|
||||
</span>
|
||||
{$t('review_large_files')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ export enum AppRoute {
|
||||
|
||||
UTILITIES = '/utilities',
|
||||
DUPLICATES = '/utilities/duplicates',
|
||||
LARGE_FILES = '/utilities/large-files',
|
||||
|
||||
FOLDERS = '/folders',
|
||||
TAGS = '/tags',
|
||||
|
||||
@@ -275,9 +275,9 @@ export function isFlipped(orientation?: string | null) {
|
||||
return value && (isRotated270CW(value) || isRotated90CW(value));
|
||||
}
|
||||
|
||||
export function getFileSize(asset: AssetResponseDto): string {
|
||||
export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string {
|
||||
const size = asset.exifInfo?.fileSizeInByte || 0;
|
||||
return size > 0 ? getByteUnitString(size, undefined, 4) : 'Invalid Data';
|
||||
return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data';
|
||||
}
|
||||
|
||||
export function getAssetResolution(asset: AssetResponseDto): string {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let assets = $derived(data.assets);
|
||||
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
||||
|
||||
const onNext = () => {
|
||||
const index = getAssetIndex($viewingAsset.id) + 1;
|
||||
if (index >= assets.length) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
setAsset(assets[index]);
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
const onPrevious = () => {
|
||||
const index = getAssetIndex($viewingAsset.id) - 1;
|
||||
if (index < 0) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
setAsset(assets[index]);
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
const onRandom = () => {
|
||||
if (assets.length <= 0) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const index = Math.floor(Math.random() * assets.length);
|
||||
const asset = assets[index];
|
||||
setAsset(asset);
|
||||
return Promise.resolve(asset);
|
||||
};
|
||||
|
||||
const onAction = (payload: Action) => {
|
||||
if (payload.type == 'trash') {
|
||||
assets = assets.filter((a) => a.id != payload.asset.id);
|
||||
$showAssetViewer = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#if assets && data.assets.length > 0}
|
||||
{#each assets as asset (asset.id)}
|
||||
<LargeAssetData {asset} onViewAsset={(asset) => setAsset(asset)} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
|
||||
{$t('no_assets_to_show')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
|
||||
{#if $showAssetViewer}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
{onRandom}
|
||||
{onAction}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { searchLargeAssets } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
const assets = await searchLargeAssets({ minFileSize: 0 });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
assets,
|
||||
meta: {
|
||||
title: $t('large_files'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
Reference in New Issue
Block a user