Merge branch 'main' into feature/readonly-sharing

# Conflicts:
#	server/src/services/album.service.ts
This commit is contained in:
mgabor
2024-04-12 17:20:36 +02:00
250 changed files with 9537 additions and 4911 deletions

View File

@@ -42,7 +42,8 @@
</script>
<ConfirmDialogue
title="Delete User"
id="delete-user-confirmation-modal"
title="Delete user"
confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
onConfirm={handleDeleteUser}
onClose={() => dispatch('cancel')}

View File

@@ -147,6 +147,7 @@
{#if confirmJob}
<ConfirmDialogue
id="reprocess-faces-modal"
prompt="Are you sure you want to reprocess all faces? This will also clear named people."
{onConfirm}
onClose={() => (confirmJob = null)}

View File

@@ -28,7 +28,8 @@
</script>
<ConfirmDialogue
title="Restore User"
id="restore-user-modal"
title="Restore user"
confirmText="Continue"
confirmColor="green"
onConfirm={handleRestoreUser}

View File

@@ -5,7 +5,7 @@
export let onConfirm: () => void;
</script>
<ConfirmDialogue title="Disable Login" onClose={onCancel} {onConfirm}>
<ConfirmDialogue id="disable-login-modal" title="Disable login" onClose={onCancel} {onConfirm}>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>

View File

@@ -54,7 +54,7 @@
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="CONSTANT RATE FACTOR (-crf)"
desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, and 31 for VP9. Lower is better, but takes longer to encode and produces larger files."
desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, 31 for VP9, and 35 for AV1. Lower is better, but produces larger files."
bind:value={config.ffmpeg.crf}
required={true}
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
@@ -115,12 +115,13 @@
<SettingSelect
label="VIDEO CODEC"
{disabled}
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files."
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files. AV1 is the most efficient codec but lacks support on older devices."
bind:value={config.ffmpeg.targetVideoCodec}
options={[
{ value: VideoCodec.H264, text: 'h264' },
{ value: VideoCodec.Hevc, text: 'hevc' },
{ value: VideoCodec.Vp9, text: 'vp9' },
{ value: VideoCodec.Av1, text: 'av1' },
]}
name="vcodec"
isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec}
@@ -137,6 +138,7 @@
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
{ value: VideoCodec.Vp9, text: 'VP9' },
{ value: VideoCodec.Av1, text: 'AV1' },
]}
isEdited={!isEqual(sortBy(config.ffmpeg.acceptedVideoCodecs), sortBy(savedConfig.ffmpeg.acceptedVideoCodecs))}
/>
@@ -179,7 +181,7 @@
<SettingSelect
label="TRANSCODE POLICY"
{disabled}
desc="Policy for when a video should be transcoded."
desc="Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled)."
bind:value={config.ffmpeg.transcode}
name="transcode"
options={[
@@ -232,6 +234,7 @@
/>
<SettingSwitch
id="two-pass-encoding"
title="TWO-PASS ENCODING"
{disabled}
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
@@ -283,9 +286,11 @@
{ value: CQMode.Cqp, text: 'CQP' },
]}
isEdited={config.ffmpeg.cqMode !== savedConfig.ffmpeg.cqMode}
{disabled}
/>
<SettingSwitch
id="temporal-aq"
title="TEMPORAL AQ"
{disabled}
subtitle="Applies only to NVENC. Increases quality of high-detail, low-motion scenes. May not be compatible with older devices."
@@ -294,10 +299,11 @@
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PREFERRED HARDWARE DEVICE FOR TRANSCODING"
desc="Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding. Set to 'auto' to let immich decide for you"
label="PREFERRED HARDWARE DEVICE"
desc="Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding."
bind:value={config.ffmpeg.preferredHwDevice}
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
{disabled}
/>
</div>
</SettingAccordion>
@@ -314,6 +320,7 @@
desc="Colors will be adjusted to look normal for a display of this brightness. Counter-intuitively, lower values increase the brightness of the video and vice versa since it compensates for the brightness of the display. 0 sets this value automatically."
bind:value={config.ffmpeg.npl}
isEdited={config.ffmpeg.npl !== savedConfig.ffmpeg.npl}
{disabled}
/>
<SettingInputField
@@ -322,6 +329,7 @@
desc="Higher values improve compression efficiency, but slow down encoding. May not be compatible with hardware acceleration on older devices. 0 disables B-frames, while -1 sets this value automatically."
bind:value={config.ffmpeg.bframes}
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
{disabled}
/>
<SettingInputField
@@ -330,6 +338,7 @@
desc="The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically."
bind:value={config.ffmpeg.refs}
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
{disabled}
/>
<SettingInputField
@@ -338,6 +347,7 @@
desc="Sets the maximum frame distance between keyframes. Lower values worsen compression efficiency, but improve seek times and may improve quality in scenes with fast movement. 0 sets this value automatically."
bind:value={config.ffmpeg.gopSize}
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
{disabled}
/>
</div>
</SettingAccordion>
@@ -347,7 +357,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['ffmpeg'] })}
on:save={() => dispatch('save', { ffmpeg: config.ffmpeg })}
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)}
{disabled}
/>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Colorspace, type SystemConfigDto } from '@immich/sdk';
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
@@ -24,6 +24,19 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSelect
label="THUMBNAIL FORMAT"
desc="WebP produces smaller files than JPEG, but is slower to encode."
bind:value={config.image.thumbnailFormat}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.thumbnailFormat !== savedConfig.image.thumbnailFormat}
{disabled}
/>
<SettingSelect
label="THUMBNAIL RESOLUTION"
desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
@@ -41,6 +54,19 @@
{disabled}
/>
<SettingSelect
label="PREVIEW FORMAT"
desc="WebP produces smaller files than JPEG, but is slower to encode."
bind:value={config.image.previewFormat}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.previewFormat !== savedConfig.image.previewFormat}
{disabled}
/>
<SettingSelect
label="PREVIEW RESOLUTION"
desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
@@ -63,14 +89,17 @@
desc="Image quality from 1-100. Higher is better for quality but produces larger files."
bind:value={config.image.quality}
isEdited={config.image.quality !== savedConfig.image.quality}
{disabled}
/>
<SettingSwitch
id="prefer-wide-gamut"
title="PREFER WIDE GAMUT"
subtitle="Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts."
checked={config.image.colorspace === Colorspace.P3}
on:toggle={(e) => (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)}
isEdited={config.image.colorspace !== savedConfig.image.colorspace}
{disabled}
/>
</div>
@@ -78,7 +107,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['image'] })}
on:save={() => dispatch('save', { image: config.image })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)}
{disabled}
/>
</div>

View File

@@ -37,6 +37,7 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="watch-filesystem"
title="Watch filesystem"
{disabled}
subtitle="Watch external libraries for file changes"
@@ -64,6 +65,7 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="periodic-library-scan"
title="ENABLED"
{disabled}
subtitle="Enable periodic library scanning"

View File

@@ -20,7 +20,13 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch title="ENABLED" {disabled} subtitle="Logging" bind:checked={config.logging.enabled} />
<SettingSwitch
id="enable-logging"
title="ENABLED"
{disabled}
subtitle="Logging"
bind:checked={config.logging.enabled}
/>
<SettingSelect
label="LEVEL"
desc="When enabled, what log level to use."

View File

@@ -25,6 +25,7 @@
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
<div class="flex flex-col gap-4">
<SettingSwitch
id="enable-machine-learning"
title="ENABLED"
subtitle="If disabled, all ML features will be disabled regardless of the below settings."
{disabled}
@@ -51,6 +52,7 @@
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-clip"
title="ENABLED"
subtitle="If disabled, images will not be encoded for smart search."
bind:checked={config.machineLearning.clip.enabled}
@@ -82,6 +84,7 @@
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-facial-recognition"
title="ENABLED"
subtitle="If disabled, images will not be encoded for facial recognition and will not populate the People section in the Explore page."
bind:checked={config.machineLearning.facialRecognition.enabled}

View File

@@ -26,6 +26,7 @@
<SettingAccordion key="map" title="Map Settings" subtitle="Manage map settings">
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-map-features"
title="ENABLED"
{disabled}
subtitle="Enable map features"
@@ -66,6 +67,7 @@
</svelte:fragment>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-reverse-geocoding"
title="ENABLED"
{disabled}
subtitle="Enable reverse geocoding"

View File

@@ -20,6 +20,7 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4">
<SettingSwitch
id="enable-new-version-check"
title="ENABLED"
subtitle="Enable periodic requests to GitHub to check for new releases"
bind:checked={config.newVersionCheck.enabled}
@@ -28,7 +29,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['newVersionCheck'] })}
on:save={() => dispatch('save', { newVersionCheck: config.newVersionCheck })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)}
{disabled}
/>
</div>

View File

@@ -71,7 +71,13 @@
>.
</p>
<SettingSwitch {disabled} title="ENABLE" subtitle="Login with OAuth" bind:checked={config.oauth.enabled} />
<SettingSwitch
id="login-with-oauth"
{disabled}
title="ENABLE"
subtitle="Login with OAuth"
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
@@ -160,6 +166,7 @@
/>
<SettingSwitch
id="auto-register-new-users"
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
@@ -167,6 +174,7 @@
/>
<SettingSwitch
id="auto-launch-oauth"
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
@@ -174,6 +182,7 @@
/>
<SettingSwitch
id="mobile-redirect-uri-override"
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}

View File

@@ -49,6 +49,7 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col">
<SettingSwitch
id="enable-password-login"
title="ENABLED"
{disabled}
subtitle="Login with email and password"

View File

@@ -41,7 +41,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['server'] })}
on:save={() => dispatch('save', { server: config.server })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)}
{disabled}
/>
</div>

View File

@@ -107,6 +107,7 @@
{#await getTemplateOptions() then}
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}">
<SettingSwitch
id="storage-template-enabled"
title="ENABLED"
{disabled}
subtitle="Enable storage template engine"
@@ -116,6 +117,7 @@
{#if !minified}
<SettingSwitch
id="hash-verification-enabled"
title="HASH VERIFICATION ENABLED"
{disabled}
subtitle="Enables hash verification, don't disable this unless you're certain of the implications"
@@ -234,7 +236,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['storageTemplate'] })}
on:save={() => dispatch('save', { storageTemplate: config.storageTemplate })}
showResetToDefault={!isEqual(savedConfig, defaultConfig) && !minified}
showResetToDefault={!isEqual(savedConfig.storageTemplate, defaultConfig.storageTemplate) && !minified}
{disabled}
/>
{/if}

View File

@@ -23,6 +23,7 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-trash-features"
title="ENABLED"
{disabled}
subtitle="Enable Trash features"
@@ -44,7 +45,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['trash'] })}
on:save={() => dispatch('save', { trash: config.trash })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.trash, defaultConfig.trash)}
{disabled}
/>
</div>

View File

@@ -36,7 +36,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['user'] })}
on:save={() => dispatch('save', { user: config.user })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.user, defaultConfig.user)}
{disabled}
/>
</div>

View File

@@ -1,6 +1,4 @@
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { ThumbnailFormat } from '@immich/sdk';
import { albumFactory } from '@test-data';
import '@testing-library/jest-dom';
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
@@ -33,7 +31,7 @@ describe('AlbumCard component', () => {
shared: true,
},
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
sut = render(AlbumCard, { album });
sut = render(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
@@ -52,36 +50,22 @@ describe('AlbumCard component', () => {
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
});
it('shows album data and loads the thumbnail image when available', async () => {
const thumbnailFile = new File([new Blob()], 'fileThumbnail');
const thumbnailUrl = 'blob:thumbnailUrlOne';
sdkMock.getAssetThumbnail.mockResolvedValue(thumbnailFile);
createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
it('shows album data', () => {
const album = albumFactory.build({
albumThumbnailAssetId: 'thumbnailIdOne',
shared: false,
albumName: 'some album name',
});
sut = render(AlbumCard, { album });
sut = render(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl));
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
expect(sdkMock.getAssetThumbnail).toHaveBeenCalledTimes(1);
expect(sdkMock.getAssetThumbnail).toHaveBeenCalledWith({
id: 'thumbnailIdOne',
format: ThumbnailFormat.Jpeg,
});
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile);
expect(albumImgElement).toHaveAttribute('src');
expect(albumNameElement).toHaveTextContent('some album name');
expect(albumDetailsElement).toHaveTextContent('0 items');
expect(albumDetailsElement).toHaveTextContent('0 item');
});
it('hides context menu when "onShowContextMenu" is undefined', () => {

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition';
import { AppRoute } from '$lib/constants';
import type { AlbumResponseDto } from '@immich/sdk';
import { albumViewSettings } from '$lib/stores/preferences.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { type AlbumGroup, isAlbumGroupCollapsed, toggleAlbumGroupCollapsing } from '$lib/utils/album-utils';
import { mdiChevronRight } from '@mdi/js';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import Icon from '$lib/components/elements/icon.svelte';
export let albums: AlbumResponseDto[];
export let group: AlbumGroup | undefined = undefined;
export let showOwner = false;
export let showDateRange = false;
export let showItemCount = false;
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
undefined;
$: isCollapsed = !!group && isAlbumGroupCollapsed($albumViewSettings, group.id);
const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => {
onShowContextMenu?.(position, album);
};
$: iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90';
</script>
{#if group}
<div class="grid">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<p on:click={() => toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 hover:cursor-pointer">
<Icon
path={mdiChevronRight}
size="24"
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<span class="ml-1.5 dark:text-immich-dark-fg">({albums.length} {albums.length > 1 ? 'albums' : 'album'})</span>
</p>
<hr class="dark:border-immich-dark-gray" />
</div>
{/if}
<div class="mt-4">
{#if !isCollapsed}
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-y-4" transition:slide={{ duration: 300 }}>
{#each albums as album, index (album.id)}
<a
data-sveltekit-preload-data="hover"
href="{AppRoute.ALBUMS}/{album.id}"
animate:flip={{ duration: 400 }}
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y }, album)}
>
<AlbumCard
{album}
{showOwner}
{showDateRange}
{showItemCount}
preload={index < 20}
onShowContextMenu={onShowContextMenu ? (position) => showContextMenu(position, album) : undefined}
/>
</a>
{/each}
</div>
{/if}
</div>

View File

@@ -2,43 +2,25 @@
import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, getAssetThumbnail, getUserById, type AlbumResponseDto } from '@immich/sdk';
import type { AlbumResponseDto } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js';
import { onMount } from 'svelte';
import { getContextMenuPosition, type ContextMenuPosition } from '../../utils/context-menu';
import IconButton from '../elements/buttons/icon-button.svelte';
import { getContextMenuPosition, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time';
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
export let album: AlbumResponseDto;
export let isSharingView = false;
export let showItemCount = true;
export let showOwner = false;
export let showDateRange = false;
export let showItemCount = false;
export let preload = false;
export let onShowContextMenu: ((position: ContextMenuPosition) => void) | undefined = undefined;
$: imageData = album.albumThumbnailAssetId
? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
: null;
const loadHighQualityThumbnail = async (assetId: string | null) => {
if (!assetId) {
return;
}
const data = await getAssetThumbnail({ id: assetId, format: ThumbnailFormat.Jpeg });
return URL.createObjectURL(data);
};
export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined;
const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onShowContextMenu?.(getContextMenuPosition(e));
};
onMount(async () => {
imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || null;
});
const getAlbumOwnerInfo = () => getUserById({ id: album.ownerId });
</script>
<div
@@ -57,60 +39,43 @@
</div>
{/if}
<div class={`relative aspect-square`}>
{#if album.albumThumbnailAssetId}
<img
loading={preload ? 'eager' : 'lazy'}
src={imageData}
alt={album.albumName}
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{:else}
<enhanced:img
loading={preload ? 'eager' : 'lazy'}
src="$lib/assets/no-thumbnail.png"
sizes="min(271px,186px)"
alt={album.albumName}
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{/if}
</div>
<AlbumCover {album} {preload} css="h-full w-full transition-all duration-300 hover:shadow-lg" />
<div class="mt-4">
<p
class="w-full truncate text-lg font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
data-testid="album-name"
title={album.albumName}
>
{album.albumName}
</p>
<span class="flex gap-2 text-sm dark:text-immich-dark-fg" data-testid="album-details">
{#if showDateRange && album.startDate && album.endDate}
<p class="flex text-sm dark:text-immich-dark-fg capitalize">
{getShortDateRange(album.startDate, album.endDate)}
</p>
{/if}
<span class="flex gap-2 text-sm" data-testid="album-details">
{#if showItemCount}
<p>
{album.assetCount.toLocaleString($locale)}
{album.assetCount == 1 ? `item` : `items`}
{album.assetCount === 1 ? `item` : `items`}
</p>
{/if}
{#if isSharingView || album.shared}
<p>·</p>
{#if (showOwner || album.shared) && showItemCount}
<p></p>
{/if}
{#if isSharingView}
{#await getAlbumOwnerInfo() then albumOwner}
{#if $user.email == albumOwner.email}
<p>Owned</p>
{:else}
<p>
Shared by {albumOwner.name}
</p>
{/if}
{/await}
{#if showOwner}
{#if $user.id === album.ownerId}
<p>Owned</p>
{:else if album.owner}
<p>Shared by {album.owner.name}</p>
{:else}
<p>Shared</p>
{/if}
{:else if album.shared}
<p>Shared</p>
{/if}

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { getAssetThumbnailUrl } from '$lib/utils';
export let album: AlbumResponseDto | undefined;
export let preload = false;
export let css = '';
$: thumbnailUrl =
album && album.albumThumbnailAssetId
? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
: null;
</script>
<div class="relative aspect-square">
{#if thumbnailUrl}
<img
loading={preload ? 'eager' : 'lazy'}
src={thumbnailUrl}
alt={album?.albumName ?? 'Unknown Album'}
class="z-0 rounded-xl object-cover {css}"
data-testid="album-image"
draggable="false"
/>
{:else}
<enhanced:img
loading={preload ? 'eager' : 'lazy'}
src="$lib/assets/no-thumbnail.png"
sizes="min(271px,186px)"
alt={album?.albumName ?? 'Empty Album'}
class="z-0 rounded-xl object-cover {css}"
data-testid="album-image"
draggable="false"
/>
{/if}
</div>

View File

@@ -37,7 +37,7 @@
on:input={(e) => autoGrowHeight(e.currentTarget)}
on:focusout={handleUpdateDescription}
use:autoGrowHeight
placeholder="Add description"
placeholder="Add a description"
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),

View File

@@ -1,10 +1,8 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
import { mdiArrowDownThin, mdiArrowUpThin, mdiClose, mdiPlus } from '@mdi/js';
import { mdiArrowDownThin, mdiArrowUpThin, mdiPlus } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
@@ -52,66 +50,52 @@
};
</script>
<FullScreenModal onClose={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden p-2 md:p-0">
<div
class="w-[550px] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="px-2 pt-2">
<div class="flex items-center">
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">Options</h1>
<FullScreenModal id="album-options-modal" title="Options" onClose={() => dispatch('close')}>
<div class="items-center justify-center">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">SETTINGS</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title="Display order"
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
/>
{/if}
<SettingSwitch
id="comments-likes"
title="Comments & likes"
subtitle="Let others respond"
checked={album.isActivityEnabled}
on:toggle={() => dispatch('toggleEnableActivity')}
/>
</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="p-2">
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>Invite People</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>Owner</div>
</div>
<div class=" items-center justify-center p-4">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">SETTINGS</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title="Display order"
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
/>
{/if}
<SettingSwitch
title="Comments & likes"
subtitle="Let others respond"
checked={album.isActivityEnabled}
on:toggle={() => dispatch('toggleEnableActivity')}
/>
{#each album.sharedUsers as user (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="p-2">
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>Invite People</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>Owner</div>
</div>
{#each album.sharedUsers as user (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
</div>
{/each}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { createAssetInteractionStore } from '../../stores/asset-interaction.store';
import { AssetStore } from '../../stores/assets.store';
import { downloadArchive } from '../../utils/asset-utils';
import { downloadAlbum } from '../../utils/asset-utils';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
@@ -36,10 +36,6 @@
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
const downloadAlbum = async () => {
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id });
};
</script>
<svelte:window
@@ -83,7 +79,7 @@
{/if}
{#if album.assetCount > 0 && sharedLink.allowDownload}
<CircleIconButton title="Download" on:click={() => downloadAlbum()} icon={mdiFolderDownloadOutline} />
<CircleIconButton title="Download" on:click={() => downloadAlbum(album)} icon={mdiFolderDownloadOutline} />
{/if}
<ThemeButton />

View File

@@ -2,30 +2,94 @@
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { AlbumFilter, AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
import {
AlbumFilter,
AlbumGroupBy,
AlbumViewMode,
albumViewSettings,
SortOrder,
} from '$lib/stores/preferences.store';
import {
mdiArrowDownThin,
mdiArrowUpThin,
mdiFolderArrowDownOutline,
mdiFolderArrowUpOutline,
mdiFolderRemoveOutline,
mdiFormatListBulletedSquare,
mdiPlusBoxOutline,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
mdiViewGridOutline,
} from '@mdi/js';
import { sortByOptions, type Sort, handleCreateAlbum } from '$lib/components/album-page/albums-list.svelte';
import {
type AlbumGroupOptionMetadata,
type AlbumSortOptionMetadata,
findGroupOptionMetadata,
findSortOptionMetadata,
getSelectedAlbumGroupOption,
groupOptionsMetadata,
sortOptionsMetadata,
} from '$lib/utils/album-utils';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import GroupTab from '$lib/components/elements/group-tab.svelte';
import { createAlbumAndRedirect, collapseAllAlbumGroups, expandAllAlbumGroups } from '$lib/utils/album-utils';
import { fly } from 'svelte/transition';
export let searchAlbum: string;
export let albumGroups: string[];
export let searchQuery: string;
const searchSort = (searched: string): Sort => {
return sortByOptions.find((option) => option.title === searched) || sortByOptions[0];
const flipOrdering = (ordering: string) => {
return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
};
const handleChangeGroupBy = ({ id, defaultOrder }: AlbumGroupOptionMetadata) => {
if ($albumViewSettings.groupBy === id) {
$albumViewSettings.groupOrder = flipOrdering($albumViewSettings.groupOrder);
} else {
$albumViewSettings.groupBy = id;
$albumViewSettings.groupOrder = defaultOrder;
}
};
const handleChangeSortBy = ({ id, defaultOrder }: AlbumSortOptionMetadata) => {
if ($albumViewSettings.sortBy === id) {
$albumViewSettings.sortOrder = flipOrdering($albumViewSettings.sortOrder);
} else {
$albumViewSettings.sortBy = id;
$albumViewSettings.sortOrder = defaultOrder;
}
};
const handleChangeListMode = () => {
$albumViewSettings.view =
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
};
let selectedGroupOption: AlbumGroupOptionMetadata;
let groupIcon: string;
$: {
selectedGroupOption = findGroupOptionMetadata($albumViewSettings.groupBy);
if (selectedGroupOption.isDisabled()) {
selectedGroupOption = findGroupOptionMetadata(AlbumGroupBy.None);
}
}
$: selectedSortOption = findSortOptionMetadata($albumViewSettings.sortBy);
$: {
if (selectedGroupOption.id === AlbumGroupBy.None) {
groupIcon = mdiFolderRemoveOutline;
} else {
groupIcon =
$albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
}
}
$: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin;
</script>
<!-- Filter Albums by Sharing Status (All, Owned, Shared) -->
<div class="hidden xl:block h-10">
<GroupTab
filters={Object.keys(AlbumFilter)}
@@ -33,44 +97,78 @@
onSelect={(selected) => ($albumViewSettings.filter = selected)}
/>
</div>
<div class="hidden xl:block xl:w-60 2xl:w-80 h-10">
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
<!-- Search Albums -->
<div class="hidden xl:block h-10 xl:w-60 2xl:w-80">
<SearchBar placeholder="Search albums" bind:name={searchQuery} isSearching={false} />
</div>
<LinkButton on:click={handleCreateAlbum}>
<!-- Create Album -->
<LinkButton on:click={() => createAlbumAndRedirect()}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" />
<p class="hidden md:block">Create album</p>
</div>
</LinkButton>
<!-- Sort Albums -->
<Dropdown
options={Object.values(sortByOptions)}
selectedOption={searchSort($albumViewSettings.sortBy)}
render={(option) => {
return {
title: option.title,
icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
};
}}
on:select={(event) => {
for (const key of sortByOptions) {
if (key.title === event.detail.title) {
key.sortDesc = !key.sortDesc;
$albumViewSettings.sortBy = key.title;
break;
}
}
}}
title="Sort albums by..."
options={Object.values(sortOptionsMetadata)}
selectedOption={selectedSortOption}
on:select={({ detail }) => handleChangeSortBy(detail)}
render={({ text }) => ({
title: text,
icon: sortIcon,
})}
/>
<!-- Group Albums -->
<Dropdown
title="Group albums by..."
options={Object.values(groupOptionsMetadata)}
selectedOption={selectedGroupOption}
on:select={({ detail }) => handleChangeGroupBy(detail)}
render={({ text, isDisabled }) => ({
title: text,
icon: groupIcon,
disabled: isDisabled(),
})}
/>
{#if getSelectedAlbumGroupOption($albumViewSettings) !== AlbumGroupBy.None}
<span in:fly={{ x: -50, duration: 250 }}>
<!-- Expand Album Groups -->
<div class="hidden xl:flex gap-0">
<div class="block">
<LinkButton title="Expand all" on:click={() => expandAllAlbumGroups()}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiUnfoldMoreHorizontal} size="18" />
</div>
</LinkButton>
</div>
<!-- Collapse Album Groups -->
<div class="block">
<LinkButton title="Collapse all" on:click={() => collapseAllAlbumGroups(albumGroups)}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiUnfoldLessHorizontal} size="18" />
</div>
</LinkButton>
</div>
</div>
</span>
{/if}
<!-- Cover/List Display Toggle -->
<LinkButton on:click={() => handleChangeListMode()}>
<div class="flex place-items-center gap-2 text-sm">
{#if $albumViewSettings.view === AlbumViewMode.List}
<Icon path={mdiViewGridOutline} size="18" />
<p class="hidden sm:block">Cover</p>
<p class="hidden md:block">Covers</p>
{:else}
<Icon path={mdiFormatListBulletedSquare} size="18" />
<p class="hidden sm:block">List</p>
<p class="hidden md:block">List</p>
{/if}
</div>
</LinkButton>

View File

@@ -1,206 +1,279 @@
<script lang="ts" context="module">
import { AlbumFilter, AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { createAlbum, deleteAlbum, type AlbumResponseDto } from '@immich/sdk';
import { get } from 'svelte/store';
export const handleCreateAlbum = async () => {
try {
const newAlbum = await createAlbum({ createAlbumDto: { albumName: '' } });
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
} catch (error) {
handleError(error, 'Unable to create album');
}
};
export interface Sort {
title: string;
sortDesc: boolean;
widthClass: string;
sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
}
export let sortByOptions: Sort[] = [
{
title: 'Album title',
sortDesc: get(albumViewSettings).sortDesc, // Load Sort Direction
widthClass: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
sortFn: (reverse, albums) => {
return orderBy(albums, 'albumName', [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Number of assets',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, 'assetCount', [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Last modified',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, [(album) => new Date(album.updatedAt)], [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Created date',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(albums, [(album) => new Date(album.createdAt)], [reverse ? 'desc' : 'asc']);
},
},
{
title: 'Most recent photo',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(
albums,
[(album) => (album.endDate ? new Date(album.endDate) : '')],
[reverse ? 'desc' : 'asc'],
).sort((a, b) => {
if (a.endDate === undefined) {
return 1;
}
if (b.endDate === undefined) {
return -1;
}
return 0;
});
},
},
{
title: 'Oldest photo',
sortDesc: get(albumViewSettings).sortDesc,
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
sortFn: (reverse, albums) => {
return orderBy(
albums,
[(album) => (album.startDate ? new Date(album.startDate) : null)],
[reverse ? 'desc' : 'asc'],
).sort((a, b) => {
if (a.startDate === undefined) {
return 1;
}
if (b.startDate === undefined) {
return -1;
}
return 0;
});
},
},
];
</script>
<script lang="ts">
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import { onMount } from 'svelte';
import { groupBy, orderBy } from 'lodash-es';
import { addUsersToAlbum, deleteAlbum, type UserResponseDto, type AlbumResponseDto } from '@immich/sdk';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { mdiDeleteOutline } from '@mdi/js';
import { orderBy } from 'lodash-es';
import { onMount } from 'svelte';
import { flip } from 'svelte/animate';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { downloadAlbum } from '$lib/utils/asset-utils';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { getSelectedAlbumGroupOption, type AlbumGroup } from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import GroupTab from '$lib/components/elements/group-tab.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { user } from '$lib/stores/user.store';
import {
AlbumGroupBy,
AlbumSortBy,
AlbumFilter,
AlbumViewMode,
SortOrder,
locale,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
export let ownedAlbums: AlbumResponseDto[];
export let sharedAlbums: AlbumResponseDto[];
export let searchAlbum: string;
export let ownedAlbums: AlbumResponseDto[] = [];
export let sharedAlbums: AlbumResponseDto[] = [];
export let searchQuery: string = '';
export let userSettings: AlbumViewSettings;
export let allowEdit = false;
export let showOwner = false;
export let albumGroupIds: string[] = [];
interface AlbumGroupOption {
[option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[];
}
interface AlbumSortOption {
[option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumResponseDto[];
}
const groupOptions: AlbumGroupOption = {
/** No grouping */
[AlbumGroupBy.None]: (order, albums): AlbumGroup[] => {
return [
{
id: 'Albums',
name: 'Albums',
albums,
},
];
},
/** Group by year */
[AlbumGroupBy.Year]: (order, albums): AlbumGroup[] => {
const unknownYear = 'Unknown Year';
const useStartDate = userSettings.sortBy === AlbumSortBy.OldestPhoto;
const groupedByYear = groupBy(albums, (album) => {
const date = useStartDate ? album.startDate : album.endDate;
return date ? new Date(date).getFullYear() : unknownYear;
});
const sortSign = order === SortOrder.Desc ? -1 : 1;
const sortedByYear = Object.entries(groupedByYear).sort(([a], [b]) => {
// We make sure empty albums stay at the end of the list
if (a === unknownYear) {
return 1;
} else if (b === unknownYear) {
return -1;
} else {
return (Number.parseInt(a) - Number.parseInt(b)) * sortSign;
}
});
return sortedByYear.map(([year, albums]) => ({
id: year,
name: year,
albums,
}));
},
/** Group by owner */
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
const currentUserId = $user.id;
const groupedByOwnerIds = groupBy(albums, 'ownerId');
const sortSign = order === SortOrder.Desc ? -1 : 1;
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerA, albumsA], [ownerB, albumsB]) => {
// We make sure owned albums stay either at the beginning or the end
// of the list
if (ownerA === currentUserId) {
return -sortSign;
} else if (ownerB === currentUserId) {
return sortSign;
} else {
return albumsA[0].owner.name.localeCompare(albumsB[0].owner.name, $locale) * sortSign;
}
});
return sortedByOwnerNames.map(([ownerId, albums]) => ({
id: ownerId,
name: ownerId === currentUserId ? 'My albums' : albums[0].owner.name,
albums,
}));
},
};
const sortOptions: AlbumSortOption = {
/** Sort by album title */
[AlbumSortBy.Title]: (order, albums) => {
const sortSign = order === SortOrder.Desc ? -1 : 1;
return albums.slice().sort((a, b) => a.albumName.localeCompare(b.albumName, $locale) * sortSign);
},
/** Sort by asset count */
[AlbumSortBy.ItemCount]: (order, albums) => {
return orderBy(albums, 'assetCount', [order]);
},
/** Sort by last modified */
[AlbumSortBy.DateModified]: (order, albums) => {
return orderBy(albums, [({ updatedAt }) => new Date(updatedAt)], [order]);
},
/** Sort by creation date */
[AlbumSortBy.DateCreated]: (order, albums) => {
return orderBy(albums, [({ createdAt }) => new Date(createdAt)], [order]);
},
/** Sort by the most recent photo date */
[AlbumSortBy.MostRecentPhoto]: (order, albums) => {
albums = orderBy(albums, [({ endDate }) => (endDate ? new Date(endDate) : '')], [order]);
return albums.sort(sortUnknownYearAlbums);
},
/** Sort by the oldest photo date */
[AlbumSortBy.OldestPhoto]: (order, albums) => {
albums = orderBy(albums, [({ startDate }) => (startDate ? new Date(startDate) : '')], [order]);
return albums.sort(sortUnknownYearAlbums);
},
};
let albums: AlbumResponseDto[] = [];
let shouldShowEditAlbumForm = false;
let selectedAlbum: AlbumResponseDto;
let albumToDelete: AlbumResponseDto | null;
let filteredAlbums: AlbumResponseDto[] = [];
let groupedAlbums: AlbumGroup[] = [];
let albumGroupOption: string = AlbumGroupBy.None;
let showShareByURLModal = false;
let albumToEdit: AlbumResponseDto | null = null;
let albumToShare: AlbumResponseDto | null = null;
let albumToDelete: AlbumResponseDto | null = null;
let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
let contextMenuTargetAlbum: AlbumResponseDto | undefined = undefined;
let contextMenuTargetAlbum: AlbumResponseDto | null = null;
// Step 1: Filter between Owned and Shared albums, or both.
$: {
for (const key of sortByOptions) {
if (key.title === $albumViewSettings.sortBy) {
switch ($albumViewSettings.filter) {
case AlbumFilter.All: {
albums = key.sortFn(
key.sortDesc,
[...sharedAlbums, ...ownedAlbums].filter(
(album, index, self) => index === self.findIndex((item) => album.id === item.id),
),
);
break;
}
case AlbumFilter.Owned: {
albums = key.sortFn(key.sortDesc, ownedAlbums);
break;
}
case AlbumFilter.Shared: {
albums = key.sortFn(key.sortDesc, sharedAlbums);
break;
}
default: {
albums = key.sortFn(
key.sortDesc,
[...sharedAlbums, ...ownedAlbums].filter(
(album, index, self) => index === self.findIndex((item) => album.id === item.id),
),
);
break;
}
}
$albumViewSettings.sortDesc = key.sortDesc;
$albumViewSettings.sortBy = key.title;
switch (userSettings.filter) {
case AlbumFilter.Owned: {
albums = ownedAlbums;
break;
}
case AlbumFilter.Shared: {
albums = sharedAlbums;
break;
}
default: {
const userId = $user.id;
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== userId);
albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
}
$: isShowContextMenu = !!contextMenuTargetAlbum;
$: albumsFiltered = albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase()));
// Step 2: Filter using the given search query.
$: {
if (searchQuery) {
const searchAlbumNormalized = normalizeSearchString(searchQuery);
filteredAlbums = albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(searchAlbumNormalized);
});
} else {
filteredAlbums = albums;
}
}
// Step 3: Group albums.
$: {
albumGroupOption = getSelectedAlbumGroupOption(userSettings);
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
}
// Step 4: Sort albums amongst each group.
$: {
const defaultSortOption = AlbumSortBy.DateModified;
const selectedSortOption = userSettings.sortBy ?? defaultSortOption;
const sortFunc = sortOptions[selectedSortOption] ?? sortOptions[defaultSortOption];
const sortOrder = stringToSortOrder(userSettings.sortOrder);
groupedAlbums = groupedAlbums.map((group) => ({
id: group.id,
name: group.name,
albums: sortFunc(sortOrder, group.albums),
}));
albumGroupIds = groupedAlbums.map(({ id }) => id);
}
$: showContextMenu = !!contextMenuTargetAlbum;
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
onMount(async () => {
await removeAlbumsIfEmpty();
if (allowEdit) {
await removeAlbumsIfEmpty();
}
});
function showAlbumContextMenu(contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto): void {
const sortUnknownYearAlbums = (a: AlbumResponseDto, b: AlbumResponseDto) => {
if (!a.endDate) {
return 1;
}
if (!b.endDate) {
return -1;
}
return 0;
};
const stringToSortOrder = (order: string) => {
return order === 'desc' ? SortOrder.Desc : SortOrder.Asc;
};
const showAlbumContextMenu = (contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto) => {
contextMenuTargetAlbum = album;
contextMenuPosition = {
x: contextMenuDetail.x,
y: contextMenuDetail.y,
};
}
};
function closeAlbumContextMenu() {
contextMenuTargetAlbum = undefined;
}
const closeAlbumContextMenu = () => {
contextMenuTargetAlbum = null;
};
const handleDownloadAlbum = async () => {
if (contextMenuTargetAlbum) {
const album = contextMenuTargetAlbum;
closeAlbumContextMenu();
await downloadAlbum(album);
}
};
const handleDeleteAlbum = async (albumToDelete: AlbumResponseDto) => {
await deleteAlbum({
id: albumToDelete.id,
});
async function handleDeleteAlbum(albumToDelete: AlbumResponseDto): Promise<void> {
await deleteAlbum({ id: albumToDelete.id });
ownedAlbums = ownedAlbums.filter(({ id }) => id !== albumToDelete.id);
}
const chooseAlbumToDelete = (album: AlbumResponseDto) => {
contextMenuTargetAlbum = album;
setAlbumToDelete();
sharedAlbums = sharedAlbums.filter(({ id }) => id !== albumToDelete.id);
};
const setAlbumToDelete = () => {
@@ -209,8 +282,8 @@
};
const handleEdit = (album: AlbumResponseDto) => {
selectedAlbum = { ...album };
shouldShowEditAlbumForm = true;
albumToEdit = album;
closeAlbumContextMenu();
};
const deleteSelectedAlbum = async () => {
@@ -230,101 +303,171 @@
};
const removeAlbumsIfEmpty = async () => {
for (const album of ownedAlbums) {
if (album.assetCount == 0 && album.albumName == '') {
try {
await handleDeleteAlbum(album);
} catch (error) {
console.log(error);
}
}
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album)));
};
const updateAlbumInfo = (album: AlbumResponseDto) => {
ownedAlbums[ownedAlbums.findIndex(({ id }) => id === album.id)] = album;
sharedAlbums[sharedAlbums.findIndex(({ id }) => id === album.id)] = album;
};
const successEditAlbumInfo = (album: AlbumResponseDto) => {
albumToEdit = null;
notificationController.show({
message: 'Album info updated',
type: NotificationType.Info,
button: {
text: 'View Album',
onClick() {
return goto(`${AppRoute.ALBUMS}/${album.id}`);
},
},
});
updateAlbumInfo(album);
};
const handleAddUsers = async (users: UserResponseDto[]) => {
if (!albumToShare) {
return;
}
try {
const album = await addUsersToAlbum({
id: albumToShare.id,
addUsersDto: {
sharedUserIds: [...users].map(({ id }) => id),
},
});
updateAlbumInfo(album);
} catch (error) {
handleError(error, 'Error adding users to album');
} finally {
albumToShare = null;
}
};
const successModifyAlbum = () => {
shouldShowEditAlbumForm = false;
notificationController.show({
message: 'Album infos updated',
type: NotificationType.Info,
});
ownedAlbums[ownedAlbums.findIndex((x) => x.id === selectedAlbum.id)] = selectedAlbum;
const handleSharedLinkCreated = (album: AlbumResponseDto) => {
album.shared = true;
album.hasSharedLink = true;
updateAlbumInfo(album);
};
const openShareModal = () => {
albumToShare = contextMenuTargetAlbum;
closeAlbumContextMenu();
};
const closeShareModal = () => {
albumToShare = null;
showShareByURLModal = false;
};
</script>
{#if shouldShowEditAlbumForm}
<FullScreenModal onClose={() => (shouldShowEditAlbumForm = false)}>
<EditAlbumForm
album={selectedAlbum}
on:editSuccess={() => successModifyAlbum()}
on:cancel={() => (shouldShowEditAlbumForm = false)}
/>
</FullScreenModal>
{/if}
{#if albums.length > 0}
<!-- Album Card -->
<div class="xl:hidden">
<div class="w-fit h-14 dark:text-immich-dark-fg py-2">
<GroupTab
filters={Object.keys(AlbumFilter)}
selected={$albumViewSettings.filter}
onSelect={(selected) => ($albumViewSettings.filter = selected)}
{#if userSettings.view === AlbumViewMode.Cover}
<!-- Album Cards -->
{#if albumGroupOption === AlbumGroupBy.None}
<AlbumCardGroup
albums={groupedAlbums[0].albums}
{showOwner}
showDateRange
showItemCount
onShowContextMenu={showAlbumContextMenu}
/>
</div>
<div class="w-60">
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
</div>
</div>
{#if $albumViewSettings.view === AlbumViewMode.Cover}
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
{#each albumsFiltered as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 200 }}>
<AlbumCard
preload={index < 20}
{album}
onShowContextMenu={(position) => showAlbumContextMenu(position, album)}
/>
</a>
{:else}
{#each groupedAlbums as albumGroup (albumGroup.id)}
<AlbumCardGroup
albums={albumGroup.albums}
group={albumGroup}
{showOwner}
showDateRange
showItemCount
onShowContextMenu={showAlbumContextMenu}
/>
{/each}
</div>
{:else if $albumViewSettings.view === AlbumViewMode.List}
<AlbumsTable
{sortByOptions}
{albumsFiltered}
onChooseAlbumToDelete={(album) => chooseAlbumToDelete(album)}
onAlbumToEdit={(album) => handleEdit(album)}
/>
{/if}
{:else if userSettings.view === AlbumViewMode.List}
<!-- Album Table -->
<AlbumsTable {groupedAlbums} {albumGroupOption} onShowContextMenu={showAlbumContextMenu} />
{/if}
<!-- Empty Message -->
{:else}
<EmptyPlaceholder text="Create an album to organize your photos and videos" onClick={handleCreateAlbum} />
<!-- Empty Message -->
<slot name="empty" />
{/if}
<!-- Context Menu -->
{#if isShowContextMenu}
<section class="fixed left-0 top-0 z-10 flex h-screen w-screen">
<ContextMenu {...contextMenuPosition} on:outclick={closeAlbumContextMenu} on:escape={closeAlbumContextMenu}>
<MenuOption on:click={() => setAlbumToDelete()}>
<span class="flex place-content-center place-items-center gap-2">
<Icon path={mdiDeleteOutline} size="18" />
<p>Delete album</p>
</span>
</MenuOption>
</ContextMenu>
</section>
{/if}
<RightClickContextMenu {...contextMenuPosition} isOpen={showContextMenu} onClose={closeAlbumContextMenu}>
{#if showFullContextMenu}
<MenuOption on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}>
<p class="flex gap-2">
<Icon path={mdiRenameOutline} size="18" />
Edit
</p>
</MenuOption>
<MenuOption on:click={() => openShareModal()}>
<p class="flex gap-2">
<Icon path={mdiShareVariantOutline} size="18" />
Share
</p>
</MenuOption>
{/if}
<MenuOption on:click={() => handleDownloadAlbum()}>
<p class="flex gap-2">
<Icon path={mdiFolderDownloadOutline} size="18" />
Download
</p>
</MenuOption>
{#if showFullContextMenu}
<MenuOption on:click={() => setAlbumToDelete()}>
<p class="flex gap-2">
<Icon path={mdiDeleteOutline} size="18" />
Delete
</p>
</MenuOption>
{/if}
</RightClickContextMenu>
{#if albumToDelete}
<ConfirmDialogue
title="Delete Album"
confirmText="Delete"
onConfirm={deleteSelectedAlbum}
onClose={() => (albumToDelete = null)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want to delete the album <b>{albumToDelete.albumName}</b>?</p>
<p>If this album is shared, other users will not be able to access it anymore.</p>
</svelte:fragment>
</ConfirmDialogue>
{#if allowEdit}
<!-- Edit Modal -->
{#if albumToEdit}
<FullScreenModal id="edit-album-modal" title="Edit album" width="wide" onClose={() => (albumToEdit = null)}>
<EditAlbumForm album={albumToEdit} onEditSuccess={successEditAlbumInfo} onCancel={() => (albumToEdit = null)} />
</FullScreenModal>
{/if}
<!-- Share Modal -->
{#if albumToShare}
{#if showShareByURLModal}
<CreateSharedLinkModal
albumId={albumToShare.id}
onClose={() => closeShareModal()}
on:created={() => albumToShare && handleSharedLinkCreated(albumToShare)}
/>
{:else}
<UserSelectionModal
album={albumToShare}
on:select={({ detail: users }) => handleAddUsers(users)}
on:share={() => (showShareByURLModal = true)}
onClose={() => closeShareModal()}
/>
{/if}
{/if}
<!-- Delete Modal -->
{#if albumToDelete}
<ConfirmDialogue
id="delete-album-dialogue-modal"
title="Delete album"
confirmText="Delete"
onConfirm={deleteSelectedAlbum}
onClose={() => (albumToDelete = null)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want to delete the album <b>{albumToDelete.albumName}</b>?</p>
<p>If this album is shared, other users will not be able to access it anymore.</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}
{/if}

View File

@@ -1,30 +1,31 @@
<script lang="ts">
import { albumViewSettings } from '$lib/stores/preferences.store';
import type { Sort } from '$lib/components/album-page/albums-list.svelte';
import { albumViewSettings, SortOrder } from '$lib/stores/preferences.store';
import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils';
export let option: Sort;
export let option: AlbumSortOptionMetadata;
const handleSort = () => {
if ($albumViewSettings.sortBy === option.title) {
$albumViewSettings.sortDesc = !option.sortDesc;
option.sortDesc = !option.sortDesc;
if ($albumViewSettings.sortBy === option.id) {
$albumViewSettings.sortOrder = $albumViewSettings.sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
} else {
$albumViewSettings.sortBy = option.title;
$albumViewSettings.sortBy = option.id;
$albumViewSettings.sortOrder = option.defaultOrder;
}
};
</script>
<th class="{option.widthClass} text-sm font-medium"
><button
<th class="text-sm font-medium {option.columnStyle}">
<button
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={() => handleSort()}
on:click={handleSort}
>
{#if $albumViewSettings.sortBy === option.title}
{#if option.sortDesc}
{#if $albumViewSettings.sortBy === option.id}
{#if $albumViewSettings.sortOrder === SortOrder.Desc}
&#8595;
{:else}
&#8593;
{/if}
{/if}{option.title}</button
></th
>
{/if}
{option.text}
</button>
</th>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute, dateFormats } from '$lib/constants';
import type { AlbumResponseDto } from '@immich/sdk';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { user } from '$lib/stores/user.store';
import { locale } from '$lib/stores/preferences.store';
import { mdiShareVariantOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
export let album: AlbumResponseDto;
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
undefined;
const showContextMenu = (position: ContextMenuPosition) => {
onShowContextMenu?.(position, album);
};
const dateLocaleString = (dateString: string) => {
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
};
</script>
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y })}
>
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
{album.albumName}
{#if album.shared}
<Icon
path={mdiShareVariantOutline}
size="16"
class="inline ml-1 opacity-70"
title={album.ownerId === $user.id ? 'Shared by you' : `Shared by ${album.owner.name}`}
/>
{/if}
</td>
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
{album.assetCount}
{album.assetCount > 1 ? `items` : `item`}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
{dateLocaleString(album.updatedAt)}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
{dateLocaleString(album.createdAt)}
</td>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.endDate}
{dateLocaleString(album.endDate)}
{:else}
-
{/if}
</td>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.startDate}
{dateLocaleString(album.startDate)}
{:else}
-
{/if}
</td>
</tr>

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { slide } from 'svelte/transition';
import type { AlbumResponseDto } from '@immich/sdk';
import TableHeader from '$lib/components/album-page/albums-table-header.svelte';
import { goto } from '$app/navigation';
import { AlbumGroupBy, albumViewSettings } from '$lib/stores/preferences.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { mdiChevronRight } from '@mdi/js';
import AlbumTableHeader from '$lib/components/album-page/albums-table-header.svelte';
import AlbumTableRow from '$lib/components/album-page/albums-table-row.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import type { Sort } from '$lib/components/album-page/albums-list.svelte';
import { locale } from '$lib/stores/preferences.store';
import { dateFormats } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import {
isAlbumGroupCollapsed,
toggleAlbumGroupCollapsing,
sortOptionsMetadata,
type AlbumGroup,
} from '$lib/utils/album-utils';
export let albumsFiltered: AlbumResponseDto[];
export let sortByOptions: Sort[];
export let onChooseAlbumToDelete: (album: AlbumResponseDto) => void;
export let onAlbumToEdit: (album: AlbumResponseDto) => void;
const dateLocaleString = (dateString: string) => {
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
};
export let groupedAlbums: AlbumGroup[];
export let albumGroupOption: string = AlbumGroupBy.None;
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
undefined;
</script>
<table class="mt-2 w-full text-left">
@@ -25,64 +25,49 @@
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
{#each sortByOptions as option, index (index)}
<TableHeader {option} />
{#each sortOptionsMetadata as option, index (index)}
<AlbumTableHeader {option} />
{/each}
<th class="hidden text-center text-sm font-medium 2xl:block 2xl:w-[12%]">Action</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
{#each albumsFiltered as album (album.id)}
<tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
{#if albumGroupOption === AlbumGroupBy.None}
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
{#each groupedAlbums[0].albums as album (album.id)}
<AlbumTableRow {album} {onShowContextMenu} />
{/each}
</tbody>
{:else}
{#each groupedAlbums as albumGroup (albumGroup.id)}
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4 hover:cursor-pointer"
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
>
<a data-sveltekit-preload-data="hover" class="flex w-full" href="{AppRoute.ALBUMS}/{album.id}">
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]"
>{album.albumName}</td
>
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
{album.assetCount}
{album.assetCount > 1 ? `items` : `item`}
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">({albumGroup.albums.length} {albumGroup.albums.length > 1 ? 'albums' : 'album'})</span>
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
>{dateLocaleString(album.updatedAt)}
</td>
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
>{dateLocaleString(album.createdAt)}</td
>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
{#if album.endDate}
{dateLocaleString(album.endDate)}
{:else}
&#10060;
{/if}</td
>
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]"
>{#if album.startDate}
{dateLocaleString(album.startDate)}
{:else}
&#10060;
{/if}</td
>
</a>
<td class="text-md hidden text-ellipsis text-center 2xl:block xl:w-[15%] 2xl:w-[12%]">
{#if $user.id === album.ownerId}
<button
on:click|stopPropagation={() => onAlbumToEdit(album)}
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<Icon path={mdiPencilOutline} size="16" />
</button>
<button
on:click|stopPropagation={() => onChooseAlbumToDelete(album)}
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<Icon path={mdiTrashCanOutline} size="16" />
</button>
{/if}
</td>
</tr>
</tr>
</tbody>
{#if !isCollapsed}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
transition:slide={{ duration: 300 }}
>
{#each albumGroup.albums as album (album.id)}
<AlbumTableRow {album} {onShowContextMenu} />
{/each}
</tbody>
{/if}
{/each}
</tbody>
{/if}
</table>

View File

@@ -13,10 +13,10 @@
import UserAvatar from '../shared-components/user-avatar.svelte';
export let album: AlbumResponseDto;
export let onClose: () => void;
const dispatch = createEventDispatcher<{
remove: string;
close: void;
}>();
let currentUser: UserResponseDto;
@@ -66,7 +66,7 @@
</script>
{#if !selectedRemoveUser}
<BaseModal id="share-info-modal" title="Options" on:close>
<BaseModal id="share-info-modal" title="Options" {onClose}>
<section class="immich-scrollbar max-h-[400px] overflow-y-auto pb-4">
<div class="flex w-full place-items-center justify-between gap-4 p-5">
<div class="flex place-items-center gap-4">
@@ -121,7 +121,8 @@
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
<ConfirmDialogue
title="Leave Album?"
id="leave-album-modal"
title="Leave album?"
prompt="Are you sure you want to leave {album.albumName}?"
confirmText="Leave"
onConfirm={handleRemoveUser}
@@ -131,8 +132,9 @@
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
<ConfirmDialogue
title="Remove User?"
prompt="Are you sure you want to remove {selectedRemoveUser.name}"
id="remove-user-modal"
title="Remove user?"
prompt="Are you sure you want to remove {selectedRemoveUser.name}?"
confirmText="Remove"
onConfirm={handleRemoveUser}
onClose={() => (selectedRemoveUser = null)}

View File

@@ -16,13 +16,13 @@
import UserAvatar from '../shared-components/user-avatar.svelte';
export let album: AlbumResponseDto;
export let onClose: () => void;
let users: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher<{
select: UserResponseDto[];
share: void;
close: void;
}>();
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => {
@@ -54,7 +54,7 @@
};
</script>
<BaseModal id="user-selection-modal" title="Invite to album" showLogo on:close>
<BaseModal id="user-selection-modal" title="Invite to album" showLogo {onClose}>
{#if selectedUsers.length > 0}
<div class="mb-2 flex flex-wrap place-items-center gap-4 overflow-x-auto px-5 py-2 sticky">
<p class="font-medium">To</p>

View File

@@ -29,6 +29,7 @@
runAssetJobs,
updateAsset,
updateAssets,
updateAlbumInfo,
type ActivityResponseDto,
type AlbumResponseDto,
type AssetResponseDto,
@@ -496,6 +497,27 @@
handleError(error, `Unable to unstack`);
}
};
const handleUpdateThumbnail = async () => {
if (!album) {
return;
}
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: asset.id,
},
});
notificationController.show({
type: NotificationType.Info,
message: 'Album cover updated',
timeout: 1500,
});
} catch (error) {
handleError(error, 'Unable to update album cover');
}
};
</script>
<svelte:window
@@ -524,6 +546,7 @@
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
{album}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
showZoomButton={asset.type === AssetTypeEnum.Image}
@@ -544,6 +567,7 @@
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:setAsAlbumCover={handleUpdateThumbnail}
on:runJob={({ detail: job }) => handleRunJob(job)}
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
on:unstack={handleUnstack}
@@ -732,7 +756,7 @@
shared={addToSharedAlbum}
on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
on:album={({ detail }) => handleAddToAlbum(detail)}
on:close={() => (isShowAlbumPicker = false)}
onClose={() => (isShowAlbumPicker = false)}
/>
{/if}
@@ -746,11 +770,11 @@
{/if}
{#if isShowProfileImageCrop}
<ProfileImageCropper {asset} on:close={() => (isShowProfileImageCrop = false)} />
<ProfileImageCropper {asset} onClose={() => (isShowProfileImageCrop = false)} />
{/if}
{#if isShowShareModal}
<CreateSharedLinkModal assetIds={[asset.id]} on:close={() => (isShowShareModal = false)} />
<CreateSharedLinkModal assetIds={[asset.id]} onClose={() => (isShowShareModal = false)} />
{/if}
</section>
</FocusTrap>

View File

@@ -648,7 +648,7 @@
<p class="pb-4 text-sm">APPEARS IN</p>
{#each albums as album}
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
<div class="flex gap-4 py-2 hover:cursor-pointer">
<div class="flex gap-4 py-2 hover:cursor-pointer items-center">
<div>
<img
alt={album.albumName}
@@ -661,11 +661,13 @@
<div class="mb-auto mt-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex gap-2 text-sm">
<p>{album.assetCount} items</p>
{#if album.shared}
<p>· Shared</p>
{/if}
<div class="flex flex-col gap-0 text-sm">
<div>
<span>{album.assetCount} items</span>
{#if album.shared}
<span> • Shared</span>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -11,8 +11,6 @@
viewer = new Viewer({
container,
panorama,
touchmoveTwoFingers: true,
mousewheelCtrlKey: false,
navbar: false,
});
});

View File

@@ -71,6 +71,7 @@
class:rounded-xl={curve}
muted
autoplay
loop
src={url}
on:play={() => {
loading = false;

View File

@@ -6,6 +6,7 @@
export type RenderedOption = {
title: string;
icon?: string;
disabled?: boolean;
};
</script>
@@ -33,6 +34,7 @@
export let showMenu = false;
export let controlable = false;
export let hideTextOnSmallScreen = true;
export let title: string | undefined = undefined;
export let render: (item: T) => string | RenderedOption = String;
@@ -61,6 +63,7 @@
return {
title: renderedOption.title,
icon: renderedOption.icon,
disabled: renderedOption.disabled,
};
}
}
@@ -69,9 +72,9 @@
$: renderedSelectedOption = renderOption(selectedOption);
</script>
<div id="dropdown-button" use:clickOutside on:outclick={handleClickOutside} on:escape={handleClickOutside}>
<div use:clickOutside on:outclick={handleClickOutside} on:escape={handleClickOutside}>
<!-- BUTTON TITLE -->
<LinkButton on:click={() => (showMenu = true)} fullwidth>
<LinkButton on:click={() => (showMenu = true)} fullwidth {title}>
<div class="flex place-items-center gap-2 text-sm">
{#if renderedSelectedOption?.icon}
<Icon path={renderedSelectedOption.icon} size="18" />
@@ -84,13 +87,15 @@
{#if showMenu}
<div
transition:fly={{ y: -30, duration: 250 }}
class="text-md fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
class="text-sm font-medium fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
>
{#each options as option (option)}
{@const renderedOption = renderOption(option)}
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
<button
class="grid grid-cols-[20px,1fr] place-items-center p-2 transition-all hover:bg-gray-300 dark:hover:bg-gray-800"
on:click={() => handleSelectOption(option)}
class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={renderedOption.disabled}
on:click={() => !renderedOption.disabled && handleSelectOption(option)}
>
{#if isEqual(selectedOption, option)}
<div class="text-immich-primary dark:text-immich-dark-primary">

View File

@@ -1,6 +1,14 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
/**
* Unique identifier for the checkbox element, used to associate labels with the input element.
*/
export let id: string;
/**
* Optional aria-describedby attribute to associate the checkbox with a description.
*/
export let ariaDescribedBy: string | undefined = undefined;
export let checked = false;
export let disabled = false;
@@ -10,17 +18,23 @@
<label class="relative inline-block h-[10px] w-[36px] flex-none">
<input
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
{id}
class="disabled::cursor-not-allowed h-0 w-0 opacity-0 peer"
type="checkbox"
bind:checked
on:click={onToggle}
{disabled}
aria-describedby={ariaDescribedBy}
/>
{#if disabled}
<span class="slider slider-disabled cursor-not-allowed" />
<span
class="slider slider-disabled cursor-not-allowed border border-transparent before:border before:border-transparent"
/>
{:else}
<span class="slider slider-enabled cursor-pointer" />
<span
class="slider slider-enabled cursor-pointer border-2 border-transparent before:border-2 before:border-transparent peer-focus-visible:outline before:peer-focus-visible:outline peer-focus-visible:dark:outline-gray-200 before:peer-focus-visible:dark:outline-gray-200 peer-focus-visible:outline-gray-600 before:peer-focus-visible:outline-gray-600 peer-focus-visible:dark:border-black before:peer-focus-visible:dark:border-black peer-focus-visible:border-white before:peer-focus-visible:border-white"
/>
{/if}
</label>
@@ -32,8 +46,8 @@
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
-webkit-transition: transform 0.4s;
transition: transform 0.4s;
border-radius: 34px;
}
@@ -46,12 +60,12 @@
content: '';
height: 20px;
width: 20px;
left: 0px;
left: -2px;
right: 0px;
bottom: -4px;
bottom: -6px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
-webkit-transition: transform 0.4s;
transition: transform 0.4s;
border-radius: 50%;
}

View File

@@ -161,6 +161,7 @@
{#if isShowConfirmation}
<ConfirmDialogue
id="merge-people-modal"
title="Merge people"
confirmText="Merge"
onConfirm={handleMerge}

View File

@@ -3,7 +3,7 @@
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
import { mdiArrowLeft, mdiClose, mdiMerge } from '@mdi/js';
import { mdiArrowLeft, mdiMerge } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
@@ -30,95 +30,80 @@
};
</script>
<FullScreenModal onClose={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class="w-[250px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[375px]"
>
<div class="relative flex items-center justify-between">
<h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
Merge People - {title}
</h1>
<div class="p-2">
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
<FullScreenModal id="merge-people-modal" title="Merge people - {title}" onClose={() => dispatch('close')}>
<div class="flex items-center justify-center py-4 md:h-36 md:py-4">
{#if !choosePersonToMerge}
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(personMerge1.id)}
altText={personMerge1.name}
widthStyle="100%"
/>
</div>
<div class="mx-0.5 flex md:mx-2">
<CircleIconButton
title="Swap merge direction"
icon={mdiMerge}
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
/>
</div>
<button
disabled={potentialMergePeople.length === 0}
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
on:click={() => {
if (potentialMergePeople.length > 0) {
choosePersonToMerge = !choosePersonToMerge;
}
}}
>
<ImageThumbnail
border={potentialMergePeople.length > 0}
circle
shadow
url={getPeopleThumbnailUrl(personMerge2.id)}
altText={personMerge2.name}
widthStyle="100%"
/>
</button>
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
on:click={() => changePersonToMerge(person)}
/>
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">
{#if !choosePersonToMerge}
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(personMerge1.id)}
altText={personMerge1.name}
widthStyle="100%"
/>
</div>
<div class="mx-0.5 flex md:mx-2">
<CircleIconButton
title="Swap merge direction"
icon={mdiMerge}
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
/>
</div>
<button
disabled={potentialMergePeople.length === 0}
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
on:click={() => {
if (potentialMergePeople.length > 0) {
choosePersonToMerge = !choosePersonToMerge;
}
}}
>
<ImageThumbnail
border={potentialMergePeople.length > 0}
circle
shadow
url={getPeopleThumbnailUrl(personMerge2.id)}
altText={personMerge2.name}
widthStyle="100%"
/>
</button>
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
on:click={() => changePersonToMerge(person)}
/>
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="flex px-4 md:px-8 md:pt-4">
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
</div>
<div class="flex px-4 pt-2 md:px-8">
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
</div>
<div class="mt-8 flex w-full gap-4 px-4 pb-4">
<Button fullwidth color="gray" on:click={() => dispatch('reject')}>No</Button>
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
</div>
</div>
<div class="flex px-4 md:pt-4">
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
</div>
<div class="flex px-4 pt-2">
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
</div>
<div class="mt-8 flex w-full gap-4 pb-4">
<Button fullwidth color="gray" on:click={() => dispatch('reject')}>No</Button>
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
</div>
</FullScreenModal>

View File

@@ -3,7 +3,6 @@
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiCake } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import DateInput from '../elements/date-input.svelte';
export let birthDate: string;
@@ -21,36 +20,27 @@
};
</script>
<FullScreenModal onClose={handleCancel}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiCake} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Set date of birth</h1>
<p class="text-sm dark:text-immich-dark-fg">
Date of birth is used to calculate the age of this person at the time of a photo.
</p>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<DateInput
class="immich-form-input"
id="birthDate"
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
<Button type="submit" fullwidth>Set</Button>
</div>
</form>
<FullScreenModal id="set-birthday-modal" title="Set date of birth" icon={mdiCake} onClose={handleCancel}>
<div class="text-immich-primary dark:text-immich-dark-primary">
<p class="text-sm dark:text-immich-dark-fg">
Date of birth is used to calculate the age of this person at the time of a photo.
</p>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="my-4 flex flex-col gap-2">
<DateInput
class="immich-form-input"
id="birthDate"
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
</div>
<div class="mt-8 flex w-full gap-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
<Button type="submit" fullwidth>Set</Button>
</div>
</form>
</FullScreenModal>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import type { ApiKeyResponseDto } from '@immich/sdk';
import { mdiKeyVariant } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
@@ -8,7 +7,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
export let apiKey: Partial<ApiKeyResponseDto>;
export let title = 'API Key';
export let title: string;
export let cancelText = 'Cancel';
export let submitText = 'Save';
@@ -29,29 +28,16 @@
};
</script>
<FullScreenModal onClose={handleCancel}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiKeyVariant} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h1>
<FullScreenModal id="api-key-modal" {title} icon={mdiKeyVariant} onClose={handleCancel}>
<form on:submit|preventDefault={handleSubmit} autocomplete="off">
<div class="mb-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
</div>
<form on:submit|preventDefault={handleSubmit} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={handleCancel}>{cancelText}</Button>
<Button type="submit" fullwidth>{submitText}</Button>
</div>
</form>
</div>
<div class="mt-8 flex w-full gap-4">
<Button color="gray" fullwidth on:click={handleCancel}>{cancelText}</Button>
<Button type="submit" fullwidth>{submitText}</Button>
</div>
</form>
</FullScreenModal>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { copyToClipboard } from '$lib/utils';
import { mdiKeyVariant } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
@@ -20,31 +19,22 @@
});
</script>
<FullScreenModal>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiKeyVariant} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">API Key</h1>
<FullScreenModal id="api-key-secret-modal" title="API key" icon={mdiKeyVariant} onClose={() => handleDone()}>
<div class="text-immich-primary dark:text-immich-dark-primary">
<p class="text-sm dark:text-immich-dark-fg">
This value will only be shown once. Please be sure to copy it before closing the window.
</p>
</div>
<p class="text-sm dark:text-immich-dark-fg">
This value will only be shown once. Please be sure to copy it before closing the window.
</p>
</div>
<div class="my-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="secret">API Key</label> -->
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
</div>
<div class="m-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="secret">API Key</label> -->
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
{#if canCopyImagesToClipboard}
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</div>
<div class="mt-8 flex w-full gap-4">
{#if canCopyImagesToClipboard}
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</div>
</FullScreenModal>

View File

@@ -5,7 +5,6 @@
import { createUser } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import PasswordField from '../shared-components/password-field.svelte';
import Slider from '../elements/slider.svelte';
@@ -69,62 +68,53 @@
}
</script>
<div
class="max-h-screen w-[500px] max-w-[95vw] overflow-y-auto immich-scrollbar rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="flex flex-col place-content-center place-items-center gap-4 px-4">
<ImmichLogo noText class="text-center" height="75" width="75" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Create new user</h1>
<form on:submit|preventDefault={registerUser} autocomplete="off">
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
</div>
<form on:submit|preventDefault={registerUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<PasswordField id="password" bind:password autocomplete="new-password" />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<PasswordField id="password" bind:password autocomplete="new-password" />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
</div>
<div class="my-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="require-password-change">
Require user to change password on first login
</label>
<Slider id="require-password-change" bind:checked={shouldChangePassword} />
</div>
<div class="m-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="Require user to change password on first login">
Require user to change password on first login
</label>
<Slider bind:checked={shouldChangePassword} />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" bind:value={name} type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" bind:value={name} type="text" required />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
Quota Size (GiB)
{#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
{/if}
</label>
<input class="immich-form-input" id="quotaSize" type="number" min="0" bind:value={quotaSize} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
Quota Size (GiB)
{#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
{/if}
</label>
<input class="immich-form-input" id="quotaSize" type="number" min="0" bind:value={quotaSize} />
</div>
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}
{#if success}
<p class="ml-4 text-sm text-immich-primary">{success}</p>
{/if}
<div class="flex w-full gap-4 p-4">
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
</div>
</form>
</div>
{#if success}
<p class="text-sm text-immich-primary">{success}</p>
{/if}
<div class="flex w-full gap-4 pt-4">
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
</div>
</form>

View File

@@ -1,59 +1,62 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { mdiImageAlbum } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
import { handleError } from '$lib/utils/handle-error';
import Button from '$lib/components/elements/buttons/button.svelte';
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
export let album: AlbumResponseDto;
export let onEditSuccess: ((album: AlbumResponseDto) => unknown) | undefined = undefined;
export let onCancel: (() => unknown) | undefined = undefined;
const dispatch = createEventDispatcher<{
editSuccess: void;
cancel: void;
}>();
let albumName = album.albumName;
let description = album.description;
const editUser = async () => {
let isSubmitting = false;
const handleUpdateAlbumInfo = async () => {
isSubmitting = true;
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumName: album.albumName,
description: album.description,
albumName,
description,
},
});
dispatch('editSuccess');
album.albumName = albumName;
album.description = description;
onEditSuccess?.(album);
} catch (error) {
handleError(error, 'Unable to update user');
handleError(error, 'Unable to update album info');
} finally {
isSubmitting = false;
}
};
</script>
<div
class="max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiImageAlbum} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit album</h1>
<form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off">
<div class="flex items-center">
<div class="hidden sm:flex">
<AlbumCover {album} css="h-[200px] w-[200px] m-4 shadow-lg" />
</div>
<div class="flex-grow">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" type="text" bind:value={albumName} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="description">Description</label>
<textarea class="immich-form-input" id="description" bind:value={description} />
</div>
</div>
</div>
<form on:submit|preventDefault={editUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" type="text" bind:value={album.albumName} />
<div class="flex justify-center">
<div class="mt-8 flex w-full sm:w-2/3 gap-4">
<Button color="gray" fullwidth on:click={() => onCancel?.()}>Cancel</Button>
<Button type="submit" fullwidth disabled={isSubmitting}>OK</Button>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="description">Description</label>
<textarea class="immich-form-input" id="description" bind:value={album.description} />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
</div>
</div>
</form>

View File

@@ -1,16 +1,12 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { AppRoute } from '$lib/constants';
import { serverInfo } from '$lib/stores/server-info.store';
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
import { handleError } from '$lib/utils/handle-error';
import { updateUser, type UserResponseDto } from '@immich/sdk';
import { mdiAccountEditOutline, mdiClose } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
export let user: UserResponseDto;
export let canResetPassword = true;
@@ -91,82 +87,64 @@
}
</script>
<FocusTrap>
<div
class="relative max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="absolute top-0 right-0 px-2 py-2 h-fit">
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
</div>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiAccountEditOutline} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit user</h1>
</div>
<form on:submit|preventDefault={editUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
>Quota Size (GiB) {#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
{/if}</label
>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
<p>Note: Enter 0 for unlimited quota</p>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
Storage Migration Job</a
>
</p>
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}
{#if success}
<p class="ml-4 text-sm text-immich-primary">{success}</p>
{/if}
<div class="mt-8 flex w-full gap-4 px-4">
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
>Reset password</Button
>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
<form on:submit|preventDefault={editUser} autocomplete="off">
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
</div>
</FocusTrap>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
>Quota Size (GiB) {#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
{/if}</label
>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
<p>Note: Enter 0 for unlimited quota</p>
</div>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> Storage Migration Job</a>
</p>
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}
{#if success}
<p class="ml-4 text-sm text-immich-primary">{success}</p>
{/if}
<div class="mt-8 flex w-full gap-4">
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
>Reset password</Button
>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
{#if isShowResetPasswordConfirmation}
<ConfirmDialogue
title="Reset Password"
id="reset-password-modal"
title="Reset password"
confirmText="Reset"
onConfirm={resetPassword}
onClose={() => (isShowResetPasswordConfirmation = false)}

View File

@@ -2,7 +2,6 @@
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiFolderRemove } from '@mdi/js';
import { onMount } from 'svelte';
@@ -29,48 +28,42 @@
const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern });
</script>
<FullScreenModal onClose={handleCancel}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiFolderRemove} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Add Exclusion pattern</h1>
<FullScreenModal
id="add-exclusion-pattern-modal"
title="Add exclusion pattern"
icon={mdiFolderRemove}
onClose={handleCancel}
>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="py-5 text-sm">
Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
folders that contain files you don't want to import, such as RAW files.
<br /><br />
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
<input
class="immich-form-input"
id="exclusionPattern"
name="exclusionPattern"
type="text"
bind:value={exclusionPattern}
/>
</div>
<div class="mt-8 flex w-full gap-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
{#if isEditing}
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
{/if}
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">
Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
folders that contain files you don't want to import, such as RAW files.
<br /><br />
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
</p>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
<input
class="immich-form-input"
id="exclusionPattern"
name="exclusionPattern"
type="text"
bind:value={exclusionPattern}
/>
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
{#if isEditing}
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
{/if}
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
</div>
<div class="mt-8 flex w-full gap-4 px-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">This exclusion pattern already exists.</p>
{/if}
</div>
</form>
</div>
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">This exclusion pattern already exists.</p>
{/if}
</div>
</form>
</FullScreenModal>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiFolderSync } from '@mdi/js';
@@ -31,45 +30,30 @@
const handleSubmit = () => dispatch('submit', { importPath });
</script>
<FullScreenModal onClose={handleCancel}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiFolderSync} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h1>
<FullScreenModal id="library-import-path-modal" {title} icon={mdiFolderSync} onClose={handleCancel}>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="py-5 text-sm">
Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">Path</label>
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">
Specify a folder to import. This folder, including subfolders, will be scanned for images and videos. Note that
you are only allowed to import paths inside of your account's external path, configured in the administrative
settings.
</p>
<div class="mt-8 flex w-full gap-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
{#if isEditing}
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
{/if}
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">Path</label>
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
</div>
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
{#if isEditing}
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
{/if}
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
</div>
<div class="mt-8 flex w-full gap-4 px-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">This import path already exists.</p>
{/if}
</div>
</form>
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">This import path already exists.</p>
{/if}
</div>
</form>
</FullScreenModal>

View File

@@ -152,7 +152,7 @@
{#if addImportPath}
<LibraryImportPathForm
title="Add Import Path"
title="Add import path"
submitText="Add"
bind:importPath={importPathToAdd}
{importPaths}
@@ -166,7 +166,7 @@
{#if editImportPath != undefined}
<LibraryImportPathForm
title="Edit Import Path"
title="Edit import path"
submitText="Save"
isEditing={true}
bind:importPath={editedImportPath}

View File

@@ -169,7 +169,7 @@
size="sm"
on:click={() => {
addExclusionPattern = true;
}}>Add Exclusion Pattern</Button
}}>Add exclusion pattern</Button
></td
></tr
>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiFolderSync } from '@mdi/js';
@@ -28,27 +27,21 @@
const handleSubmit = () => dispatch('submit', { ownerId });
</script>
<FullScreenModal onClose={handleCancel}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiFolderSync} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Select library owner</h1>
<FullScreenModal
id="select-library-owner-modal"
title="Select library owner"
icon={mdiFolderSync}
onClose={handleCancel}
>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
<div class="mt-8 flex w-full gap-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
<Button type="submit" fullwidth>Create</Button>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
<Button type="submit" fullwidth>Create</Button>
</div>
</form>
</div>
</form>
</FullScreenModal>

View File

@@ -21,98 +21,92 @@
const handleClose = () => dispatch('close');
</script>
<FullScreenModal onClose={handleClose}>
<div
class="flex w-96 max-w-lg flex-col gap-8 rounded-3xl border bg-white p-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray"
<FullScreenModal id="map-settings-modal" title="Map settings" onClose={handleClose}>
<form
on:submit|preventDefault={() => dispatch('save', settings)}
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="self-center text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Map Settings</h1>
<form
on:submit|preventDefault={() => dispatch('save', settings)}
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch title="Only favorites" bind:checked={settings.onlyFavorites} />
<SettingSwitch title="Include archived" bind:checked={settings.includeArchived} />
<SettingSwitch title="Include shared with me" bind:checked={settings.withPartners} />
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<LinkButton
on:click={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
Remove custom date range
</LinkButton>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label="Date range"
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: 'All',
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: 'Past 24 hours',
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: 'Past 7 days',
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: 'Past 30 days',
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: 'Past year',
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: 'Past 3 years',
},
]}
<SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} />
<SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} />
<SettingSwitch id="include-shared-with-me" title="Include shared with me" bind:checked={settings.withPartners} />
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
<div class="text-xs">
<LinkButton
on:click={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
Use custom date range instead
</LinkButton>
</div>
</div>
{/if}
<div class="mt-4 flex w-full gap-4">
<Button color="gray" size="sm" fullwidth on:click={handleClose}>Cancel</Button>
<Button type="submit" size="sm" fullwidth>Save</Button>
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<LinkButton
on:click={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
Remove custom date range
</LinkButton>
</div>
</div>
</form>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label="Date range"
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: 'All',
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: 'Past 24 hours',
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: 'Past 7 days',
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: 'Past 30 days',
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: 'Past year',
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: 'Past 3 years',
},
]}
/>
<div class="text-xs">
<LinkButton
on:click={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
Use custom date range instead
</LinkButton>
</div>
</div>
{/if}
<div class="mt-4 flex w-full gap-4">
<Button color="gray" size="sm" fullwidth on:click={handleClose}>Cancel</Button>
<Button type="submit" size="sm" fullwidth>Save</Button>
</div>
</form>
</FullScreenModal>

View File

@@ -46,6 +46,6 @@
{shared}
on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
on:album={({ detail }) => handleAddToAlbum(detail)}
on:close={handleHideAlbumPicker}
onClose={handleHideAlbumPicker}
/>
{/if}

View File

@@ -11,5 +11,5 @@
<CircleIconButton title="Share" icon={mdiShareVariantOutline} on:click={() => (showModal = true)} />
{#if showModal}
<CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} on:close={() => (showModal = false)} />
<CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} onClose={() => (showModal = false)} />
{/if}

View File

@@ -57,6 +57,7 @@
{#if isShowConfirmation}
<ConfirmDialogue
id="remove-from-album-modal"
title="Remove from {album.albumName}"
confirmText="Remove"
onConfirm={removeFromAlbum}

View File

@@ -50,7 +50,8 @@
{#if removing}
<ConfirmDialogue
title="Remove Assets?"
id="remove-assets-modal"
title="Remove assets?"
prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?"
confirmText="Remove"
onConfirm={() => handleRemove()}

View File

@@ -87,10 +87,12 @@
};
const onStackAssets = async () => {
await stackAssets(Array.from($selectedAssets), (ids) => {
assetStore.removeAssets(ids);
dispatch('escape');
});
if ($selectedAssets.size > 1) {
await stackAssets(Array.from($selectedAssets), (ids) => {
assetStore.removeAssets(ids);
dispatch('escape');
});
}
};
$: shortcutList = (() => {

View File

@@ -25,7 +25,8 @@
</script>
<ConfirmDialogue
title="Permanently Delete Asset{size > 1 ? 's' : ''}"
id="permanently-delete-asset-modal"
title="Permanently delete asset{size > 1 ? 's' : ''}"
confirmText="Delete"
onConfirm={handleConfirm}
onClose={() => dispatch('cancel')}

View File

@@ -16,10 +16,10 @@
const dispatch = createEventDispatcher<{
newAlbum: string;
album: AlbumResponseDto;
close: void;
}>();
export let shared: boolean;
export let onClose: () => void;
onMount(async () => {
albums = await getAllAlbums({ shared: shared || undefined });
@@ -52,7 +52,7 @@
};
</script>
<BaseModal id="album-selection-modal" title={getTitle()} on:close>
<BaseModal id="album-selection-modal" title={getTitle()} {onClose}>
<div class="mb-2 flex max-h-[400px] flex-col">
{#if loading}
{#each { length: 3 } as _}

View File

@@ -1,23 +1,18 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiClose } from '@mdi/js';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
const dispatch = createEventDispatcher<{
close: void;
}>();
/**
* Unique identifier for the modal.
*/
export let id: string;
export let title: string;
export let onClose: () => void;
export let zIndex = 9999;
/**
* If true, the logo will be displayed next to the modal title.
@@ -28,6 +23,8 @@
*/
export let icon: string | undefined = undefined;
$: titleId = `${id}-title`;
onMount(() => {
if (browser) {
const scrollTop = document.documentElement.scrollTop;
@@ -51,33 +48,21 @@
<FocusTrap>
<div
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-labelledby={titleId}
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed left-0 top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50"
>
<div
use:clickOutside={{
onOutclick: () => dispatch('close'),
onEscape: () => dispatch('close'),
onOutclick: onClose,
onEscape: onClose,
}}
class="max-h-[800px] min-h-[200px] w-[450px] overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
class="min-h-[200px] w-[450px] overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar scroll-pb-20"
style="max-height: min(95vh, 800px);"
tabindex="-1"
>
<div class="flex place-items-center justify-between px-5 py-3">
<div class="flex gap-2 place-items-center">
{#if showLogo}
<ImmichLogo noText={true} width={32} />
{:else if icon}
<Icon path={icon} size={32} ariaHidden={true} class="text-immich-primary dark:text-immich-dark-primary" />
{/if}
<h1 id={`${id}-title`}>
{title}
</h1>
</div>
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} size={'20'} title="Close" />
</div>
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
<div>
<slot />

View File

@@ -63,16 +63,16 @@
<div role="presentation" on:keydown={handleKeydown}>
<ConfirmDialogue
id="edit-date-time-modal"
confirmColor="primary"
cancelColor="secondary"
title="Edit date & time"
title="Edit date and time"
prompt="Please select a new date:"
disabled={!date.isValid}
onConfirm={handleConfirm}
onClose={handleCancel}
>
<div class="flex flex-col text-md px-4 text-center gap-2" slot="prompt">
<div class="mt-2" />
<div class="flex flex-col">
<label for="datetime">Date and Time</label>
<DateInput

View File

@@ -12,7 +12,6 @@
import SearchBar from '../elements/search-bar.svelte';
import { listNavigation } from '$lib/utils/list-navigation';
export const title = 'Change Location';
export let asset: AssetResponseDto | undefined = undefined;
interface Point {
@@ -95,10 +94,11 @@
</script>
<ConfirmDialogue
id="change-location-modal"
confirmColor="primary"
cancelColor="secondary"
title="Change Location"
width={800}
title="Change location"
width="wide"
onConfirm={handleConfirm}
onClose={handleCancel}
>

View File

@@ -3,6 +3,7 @@
import Button from '../elements/buttons/button.svelte';
import type { Color } from '$lib/components/elements/buttons/button.svelte';
export let id: string;
export let title = 'Confirm';
export let prompt = 'Are you sure you want to do this?';
export let confirmText = 'Confirm';
@@ -11,7 +12,7 @@
export let cancelColor: Color = 'primary';
export let hideCancelButton = false;
export let disabled = false;
export let width = 500;
export let width: 'wide' | 'narrow' = 'narrow';
export let onClose: () => void;
export let onConfirm: () => void;
@@ -23,35 +24,21 @@
};
</script>
<FullScreenModal {onClose}>
<div
class="max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
style="width: {width}px"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="pb-2 text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h1>
</div>
<div>
<div class="text-md px-4 py-5 text-center">
<slot name="prompt">
<p>{prompt}</p>
</slot>
</div>
<FullScreenModal {title} {id} {onClose} {width}>
<div class="text-md py-5 text-center">
<slot name="prompt">
<p>{prompt}</p>
</slot>
</div>
<div class="mt-4 flex w-full gap-4 px-4">
{#if !hideCancelButton}
<Button color={cancelColor} fullwidth on:click={onClose}>
{cancelText}
</Button>
{/if}
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={disabled || isConfirmButtonDisabled}>
{confirmText}
</Button>
</div>
</div>
<div class="mt-4 flex flex-col sm:flex-row w-full gap-4">
{#if !hideCancelButton}
<Button color={cancelColor} fullwidth on:click={onClose}>
{cancelText}
</Button>
{/if}
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={disabled || isConfirmButtonDisabled}>
{confirmText}
</Button>
</div>
</FullScreenModal>

View File

@@ -1,30 +1,40 @@
<script lang="ts">
import { clickOutside } from '$lib/utils/click-outside';
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/utils/click-outside';
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
let menuElement: HTMLDivElement;
export let menuElement: HTMLDivElement | undefined = undefined;
let left: number;
let top: number;
$: if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const directionWidth = direction === 'left' ? rect.width : 0;
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number;
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
top = Math.min(window.innerHeight - rect.height, y);
$: {
if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const directionWidth = direction === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
top = Math.min(window.innerHeight - menuHeight, y);
}
}
</script>
<div
transition:slide={{ duration: 200, easing: quintOut }}
bind:this={menuElement}
bind:clientHeight={height}
transition:slide={{ duration: 250, easing: quintOut }}
class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
style="left: {left}px; top: {top}px;"
style:top="{top}px"
style:left="{left}px"
role="menu"
use:clickOutside
on:outclick

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { tick } from 'svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
export let isOpen = false;
export let onClose: (() => unknown) | undefined;
let uniqueKey = {};
let contextMenuElement: HTMLDivElement;
const reopenContextMenu = async (event: MouseEvent) => {
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
view: window,
clientX: event.x,
clientY: event.y,
});
const elements = document.elementsFromPoint(event.x, event.y);
if (elements.includes(contextMenuElement)) {
// User right-clicked on the context menu itself, we keep the context
// menu as is
return;
}
closeContextMenu();
await tick();
uniqueKey = {};
// Event will bubble through the DOM tree
const sectionIndex = elements.indexOf(event.target as Element);
elements.at(sectionIndex + 1)?.dispatchEvent(contextMenuEvent);
};
const closeContextMenu = () => {
onClose?.();
};
</script>
{#key uniqueKey}
{#if isOpen}
<section
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
on:contextmenu|preventDefault={reopenContextMenu}
role="presentation"
>
<ContextMenu
{x}
{y}
{direction}
on:outclick={closeContextMenu}
on:escape={closeContextMenu}
bind:menuElement={contextMenuElement}
>
<slot />
</ContextMenu>
</section>
{/if}
{/key}

View File

@@ -15,6 +15,7 @@
import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
import SettingSwitch from '../settings/setting-switch.svelte';
export let onClose: () => void;
export let albumId: string | undefined = undefined;
export let assetIds: string[] = [];
export let editingLink: SharedLinkResponseDto | undefined = undefined;
@@ -30,13 +31,12 @@
let enablePassword = false;
const dispatch = createEventDispatcher<{
close: void;
escape: void;
created: void;
}>();
const expiredDateOption: ImmichDropDownOption = {
default: 'Never',
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days'],
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days', '3 months', '1 year'],
};
$: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual;
@@ -78,6 +78,7 @@
},
});
sharedLink = makeSharedLinkUrl($serverConfig.externalDomain, data.key);
dispatch('created');
} catch (error) {
handleError(error, 'Failed to create shared link');
}
@@ -103,6 +104,12 @@
case '30 days': {
return 30 * 24 * 60 * 60 * 1000;
}
case '3 months': {
return 30 * 24 * 60 * 60 * 3 * 1000;
}
case '1 year': {
return 30 * 24 * 60 * 60 * 12 * 1000;
}
default: {
return 0;
}
@@ -138,7 +145,7 @@
message: 'Edited',
});
dispatch('close');
onClose();
} catch (error) {
handleError(error, 'Failed to edit shared link');
}
@@ -152,7 +159,7 @@
};
</script>
<BaseModal id="create-shared-link-modal" title={getTitle()} icon={mdiLink} on:close>
<BaseModal id="create-shared-link-modal" title={getTitle()} icon={mdiLink} {onClose}>
<section class="mx-6 mb-6">
{#if shareType === SharedLinkType.Album}
{#if !editingLink}
@@ -197,25 +204,33 @@
</div>
<div class="my-3">
<SettingSwitch bind:checked={enablePassword} title={'Require password'} />
<SettingSwitch id="require-password" bind:checked={enablePassword} title={'Require password'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
<SettingSwitch id="show-metadata" bind:checked={showMetadata} title={'Show metadata'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowDownload} title={'Allow public user to download'} />
<SettingSwitch
id="allow-public-download"
bind:checked={allowDownload}
title={'Allow public user to download'}
/>
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowUpload} title={'Allow public user to upload'} />
<SettingSwitch id="allow-public-upload" bind:checked={allowUpload} title={'Allow public user to upload'} />
</div>
<div class="text-sm">
{#if editingLink}
<p class="immich-form-label my-2">
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={'Change expiration time'} />
<SettingSwitch
id="change-expiration-time"
bind:checked={shouldChangeExpirationTime}
title={'Change expiration time'}
/>
</p>
{:else}
<p class="immich-form-label my-2">Expire after</p>

View File

@@ -1,28 +1,54 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte';
export let dropHandler: (event: DragEvent) => void;
import { page } from '$app/stores';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';
$: albumId = ($page.route?.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
$: isShare = $page.route?.id === '/(user)/share/[key]' || undefined;
let dragStartTarget: EventTarget | null = null;
const handleDragEnter = (e: DragEvent) => {
const onDragEnter = (e: DragEvent) => {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
dragStartTarget = e.target;
}
};
</script>
<svelte:body
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
on:dragleave|stopPropagation|preventDefault={(e) => {
const onDragLeave = (e: DragEvent) => {
if (dragStartTarget === e.target) {
dragStartTarget = null;
}
}}
on:drop|stopPropagation|preventDefault={(e) => {
};
const onDrop = async (e: DragEvent) => {
dragStartTarget = null;
dropHandler(e);
}}
await handleFiles(e.dataTransfer?.files);
};
const onPaste = ({ clipboardData }: ClipboardEvent) => handleFiles(clipboardData?.files);
const handleFiles = async (files?: FileList) => {
if (!files) {
return;
}
const filesArray: File[] = Array.from<File>(files);
if (isShare) {
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
} else {
await fileUploadHandler(filesArray, albumId);
}
};
</script>
<svelte:window on:paste={onPaste} />
<svelte:body
on:dragenter|stopPropagation|preventDefault={onDragEnter}
on:dragleave|stopPropagation|preventDefault={onDragLeave}
on:drop|stopPropagation|preventDefault={onDrop}
/>
{#if dragStartTarget}

View File

@@ -2,8 +2,44 @@
import { clickOutside } from '../../utils/click-outside';
import { fade } from 'svelte/transition';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
export let onClose: (() => void) | undefined = undefined;
export let onClose: () => void;
/**
* Unique identifier for the modal.
*/
export let id: string;
export let title: string;
/**
* If true, the logo will be displayed next to the modal title.
*/
export let showLogo = false;
/**
* Optional icon to display next to the modal title, if `showLogo` is false.
*/
export let icon: string | undefined = undefined;
/**
* Sets the width of the modal.
*
* - `wide`: 750px
* - `narrow`: 450px
* - `auto`: fits the width of the modal content, up to a maximum of 550px
*/
export let width: 'wide' | 'narrow' | 'auto' = 'narrow';
$: titleId = `${id}-title`;
let modalWidth: string;
$: {
if (width === 'wide') {
modalWidth = 'w-[750px]';
} else if (width === 'narrow') {
modalWidth = 'w-[450px]';
} else {
modalWidth = 'sm:max-w-[550px]';
}
}
</script>
<FocusTrap>
@@ -12,8 +48,17 @@
out:fade={{ duration: 100 }}
class="fixed left-0 top-0 z-[9990] flex h-screen w-screen place-content-center place-items-center bg-black/40"
>
<div class="z-[9999]" use:clickOutside={{ onOutclick: onClose, onEscape: onClose }} tabindex="-1">
<slot />
<div
class="z-[9999] max-w-[95vw] max-h-[95vh] {modalWidth} overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
tabindex="-1"
aria-modal="true"
aria-labelledby={titleId}
>
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
<div class="p-5 pt-0">
<slot />
</div>
</div>
</section>
</FocusTrap>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiClose } from '@mdi/js';
/**
* Unique identifier for the header text.
*/
export let id: string;
export let title: string;
export let onClose: () => void;
/**
* If true, the logo will be displayed next to the modal title.
*/
export let showLogo = false;
/**
* Optional icon to display next to the modal title, if `showLogo` is false.
*/
export let icon: string | undefined = undefined;
</script>
<div class="flex place-items-center justify-between px-5 py-3">
<div class="flex gap-2 place-items-center">
{#if showLogo}
<ImmichLogo noText={true} width={32} />
{:else if icon}
<Icon path={icon} size={32} ariaHidden={true} class="text-immich-primary dark:text-immich-dark-primary" />
{/if}
<h1 {id}>
{title}
</h1>
</div>
<CircleIconButton on:click={onClose} icon={mdiClose} size={'20'} title="Close" />
</div>

View File

@@ -1,7 +1,5 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
import { mdiClose } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import FullScreenModal from '../full-screen-modal.svelte';
import UserAvatar from '../user-avatar.svelte';
@@ -15,28 +13,14 @@
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
</script>
<FullScreenModal onClose={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class=" rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg p-4"
>
<div class="flex items-center">
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary text-sm">
SELECT AVATAR COLOR
</h1>
<div>
<CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
</div>
</div>
<div class="flex items-center justify-center p-4 mt-4">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
{#each colors as color}
<button on:click={() => dispatch('choose', color)}>
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
</button>
{/each}
</div>
</div>
<FullScreenModal id="avatar-selector-modal" title="Select avatar color" width="auto" onClose={() => dispatch('close')}>
<div class="flex items-center justify-center mt-4">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
{#each colors as color}
<button on:click={() => dispatch('choose', color)}>
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
</button>
{/each}
</div>
</div>
</FullScreenModal>

View File

@@ -3,17 +3,15 @@
import { handleError } from '$lib/utils/handle-error';
import { createProfileImage, type AssetResponseDto } from '@immich/sdk';
import domtoimage from 'dom-to-image';
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
import PhotoViewer from '../asset-viewer/photo-viewer.svelte';
import Button from '../elements/buttons/button.svelte';
import BaseModal from './base-modal.svelte';
import { NotificationType, notificationController } from './notification/notification';
export let asset: AssetResponseDto;
export let onClose: () => void;
const dispatch = createEventDispatcher<{
close: void;
}>();
let imgElement: HTMLDivElement;
onMount(() => {
@@ -67,11 +65,11 @@
} catch (error) {
handleError(error, 'Error setting profile picture.');
}
dispatch('close');
onClose();
};
</script>
<BaseModal id="profile-image-cropper" title="Set profile picture" on:close>
<BaseModal id="profile-image-cropper" title="Set profile picture" {onClose}>
<div class="flex place-items-center items-center justify-center">
<div
class="relative flex aspect-square w-1/2 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"

View File

@@ -11,7 +11,7 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcut } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/utils/shortcut';
import { focusOutside } from '$lib/utils/focus-outside';
export let value = '';
@@ -87,12 +87,11 @@
</script>
<svelte:window
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
},
}}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onFocusOut },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.focus() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}>
@@ -130,12 +129,10 @@
on:click={onFocusIn}
on:focus={onFocusIn}
disabled={showFilter}
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
},
}}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onFocusOut },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">

View File

@@ -4,20 +4,24 @@
import { createEventDispatcher } from 'svelte';
import Slider from '$lib/components/elements/slider.svelte';
export let id: string;
export let title: string;
export let subtitle = '';
export let checked = false;
export let disabled = false;
export let isEdited = false;
$: sliderId = `${id}-slider`;
$: subtitleId = subtitle ? `${id}-subtitle` : undefined;
const dispatch = createEventDispatcher<{ toggle: boolean }>();
const onToggle = (ischecked: boolean) => dispatch('toggle', ischecked);
const onToggle = (isChecked: boolean) => dispatch('toggle', isChecked);
</script>
<div class="flex place-items-center justify-between">
<div>
<div class="mr-2">
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={sliderId}>
{title}
</label>
{#if isEdited}
@@ -30,9 +34,17 @@
{/if}
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
{#if subtitle}
<p id={subtitleId} class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
{/if}
<slot />
</div>
<Slider bind:checked {disabled} on:toggle={({ detail }) => onToggle(detail)} />
<Slider
id={sliderId}
bind:checked
{disabled}
on:toggle={({ detail }) => onToggle(detail)}
ariaDescribedBy={subtitleId}
/>
</div>

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import FullScreenModal from './full-screen-modal.svelte';
import { mdiClose, mdiInformationOutline } from '@mdi/js';
import { mdiInformationOutline } from '@mdi/js';
import Icon from '../elements/icon.svelte';
interface Shortcuts {
@@ -20,7 +19,8 @@
general: [
{ key: ['←', '→'], action: 'Previous or next photo' },
{ key: ['Esc'], action: 'Back, close, or deselect' },
{ key: ['/'], action: 'Search your photos' },
{ key: ['Ctrl', 'k'], action: 'Search your photos' },
{ key: ['Ctrl', '⇧', 'k'], action: 'Open the search filters' },
],
actions: [
{ key: ['f'], action: 'Favorite or unfavorite photo' },
@@ -37,63 +37,51 @@
}>();
</script>
<FullScreenModal onClose={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class="w-[400px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[650px]"
>
<div class="relative px-4 pt-4">
<h1 class="px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">Keyboard Shortcuts</h1>
<div class="absolute inset-y-0 right-0 px-4 py-4">
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
</div>
<FullScreenModal
id="keyboard-shortcuts-modal"
title="Keyboard shortcuts"
width="auto"
onClose={() => dispatch('close')}
>
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
<div class="p-4">
<h2>General</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
</div>
</div>
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
<div class="px-4 py-4">
<h2>General</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut}
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p
class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
<div class="p-4">
<h2>Actions</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut}
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
{key}
</p>
{/each}
</div>
<div class="flex items-center gap-2">
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
{#if shortcut.info}
<Icon path={mdiInformationOutline} title={shortcut.info} />
{/if}
</div>
</div>
</div>
<div class="px-4 py-4">
<h2>Actions</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut}
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p
class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<div class="flex items-center gap-2">
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
{#if shortcut.info}
<Icon path={mdiInformationOutline} title={shortcut.info} />
{/if}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>

View File

@@ -33,34 +33,28 @@
</script>
{#if showModal}
<FullScreenModal onClose={() => (showModal = false)}>
<div
class="max-w-lg rounded-3xl border bg-immich-bg px-8 py-10 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<p class="mb-4 text-2xl">🎉 NEW VERSION AVAILABLE 🎉</p>
<FullScreenModal id="new-version-modal" title="🎉 NEW VERSION AVAILABLE" onClose={() => (showModal = false)}>
<div>
Hi friend, there is a new version of the application please take your time to visit the
<span class="font-medium underline"
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"
>release notes</a
></span
>
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations,
especially if you use WatchTower or any mechanism that handles updating your application automatically.
</div>
<div>
Hi friend, there is a new version of the application please take your time to visit the
<span class="font-medium underline"
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"
>release notes</a
></span
>
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations,
especially if you use WatchTower or any mechanism that handles updating your application automatically.
</div>
<div class="mt-4 font-medium">Your friend, Alex</div>
<div class="mt-4 font-medium">Your friend, Alex</div>
<div class="font-sm mt-8">
<code>Server Version: {serverVersion}</code>
<br />
<code>Latest Version: {releaseVersion}</code>
</div>
<div class="font-sm mt-8">
<code>Server Version: {serverVersion}</code>
<br />
<code>Latest Version: {releaseVersion}</code>
</div>
<div class="mt-8 text-right">
<Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
</div>
<div class="mt-8 text-right">
<Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
</div>
</FullScreenModal>
{/if}

View File

@@ -1,20 +1,13 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { getAssetThumbnailUrl } from '$lib/utils';
import {
SharedLinkType,
ThumbnailFormat,
getAssetInfo,
type AssetResponseDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js';
import * as luxon from 'luxon';
import { createEventDispatcher } from 'svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { locale } from '$lib/stores/preferences.store';
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
export let link: SharedLinkResponseDto;
@@ -25,18 +18,6 @@
edit: void;
}>();
const getThumbnail = async (): Promise<AssetResponseDto> => {
let assetId = '';
if (link.album?.albumThumbnailAssetId) {
assetId = link.album.albumThumbnailAssetId;
} else if (link.assets.length > 0) {
assetId = link.assets[0].id;
}
return getAssetInfo({ id: assetId });
};
const getCountDownExpirationDate = () => {
if (!link.expiresAt) {
return;
@@ -70,28 +51,7 @@
class="flex w-full gap-4 border-b border-gray-200 py-4 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary"
>
<div>
{#if link?.album?.albumThumbnailAssetId || link.assets.length > 0}
{#await getThumbnail()}
<LoadingSpinner />
{:then asset}
<img
id={asset.id}
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
alt={asset.id}
class="h-[100px] w-[100px] rounded-lg object-cover"
loading="lazy"
draggable="false"
/>
{/await}
{:else}
<enhanced:img
src="$lib/assets/no-thumbnail.png"
alt={'Album without assets'}
class="h-[100px] w-[100px] rounded-lg object-cover"
loading="lazy"
draggable="false"
/>
{/if}
<AlbumCover album={link?.album} css="h-[100px] w-[100px] transition-all duration-300 hover:shadow-lg" />
</div>
<div class="flex flex-col justify-between">

View File

@@ -30,31 +30,22 @@
};
</script>
<FullScreenModal {onClose}>
<div
class="flex w-full md:w-96 max-w-lg flex-col gap-8 rounded-3xl border bg-white p-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<h1 class="self-center text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
Slideshow Settings
</h1>
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
<SettingDropdown
title="Direction"
options={Object.values(options)}
selectedOption={options[$slideshowNavigation]}
onToggle={(option) => handleToggle(option)}
/>
<SettingSwitch title="Show Progress Bar" bind:checked={$showProgressBar} />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="Duration"
desc="Number of seconds to display each image"
min={1}
bind:value={$slideshowDelay}
/>
<Button class="w-full" color="gray" on:click={onClose}>Done</Button>
</div>
<FullScreenModal id="slideshow-settings-modal" title="Slideshow settings" {onClose}>
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
<SettingDropdown
title="Direction"
options={Object.values(options)}
selectedOption={options[$slideshowNavigation]}
onToggle={(option) => handleToggle(option)}
/>
<SettingSwitch id="show-progress-bar" title="Show Progress Bar" bind:checked={$showProgressBar} />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="Duration"
desc="Number of seconds to display each image"
min={1}
bind:value={$slideshowDelay}
/>
<Button class="w-full" color="gray" on:click={onClose}>Done</Button>
</div>
</FullScreenModal>

View File

@@ -66,6 +66,7 @@
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch
id="theme-selection"
title="Theme selection"
subtitle="Automatically set the theme to light or dark based on your browser's system preference"
bind:checked={$colorTheme.system}
@@ -75,6 +76,7 @@
<div class="ml-4">
<SettingSwitch
id="default-locale"
title="Default Locale"
subtitle="Format dates and numbers based on your browser locale"
checked={$locale == undefined}
@@ -99,6 +101,7 @@
<div class="ml-4">
<SettingSwitch
id="always-load-original-file"
title="Display original photos"
subtitle="Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds."
bind:checked={$alwaysLoadOriginalFile}
@@ -107,6 +110,7 @@
</div>
<div class="ml-4">
<SettingSwitch
id="play-video-thumbnail-on-hover"
title="Play video thumbnail on hover"
subtitle="Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon."
bind:checked={$playVideoThumbnailOnHover}
@@ -116,6 +120,7 @@
<div class="ml-4">
<SettingSwitch
id="show-delete-warning"
title="Permanent deletion warning"
subtitle="Show a warning when permanently deleting assets"
bind:checked={$showDeleteModal}
@@ -124,6 +129,7 @@
<div class="ml-4">
<SettingSwitch
id="people-sidebar-link"
title="People"
subtitle="Display a link to People in the sidebar"
bind:checked={$sidebarSettings.people}
@@ -131,6 +137,7 @@
</div>
<div class="ml-4">
<SettingSwitch
id="sharing-sidebar-link"
title="Sharing"
subtitle="Display a link to Sharing in the sidebar"
bind:checked={$sidebarSettings.sharing}

View File

@@ -49,6 +49,7 @@
{#if deleteDevice}
<ConfirmDialogue
id="log-out-device-modal"
prompt="Are you sure you want to log out this device?"
onConfirm={() => handleDelete()}
onClose={() => (deleteDevice = null)}
@@ -57,6 +58,7 @@
{#if deleteAll}
<ConfirmDialogue
id="log-out-all-modal"
prompt="Are you sure you want to log out all devices?"
onConfirm={() => handleDeleteAll()}
onClose={() => (deleteAll = false)}

View File

@@ -36,6 +36,7 @@
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch
id="time-based-memories"
title="Time-based memories"
subtitle="Photos from previous years"
bind:checked={user.memoriesEnabled}

View File

@@ -6,11 +6,12 @@
import UserAvatar from '../shared-components/user-avatar.svelte';
export let user: UserResponseDto;
export let onClose: () => void;
let availableUsers: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher<{ close: void; 'add-users': UserResponseDto[] }>();
const dispatch = createEventDispatcher<{ 'add-users': UserResponseDto[] }>();
onMount(async () => {
// TODO: update endpoint to have a query param for deleted users
@@ -32,7 +33,7 @@
};
</script>
<BaseModal id="partner-selection-modal" title="Add partner" showLogo on:close>
<BaseModal id="partner-selection-modal" title="Add partner" showLogo {onClose}>
<div class="immich-scrollbar max-h-[300px] overflow-y-auto">
{#if availableUsers.length > 0}
{#each availableUsers as user}

View File

@@ -162,6 +162,7 @@
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<p class="text-xs font-medium my-4">PHOTOS FROM {partner.user.name.toUpperCase()}</p>
<SettingSwitch
id="show-in-timeline"
title="Show in timeline"
subtitle="Show photos and videos from this user in your timeline"
bind:checked={partner.inTimeline}
@@ -181,13 +182,14 @@
{#if createPartnerFlag}
<PartnerSelectionModal
{user}
on:close={() => (createPartnerFlag = false)}
onClose={() => (createPartnerFlag = false)}
on:add-users={(event) => handleCreatePartners(event.detail)}
/>
{/if}
{#if removePartnerDto}
<ConfirmDialogue
id="stop-sharing-photos-modal"
title="Stop sharing your photos?"
prompt="{removePartnerDto.name} will no longer be able to access your photos."
onClose={() => (removePartnerDto = null)}

View File

@@ -81,7 +81,7 @@
{#if newKey}
<APIKeyForm
title="New API Key"
title="New API key"
submitText="Create"
apiKey={newKey}
on:submit={({ detail }) => handleCreate(detail)}
@@ -95,6 +95,7 @@
{#if editKey}
<APIKeyForm
title="API key"
submitText="Save"
apiKey={editKey}
on:submit={({ detail }) => handleUpdate(detail)}
@@ -104,7 +105,8 @@
{#if deleteKey}
<ConfirmDialogue
prompt="Are you sure you want to delete this API Key?"
id="confirm-api-key-delete-modal"
prompt="Are you sure you want to delete this API key?"
onConfirm={() => handleDelete()}
onClose={() => (deleteKey = null)}
/>

View File

@@ -67,10 +67,16 @@ export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {})
export const isShowDetail = persisted<boolean>('info-opened', false, {});
export interface AlbumViewSettings {
sortBy: string;
sortDesc: boolean;
view: string;
filter: string;
groupBy: string;
groupOrder: string;
sortBy: string;
sortOrder: string;
collapsedGroups: {
// Grouping Option => Array<Group ID>
[group: string]: string[];
};
}
export interface SidebarSettings {
@@ -83,6 +89,11 @@ export const sidebarSettings = persisted<SidebarSettings>('sidebar-settings-1',
sharing: true,
});
export enum SortOrder {
Asc = 'asc',
Desc = 'desc',
}
export enum AlbumViewMode {
Cover = 'Cover',
List = 'List',
@@ -94,11 +105,29 @@ export enum AlbumFilter {
Shared = 'Shared',
}
export enum AlbumGroupBy {
None = 'None',
Year = 'Year',
Owner = 'Owner',
}
export enum AlbumSortBy {
Title = 'Title',
ItemCount = 'ItemCount',
DateModified = 'DateModified',
DateCreated = 'DateCreated',
MostRecentPhoto = 'MostRecentPhoto',
OldestPhoto = 'OldestPhoto',
}
export const albumViewSettings = persisted<AlbumViewSettings>('album-view-settings', {
sortBy: 'Most recent photo',
sortDesc: true,
view: AlbumViewMode.Cover,
filter: AlbumFilter.All,
groupBy: AlbumGroupBy.Year,
groupOrder: SortOrder.Desc,
sortBy: AlbumSortBy.MostRecentPhoto,
sortOrder: SortOrder.Desc,
collapsedGroups: {},
});
export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {});

View File

@@ -0,0 +1,203 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import {
AlbumGroupBy,
AlbumSortBy,
SortOrder,
albumViewSettings,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
import type { AlbumResponseDto } from '@immich/sdk';
import * as sdk from '@immich/sdk';
import { get } from 'svelte/store';
/**
* -------------------------
* Albums General Management
* -------------------------
*/
export const createAlbum = async (name?: string, assetIds?: string[]) => {
try {
const newAlbum: AlbumResponseDto = await sdk.createAlbum({
createAlbumDto: {
albumName: name ?? '',
assetIds,
},
});
return newAlbum;
} catch (error) {
handleError(error, 'Failed to create album');
}
};
export const createAlbumAndRedirect = async (name?: string, assetIds?: string[]) => {
const newAlbum = await createAlbum(name, assetIds);
if (newAlbum) {
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
}
};
/**
* -------------
* Album Sorting
* -------------
*/
export interface AlbumSortOptionMetadata {
id: AlbumSortBy;
text: string;
defaultOrder: SortOrder;
columnStyle: string;
}
export const sortOptionsMetadata: AlbumSortOptionMetadata[] = [
{
id: AlbumSortBy.Title,
text: 'Title',
defaultOrder: SortOrder.Asc,
columnStyle: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
},
{
id: AlbumSortBy.ItemCount,
text: 'Number of items',
defaultOrder: SortOrder.Desc,
columnStyle: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
},
{
id: AlbumSortBy.DateModified,
text: 'Date modified',
defaultOrder: SortOrder.Desc,
columnStyle: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
},
{
id: AlbumSortBy.DateCreated,
text: 'Date created',
defaultOrder: SortOrder.Desc,
columnStyle: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
},
{
id: AlbumSortBy.MostRecentPhoto,
text: 'Most recent photo',
defaultOrder: SortOrder.Desc,
columnStyle: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
},
{
id: AlbumSortBy.OldestPhoto,
text: 'Oldest photo',
defaultOrder: SortOrder.Desc,
columnStyle: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
},
];
export const findSortOptionMetadata = (sortBy: string) => {
// Default is sort by most recent photo
const defaultSortOption = sortOptionsMetadata[4];
return sortOptionsMetadata.find(({ id }) => sortBy === id) ?? defaultSortOption;
};
/**
* --------------
* Album Grouping
* --------------
*/
export interface AlbumGroup {
id: string;
name: string;
albums: AlbumResponseDto[];
}
export interface AlbumGroupOptionMetadata {
id: AlbumGroupBy;
text: string;
defaultOrder: SortOrder;
isDisabled: () => boolean;
}
export const groupOptionsMetadata: AlbumGroupOptionMetadata[] = [
{
id: AlbumGroupBy.None,
text: 'No grouping',
defaultOrder: SortOrder.Asc,
isDisabled: () => false,
},
{
id: AlbumGroupBy.Year,
text: 'Group by year',
defaultOrder: SortOrder.Desc,
isDisabled() {
const disabledWithSortOptions: string[] = [AlbumSortBy.DateCreated, AlbumSortBy.DateModified];
return disabledWithSortOptions.includes(get(albumViewSettings).sortBy);
},
},
{
id: AlbumGroupBy.Owner,
text: 'Group by owner',
defaultOrder: SortOrder.Asc,
isDisabled: () => false,
},
];
export const findGroupOptionMetadata = (groupBy: string) => {
// Default is no grouping
const defaultGroupOption = groupOptionsMetadata[0];
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
};
export const getSelectedAlbumGroupOption = (settings: AlbumViewSettings) => {
const defaultGroupOption = AlbumGroupBy.None;
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
return defaultGroupOption;
}
return albumGroupOption;
};
/**
* ----------------------------
* Album Groups Collapse/Expand
* ----------------------------
*/
const getCollapsedAlbumGroups = (settings: AlbumViewSettings) => {
settings.collapsedGroups ??= {};
const { collapsedGroups, groupBy } = settings;
collapsedGroups[groupBy] ??= [];
return collapsedGroups[groupBy];
};
export const isAlbumGroupCollapsed = (settings: AlbumViewSettings, groupId: string) => {
if (settings.groupBy === AlbumGroupBy.None) {
return false;
}
return getCollapsedAlbumGroups(settings).includes(groupId);
};
export const toggleAlbumGroupCollapsing = (groupId: string) => {
const settings = get(albumViewSettings);
if (settings.groupBy === AlbumGroupBy.None) {
return;
}
const collapsedGroups = getCollapsedAlbumGroups(settings);
const groupIndex = collapsedGroups.indexOf(groupId);
if (groupIndex === -1) {
// Collapse
collapsedGroups.push(groupId);
} else {
// Expand
collapsedGroups.splice(groupIndex, 1);
}
albumViewSettings.set(settings);
};
export const collapseAllAlbumGroups = (groupIds: string[]) => {
albumViewSettings.update((settings) => {
const collapsedGroups = getCollapsedAlbumGroups(settings);
collapsedGroups.length = 0;
collapsedGroups.push(...groupIds);
return settings;
});
};
export const expandAllAlbumGroups = () => {
collapseAllAlbumGroups([]);
};

View File

@@ -5,13 +5,14 @@ import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
import { downloadManager } from '$lib/stores/download';
import { downloadRequest, getKey } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils';
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
import {
addAssetsToAlbum as addAssets,
createAlbum,
defaults,
getDownloadInfo,
updateAssets,
type AlbumResponseDto,
type AssetResponseDto,
type AssetTypeEnum,
type DownloadInfoDto,
@@ -48,33 +49,30 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => {
};
export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
try {
const album = await createAlbum({
createAlbumDto: {
albumName,
assetIds,
},
});
const displayName = albumName ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`,
html: true,
button: {
text: 'View Album',
onClick() {
return goto(`${AppRoute.ALBUMS}/${album.id}`);
},
},
});
return album;
} catch {
notificationController.show({
type: NotificationType.Error,
message: 'Failed to create album',
});
const album = await createAlbum(albumName, assetIds);
if (!album) {
return;
}
const displayName = albumName ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`,
html: true,
button: {
text: 'View Album',
onClick() {
return goto(`${AppRoute.ALBUMS}/${album.id}`);
},
},
});
return album;
};
export const downloadAlbum = async (album: AlbumResponseDto) => {
await downloadArchive(`${album.albumName}.zip`, {
albumId: album.id,
});
};
export const downloadBlob = (data: Blob, filename: string) => {

View File

@@ -1,4 +1,6 @@
import { locale } from '$lib/stores/preferences.store';
import { DateTime, Duration } from 'luxon';
import { get } from 'svelte/store';
/**
* Convert time like `01:02:03.456` to seconds.
@@ -15,3 +17,37 @@ export function timeToSeconds(time: string) {
export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
}
export const getShortDateRange = (startDate: string | Date, endDate: string | Date) => {
startDate = startDate instanceof Date ? startDate : new Date(startDate);
endDate = endDate instanceof Date ? endDate : new Date(endDate);
const userLocale = get(locale);
const endDateLocalized = endDate.toLocaleString(userLocale, {
month: 'short',
year: 'numeric',
});
if (startDate.getFullYear() === endDate.getFullYear()) {
if (startDate.getMonth() === endDate.getMonth()) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString(userLocale, {
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
}
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString(userLocale, {
month: 'short',
year: 'numeric',
});
return `${startDateLocalized} - ${endDateLocalized}`;
}
};

View File

@@ -1,29 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';
let albumId: string | undefined;
const dropHandler = async ({ dataTransfer }: DragEvent) => {
const files = dataTransfer?.files;
if (!files) {
return;
}
const filesArray: File[] = Array.from<File>(files);
albumId = ($page.route.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
const isShare = $page.route.id === '/(user)/share/[key]' || undefined;
if (isShare) {
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
} else {
await fileUploadHandler(filesArray, albumId);
}
};
</script>
<slot {albumId} />
<UploadCover {dropHandler} />
<slot />
<UploadCover />

View File

@@ -1,17 +1,50 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import type { PageData } from './$types';
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AlbumsControls from '$lib/components/album-page/albums-controls.svelte';
import Albums from '$lib/components/album-page/albums-list.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import GroupTab from '$lib/components/elements/group-tab.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
export let data: PageData;
let searchAlbum = '';
let searchQuery = '';
let albumGroups: string[] = [];
</script>
<UserPageLayout title={data.meta.title}>
<div class="flex place-items-center gap-2" slot="buttons">
<AlbumsControls bind:searchAlbum />
<AlbumsControls {albumGroups} bind:searchQuery />
</div>
<Albums ownedAlbums={data.albums} {searchAlbum} sharedAlbums={data.sharedAlbums} />
<div class="xl:hidden">
<div class="w-fit h-14 dark:text-immich-dark-fg py-2">
<GroupTab
filters={Object.keys(AlbumFilter)}
selected={$albumViewSettings.filter}
onSelect={(selected) => ($albumViewSettings.filter = selected)}
/>
</div>
<div class="w-60">
<SearchBar placeholder="Search albums" bind:name={searchQuery} isSearching={false} />
</div>
</div>
<Albums
ownedAlbums={data.albums}
sharedAlbums={data.sharedAlbums}
userSettings={$albumViewSettings}
allowEdit
{searchQuery}
bind:albumGroupIds={albumGroups}
>
<EmptyPlaceholder
slot="empty"
text="Create an album to organize your photos and videos"
onClick={() => createAlbumAndRedirect()}
/>
</Albums>
</UserPageLayout>

View File

@@ -39,7 +39,7 @@
import { locale } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { downloadArchive } from '$lib/utils/asset-utils';
import { downloadAlbum } from '$lib/utils/asset-utils';
import { clickOutside } from '$lib/utils/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -345,7 +345,7 @@
};
const handleDownloadAlbum = async () => {
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id });
await downloadAlbum(album);
};
const handleRemoveAlbum = async () => {
@@ -372,6 +372,18 @@
viewMode = ViewMode.VIEW;
assetInteractionStore.clearMultiselect();
await updateThumbnail(assetId);
};
const updateThumbnailUsingCurrentSelection = async () => {
if ($selectedAssets.size === 1) {
const assetId = [...$selectedAssets][0].id;
assetInteractionStore.clearMultiselect();
await updateThumbnail(assetId);
}
};
const updateThumbnail = async (assetId: string) => {
try {
await updateAlbumInfo({
id: album.id,
@@ -403,6 +415,13 @@
{#if isAllUserOwned}
<ChangeDate menuItem />
<ChangeLocation menuItem />
{#if $selectedAssets.size === 1}
<MenuOption
text="Set as album cover"
icon={mdiImageOutline}
on:click={() => updateThumbnailUsingCurrentSelection()}
/>
{/if}
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={() => assetStore.triggerUpdate()} />
{/if}
{#if isOwned || isAllUserOwned}
@@ -663,17 +682,17 @@
{album}
on:select={({ detail: users }) => handleAddUsers(users)}
on:share={() => (viewMode = ViewMode.LINK_SHARING)}
on:close={() => (viewMode = ViewMode.VIEW)}
onClose={() => (viewMode = ViewMode.VIEW)}
/>
{/if}
{#if viewMode === ViewMode.LINK_SHARING}
<CreateSharedLinkModal albumId={album.id} on:close={() => (viewMode = ViewMode.VIEW)} />
<CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = ViewMode.VIEW)} />
{/if}
{#if viewMode === ViewMode.VIEW_USERS}
<ShareInfoModal
on:close={() => (viewMode = ViewMode.VIEW)}
onClose={() => (viewMode = ViewMode.VIEW)}
{album}
on:remove={({ detail: userId }) => handleRemoveUser(userId)}
/>
@@ -681,6 +700,7 @@
{#if viewMode === ViewMode.CONFIRM_DELETE}
<ConfirmDialogue
id="delete-album-modal"
title="Delete album"
confirmText="Delete"
onConfirm={handleRemoveAlbum}

View File

@@ -463,35 +463,25 @@
{/if}
{#if showChangeNameModal}
<FullScreenModal onClose={() => (showChangeNameModal = false)}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Change name</h1>
<FullScreenModal id="change-name-modal" title="Change name" onClose={() => (showChangeNameModal = false)}>
<form on:submit|preventDefault={submitNameChange} autocomplete="off">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<!-- svelte-ignore a11y-autofocus -->
<input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus />
</div>
<form on:submit|preventDefault={submitNameChange} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<!-- svelte-ignore a11y-autofocus -->
<input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button
color="gray"
fullwidth
on:click={() => {
showChangeNameModal = false;
}}>Cancel</Button
>
<Button type="submit" fullwidth>Ok</Button>
</div>
</form>
</div>
<div class="mt-8 flex w-full gap-4">
<Button
color="gray"
fullwidth
on:click={() => {
showChangeNameModal = false;
}}>Cancel</Button
>
<Button type="submit" fullwidth>Ok</Button>
</div>
</form>
</FullScreenModal>
{/if}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
@@ -31,7 +30,6 @@
type AlbumResponseDto,
} from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { flip } from 'svelte/animate';
import type { Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
@@ -39,6 +37,7 @@
import { parseUtcDate } from '$lib/utils/date-time';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -275,13 +274,7 @@
{#if searchResultAlbums.length > 0}
<section>
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
{#each searchResultAlbums as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
<AlbumCard preload={index < 20} {album} isSharingView={false} showItemCount={false} />
</a>
{/each}
</div>
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
<div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80">PHOTOS & VIDEOS</div>
</section>

View File

@@ -1,37 +1,44 @@
<script lang="ts">
import { goto } from '$app/navigation';
import empty2Url from '$lib/assets/empty-2.svg';
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute } from '$lib/constants';
import { createAlbum } from '@immich/sdk';
import { mdiLink, mdiPlusBoxOutline } from '@mdi/js';
import { flip } from 'svelte/animate';
import { handleError } from '../../../lib/utils/handle-error';
import type { PageData } from './$types';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import {
AlbumFilter,
AlbumGroupBy,
AlbumSortBy,
AlbumViewMode,
SortOrder,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import Albums from '$lib/components/album-page/albums-list.svelte';
export let data: PageData;
const createSharedAlbum = async () => {
try {
const newAlbum = await createAlbum({ createAlbumDto: { albumName: '' } });
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
} catch (error) {
handleError(error, 'Unable to create album');
}
const settings: AlbumViewSettings = {
view: AlbumViewMode.Cover,
filter: AlbumFilter.Shared,
groupBy: AlbumGroupBy.None,
groupOrder: SortOrder.Desc,
sortBy: AlbumSortBy.MostRecentPhoto,
sortOrder: SortOrder.Desc,
collapsedGroups: {},
};
</script>
<UserPageLayout title={data.meta.title}>
<div class="flex" slot="buttons">
<LinkButton on:click={createSharedAlbum}>
<LinkButton on:click={() => createAlbumAndRedirect()}>
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" />
<span class="leading-none max-sm:text-xs">Create shared album</span>
<span class="leading-none max-sm:text-xs">Create album</span>
</div>
</LinkButton>
@@ -79,22 +86,15 @@
</div>
<div>
<!-- Share Album List -->
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
{#each data.sharedAlbums as album, index (album.id)}
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
<AlbumCard preload={index < 20} {album} isSharingView />
</a>
{/each}
</div>
<!-- Empty List -->
{#if data.sharedAlbums.length === 0}
<!-- Shared Album List -->
<Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
<!-- Empty List -->
<EmptyPlaceholder
text="Create a shared album to share photos and videos with people in your network"
slot="empty"
text="Create an album to share photos and videos with people in your network"
src={empty2Url}
/>
{/if}
</Albums>
</div>
</div>
</div>

View File

@@ -83,12 +83,13 @@
</section>
{#if editSharedLink}
<CreateSharedLinkModal editingLink={editSharedLink} on:close={handleEditDone} />
<CreateSharedLinkModal editingLink={editSharedLink} onClose={handleEditDone} />
{/if}
{#if deleteLinkId}
<ConfirmDialogue
title="Delete Shared Link"
id="delete-shared-link-modal"
title="Delete shared link"
prompt="Are you sure you want to delete this shared link?"
confirmText="Delete"
onConfirm={() => handleDeleteLink()}

View File

@@ -98,7 +98,8 @@
{#if isShowEmptyConfirmation}
<ConfirmDialogue
title="Empty Trash"
id="empty-trash-modal"
title="Empty trash"
confirmText="Empty"
onConfirm={handleEmptyTrash}
onClose={() => (isShowEmptyConfirmation = false)}

View File

@@ -19,7 +19,6 @@
import '../app.css';
let showNavigationLoadingBar = false;
let albumId: string | undefined;
const isSharedLinkRoute = (route: string | null) => route?.startsWith('/(user)/share/[key]');
@@ -111,7 +110,7 @@
</FullscreenContainer>
</noscript>
<slot {albumId} />
<slot />
{#if showNavigationLoadingBar}
<NavigationLoadingBar />

View File

@@ -30,7 +30,7 @@
<UserPageLayout title={data.meta.title} admin>
<div class="flex justify-end" slot="buttons">
<a href="{AppRoute.ADMIN_SETTINGS}?open=job">
<a href="{AppRoute.ADMIN_SETTINGS}?isOpen=job">
<LinkButton>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiCog} size="18" />

View File

@@ -118,7 +118,9 @@
const handleCreate = async (ownerId: string) => {
try {
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } });
const createdLibrary = await createLibrary({
createLibraryDto: { ownerId, type: LibraryType.External },
});
notificationController.show({
message: `Created library: ${createdLibrary.name}`,
@@ -300,6 +302,7 @@
{#if confirmDeleteLibrary}
<ConfirmDialogue
id="warning-modal"
title="Warning!"
prompt="Are you sure you want to delete this library? This will delete all {deleteAssetCount} contained assets from Immich and cannot be undone. Files will remain on disk."
onConfirm={handleDelete}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialogue.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import RestoreDialogue from '$lib/components/admin-page/restore-dialoge.svelte';
import RestoreDialogue from '$lib/components/admin-page/restore-dialogue.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
@@ -21,7 +21,14 @@
import { asByteUnitString } from '$lib/utils/byte-units';
import { copyToClipboard } from '$lib/utils';
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk';
import { mdiClose, mdiContentCopy, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import {
mdiAccountEditOutline,
mdiClose,
mdiContentCopy,
mdiDeleteRestore,
mdiPencilOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@@ -116,13 +123,23 @@
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 lg:w-[850px]">
{#if shouldShowCreateUserForm}
<FullScreenModal onClose={() => (shouldShowCreateUserForm = false)}>
<FullScreenModal
id="create-new-user-modal"
title="Create new user"
showLogo
onClose={() => (shouldShowCreateUserForm = false)}
>
<CreateUserForm on:submit={onUserCreated} on:cancel={() => (shouldShowCreateUserForm = false)} />
</FullScreenModal>
{/if}
{#if shouldShowEditUserForm}
<FullScreenModal onClose={() => (shouldShowEditUserForm = false)}>
<FullScreenModal
id="edit-user-modal"
title="Edit user"
icon={mdiAccountEditOutline}
onClose={() => (shouldShowEditUserForm = false)}
>
<EditUserForm
user={selectedUser}
bind:newPassword
@@ -153,40 +170,39 @@
{/if}
{#if shouldShowPasswordResetSuccess}
<FullScreenModal onClose={() => (shouldShowPasswordResetSuccess = false)}>
<ConfirmDialogue
title="Password Reset Success"
confirmText="Done"
onConfirm={() => (shouldShowPasswordResetSuccess = false)}
onClose={() => (shouldShowPasswordResetSuccess = false)}
hideCancelButton={true}
confirmColor="green"
>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>The user's password has been reset:</p>
<ConfirmDialogue
id="password-reset-success-modal"
title="Password reset success"
confirmText="Done"
onConfirm={() => (shouldShowPasswordResetSuccess = false)}
onClose={() => (shouldShowPasswordResetSuccess = false)}
hideCancelButton={true}
confirmColor="green"
>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>The user's password has been reset:</p>
<div class="flex justify-center gap-2">
<code
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary dark:text-immich-dark-primary dark:bg-gray-700"
>
{newPassword}
</code>
<LinkButton on:click={() => copyToClipboard(newPassword)} title="Copy password">
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiContentCopy} size="18" />
</div>
</LinkButton>
</div>
<p>
Please provide the temporary password to the user and inform them they will need to change the
password at their next login.
</p>
<div class="flex justify-center gap-2">
<code
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary dark:text-immich-dark-primary dark:bg-gray-700"
>
{newPassword}
</code>
<LinkButton on:click={() => copyToClipboard(newPassword)} title="Copy password">
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiContentCopy} size="18" />
</div>
</LinkButton>
</div>
</svelte:fragment>
</ConfirmDialogue>
</FullScreenModal>
<p>
Please provide the temporary password to the user and inform them they will need to change the password
at their next login.
</p>
</div>
</svelte:fragment>
</ConfirmDialogue>
{/if}
<table class="my-5 w-full text-left">