chore(web): migration svelte 5 syntax (#13883)

This commit is contained in:
Alex
2024-11-14 08:43:25 -06:00
committed by GitHub
parent 9203a61709
commit 0b3742cf13
310 changed files with 6435 additions and 4176 deletions
+11 -3
View File
@@ -1,13 +1,21 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
$: {
run(() => {
if ($page.data.asset) {
setAsset($page.data.asset);
} else {
@@ -15,11 +23,11 @@
}
const asset = $page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
}
});
</script>
<div class:display-none={$showAssetViewer}>
<slot />
{@render children?.()}
</div>
<UploadCover />
+15 -7
View File
@@ -10,16 +10,22 @@
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { t } from 'svelte-i18n';
export let data: PageData;
interface Props {
data: PageData;
}
let searchQuery = '';
let albumGroups: string[] = [];
let { data }: Props = $props();
let searchQuery = $state('');
let albumGroups: string[] = $state([]);
</script>
<UserPageLayout title={data.meta.title}>
<div class="flex place-items-center gap-2" slot="buttons">
<AlbumsControls {albumGroups} bind:searchQuery />
</div>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<AlbumsControls {albumGroups} bind:searchQuery />
</div>
{/snippet}
<div class="xl:hidden">
<div class="w-fit h-14 dark:text-immich-dark-fg py-2">
@@ -43,6 +49,8 @@
{searchQuery}
bind:albumGroupIds={albumGroups}
>
<EmptyPlaceholder slot="empty" text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
{#snippet empty()}
<EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
{/snippet}
</Albums>
</UserPageLayout>
@@ -32,7 +32,7 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute } from '$lib/constants';
import { AppRoute, AlbumPageViewMode } from '$lib/constants';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -87,69 +87,33 @@
import { confirmAlbumDelete } from '$lib/utils/album-utils';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data = $bindable() }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
$: album = data.album;
$: albumId = album.id;
$: albumKey = `${albumId}_${albumOrder}`;
let backUrl: string = $state(AppRoute.ALBUMS);
let viewMode = $state(AlbumPageViewMode.VIEW);
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let isLiked: ActivityResponseDto | null = $state(null);
let reactions: ActivityResponseDto[] = $state([]);
let globalWidth: number = $state(0);
let assetGridWidth: number = $derived(isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
$: {
if (!album.isActivityEnabled && $numberOfComments === 0) {
isShowActivity = false;
}
}
enum ViewMode {
LINK_SHARING = 'link-sharing',
SELECT_USERS = 'select-users',
SELECT_THUMBNAIL = 'select-thumbnail',
SELECT_ASSETS = 'select-assets',
VIEW_USERS = 'view-users',
VIEW = 'view',
OPTIONS = 'options',
}
let backUrl: string = AppRoute.ALBUMS;
let viewMode = ViewMode.VIEW;
let isCreatingSharedAlbum = false;
let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null;
let reactions: ActivityResponseDto[] = [];
let globalWidth: number;
let assetGridWidth: number;
let albumOrder: AssetOrder | undefined = data.album.order;
$: assetStore = new AssetStore({ albumId, order: albumOrder });
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: timelineStore = new AssetStore({ isArchived: false, withPartners: true }, albumId);
const timelineInteractionStore = createAssetInteractionStore();
const { selectedAssets: timelineSelected } = timelineInteractionStore;
$: isOwned = $user.id == album.ownerId;
$: isAllUserOwned = [...$selectedAssets].every((asset) => asset.ownerId === $user.id);
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
$: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
$: {
assetGridWidth = isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth;
}
$: showActivityStatus =
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
// svelte-ignore reactive_declaration_non_reactive_property
$: isEditor =
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
album.ownerId === $user.id;
// svelte-ignore reactive_declaration_non_reactive_property
$: albumHasViewers = album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer);
afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname;
@@ -171,12 +135,15 @@
const handleToggleEnableActivity = async () => {
try {
await updateAlbumInfo({
const updateAlbum = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
album = { ...album, isActivityEnabled: updateAlbum.isActivityEnabled };
await refreshAlbum();
notificationController.show({
type: NotificationType.Info,
@@ -236,11 +203,6 @@
isShowActivity = !isShowActivity;
};
$: if (album.albumUsers.length > 0) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
}
const handleStartSlideshow = async () => {
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
@@ -251,21 +213,21 @@
};
const handleEscape = async () => {
if (viewMode === ViewMode.SELECT_USERS) {
viewMode = ViewMode.VIEW;
if (viewMode === AlbumPageViewMode.SELECT_USERS) {
viewMode = AlbumPageViewMode.VIEW;
return;
}
if (viewMode === ViewMode.SELECT_ASSETS) {
if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
await handleCloseSelectAssets();
return;
}
if (viewMode === ViewMode.LINK_SHARING) {
viewMode = ViewMode.VIEW;
if (viewMode === AlbumPageViewMode.LINK_SHARING) {
viewMode = AlbumPageViewMode.VIEW;
return;
}
if (viewMode === ViewMode.OPTIONS) {
viewMode = ViewMode.VIEW;
if (viewMode === AlbumPageViewMode.OPTIONS) {
viewMode = AlbumPageViewMode.VIEW;
return;
}
if ($showAssetViewer) {
@@ -280,7 +242,7 @@
};
const refreshAlbum = async () => {
data.album = await getAlbumInfo({ id: album.id, withoutAssets: true });
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
};
const handleAddAssets = async () => {
@@ -308,7 +270,7 @@
};
const setModeToView = async () => {
viewMode = ViewMode.VIEW;
viewMode = AlbumPageViewMode.VIEW;
assetStore.destroy();
assetStore = new AssetStore({ albumId, order: albumOrder });
timelineStore.destroy();
@@ -341,13 +303,13 @@
});
await refreshAlbum();
viewMode = ViewMode.VIEW;
viewMode = AlbumPageViewMode.VIEW;
} catch (error) {
handleError(error, $t('errors.error_adding_users_to_album'));
}
};
const handleRemoveUser = async (userId: string, nextViewMode: ViewMode) => {
const handleRemoveUser = async (userId: string, nextViewMode: AlbumPageViewMode) => {
if (userId == 'me' || userId === $user.id) {
await goto(backUrl);
return;
@@ -357,7 +319,7 @@
await refreshAlbum();
// Dynamically set the view mode based on the passed argument
viewMode = album.albumUsers.length > 0 ? nextViewMode : ViewMode.VIEW;
viewMode = album.albumUsers.length > 0 ? nextViewMode : AlbumPageViewMode.VIEW;
} catch (error) {
handleError(error, $t('errors.error_deleting_shared_user'));
}
@@ -371,7 +333,7 @@
const isConfirmed = await confirmAlbumDelete(album);
if (!isConfirmed) {
viewMode = ViewMode.VIEW;
viewMode = AlbumPageViewMode.VIEW;
return;
}
@@ -381,7 +343,7 @@
} catch (error) {
handleError(error, $t('errors.unable_to_delete_album'));
} finally {
viewMode = ViewMode.VIEW;
viewMode = AlbumPageViewMode.VIEW;
}
};
@@ -391,11 +353,11 @@
};
const handleUpdateThumbnail = async (assetId: string) => {
if (viewMode !== ViewMode.SELECT_THUMBNAIL) {
if (viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL) {
return;
}
viewMode = ViewMode.VIEW;
viewMode = AlbumPageViewMode.VIEW;
assetInteractionStore.clearMultiselect();
await updateThumbnail(assetId);
@@ -432,6 +394,40 @@
assetStore.destroy();
timelineStore.destroy();
});
let album = $state(data.album);
let albumId = $derived(album.id);
let albumKey = $derived(`${albumId}_${albumOrder}`);
$effect(() => {
if (!album.isActivityEnabled && $numberOfComments === 0) {
isShowActivity = false;
}
});
let assetStore = $derived(new AssetStore({ albumId, order: albumOrder }));
let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId));
let isOwned = $derived($user.id == album.ownerId);
let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived));
let showActivityStatus = $derived(
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
album.ownerId === $user.id,
);
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
$effect(() => {
if (album.albumUsers.length > 0) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
}
});
</script>
<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
@@ -475,14 +471,14 @@
</ButtonContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW}
{#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
<svelte:fragment slot="trailing">
{#snippet trailing()}
{#if isEditor}
<CircleIconButton
title={$t('add_photos')}
on:click={async () => {
viewMode = ViewMode.SELECT_ASSETS;
onclick={async () => {
viewMode = AlbumPageViewMode.SELECT_ASSETS;
oldAt = { at: $gridScrollTarget?.at };
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
@@ -496,23 +492,27 @@
{#if isOwned}
<CircleIconButton
title={$t('share')}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
icon={mdiShareVariantOutline}
/>
{/if}
{#if album.assetCount > 0}
<CircleIconButton title={$t('slideshow')} on:click={handleStartSlideshow} icon={mdiPresentationPlay} />
<CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
<CircleIconButton title={$t('slideshow')} onclick={handleStartSlideshow} icon={mdiPresentationPlay} />
<CircleIconButton title={$t('download')} onclick={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
{#if isOwned}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}>
<MenuOption
icon={mdiImageOutline}
text={$t('select_album_cover')}
onClick={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)}
/>
<MenuOption
icon={mdiCogOutline}
text={$t('options')}
onClick={() => (viewMode = AlbumPageViewMode.OPTIONS)}
/>
<MenuOption icon={mdiCogOutline} text={$t('options')} onClick={() => (viewMode = ViewMode.OPTIONS)} />
<MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} onClick={() => handleRemoveAlbum()} />
</ButtonContextMenu>
{/if}
@@ -523,18 +523,18 @@
size="sm"
rounded="lg"
disabled={album.assetCount === 0}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
>
{$t('share')}
</Button>
{/if}
</svelte:fragment>
{/snippet}
</ControlAppBar>
{/if}
{#if viewMode === ViewMode.SELECT_ASSETS}
{#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
<ControlAppBar onClose={handleCloseSelectAssets}>
<svelte:fragment slot="leading">
{#snippet leading()}
<p class="text-lg dark:text-immich-dark-fg">
{#if $timelineSelected.size === 0}
{$t('add_to_album')}
@@ -542,26 +542,28 @@
{$t('selected_count', { values: { count: $timelineSelected.size } })}
{/if}
</p>
</svelte:fragment>
{/snippet}
<svelte:fragment slot="trailing">
{#snippet trailing()}
<button
type="button"
on:click={handleSelectFromComputer}
onclick={handleSelectFromComputer}
class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
>
{$t('select_from_computer')}
</button>
<Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}
<Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} onclick={handleAddAssets}
>{$t('done')}</Button
>
</svelte:fragment>
{/snippet}
</ControlAppBar>
{/if}
{#if viewMode === ViewMode.SELECT_THUMBNAIL}
<ControlAppBar onClose={() => (viewMode = ViewMode.VIEW)}>
<svelte:fragment slot="leading">{$t('select_album_cover')}</svelte:fragment>
{#if viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
<ControlAppBar onClose={() => (viewMode = AlbumPageViewMode.VIEW)}>
{#snippet leading()}
{$t('select_album_cover')}
{/snippet}
</ControlAppBar>
{/if}
{/if}
@@ -572,7 +574,7 @@
>
<!-- Use key because AssetGrid can't deal with changing stores -->
{#key albumKey}
{#if viewMode === ViewMode.SELECT_ASSETS}
{#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
<AssetGrid
enableRouting={false}
assetStore={timelineStore}
@@ -586,13 +588,13 @@
{assetStore}
{assetInteractionStore}
isShared={album.albumUsers.length > 0}
isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL}
singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL}
isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
showArchiveIcon
onSelect={({ id }) => handleUpdateThumbnail(id)}
onEscape={handleEscape}
>
{#if viewMode !== ViewMode.SELECT_THUMBNAIL}
{#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
<!-- ALBUM TITLE -->
<section class="pt-8 md:pt-24">
<AlbumTitle
@@ -616,18 +618,18 @@
color="gray"
size="20"
icon={mdiLink}
on:click={() => (viewMode = ViewMode.LINK_SHARING)}
onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)}
/>
{/if}
<!-- owner -->
<button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users with write access (collaborators) -->
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
<button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
<UserAvatar {user} size="md" />
</button>
{/each}
@@ -639,7 +641,7 @@
color="gray"
size="20"
icon={mdiDotsVertical}
on:click={() => (viewMode = ViewMode.VIEW_USERS)}
onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}
/>
{/if}
@@ -648,7 +650,7 @@
color="gray"
size="20"
icon={mdiPlus}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
title={$t('add_more_users')}
/>
{/if}
@@ -665,7 +667,7 @@
<p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p>
<button
type="button"
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)}
class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
>
<span class="text-text-immich-primary dark:text-immich-dark-primary"
@@ -717,29 +719,29 @@
</div>
{/if}
</div>
{#if viewMode === ViewMode.SELECT_USERS}
{#if viewMode === AlbumPageViewMode.SELECT_USERS}
<UserSelectionModal
{album}
onSelect={handleAddUsers}
onShare={() => (viewMode = ViewMode.LINK_SHARING)}
onClose={() => (viewMode = ViewMode.VIEW)}
onShare={() => (viewMode = AlbumPageViewMode.LINK_SHARING)}
onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
/>
{/if}
{#if viewMode === ViewMode.LINK_SHARING}
<CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = ViewMode.VIEW)} />
{#if viewMode === AlbumPageViewMode.LINK_SHARING}
<CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = AlbumPageViewMode.VIEW)} />
{/if}
{#if viewMode === ViewMode.VIEW_USERS}
{#if viewMode === AlbumPageViewMode.VIEW_USERS}
<ShareInfoModal
onClose={() => (viewMode = ViewMode.VIEW)}
onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
{album}
onRemove={(userId) => handleRemoveUser(userId, ViewMode.VIEW_USERS)}
onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.VIEW_USERS)}
onRefreshAlbum={refreshAlbum}
/>
{/if}
{#if viewMode === ViewMode.OPTIONS && $user}
{#if viewMode === AlbumPageViewMode.OPTIONS && $user}
<AlbumOptions
{album}
order={albumOrder}
@@ -748,11 +750,11 @@
albumOrder = order;
await setModeToView();
}}
onRemove={(userId) => handleRemoveUser(userId, ViewMode.OPTIONS)}
onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.OPTIONS)}
onRefreshAlbum={refreshAlbum}
onClose={() => (viewMode = ViewMode.VIEW)}
onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
onToggleEnabledActivity={handleToggleEnableActivity}
onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
onShowSelectSharedUser={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
/>
{/if}
@@ -19,13 +19,17 @@
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
const assetStore = new AssetStore({ isArchived: true });
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
onDestroy(() => {
assetStore.destroy();
@@ -51,6 +55,8 @@
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
<EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
{#snippet empty()}
<EmptyPlaceholder text={$t('no_archived_assets_message')} />
{/snippet}
</AssetGrid>
</UserPageLayout>
+6 -2
View File
@@ -11,8 +11,12 @@
import { purchaseStore } from '$lib/stores/purchase.store';
import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
export let data: PageData;
let showLicenseActivated = false;
interface Props {
data: PageData;
}
let { data }: Props = $props();
let showLicenseActivated = $state(false);
const { isPurchased } = purchaseStore;
</script>
+46 -27
View File
@@ -12,7 +12,11 @@
import { websocketEvents } from '$lib/stores/websocket';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
enum Field {
CITY = 'exifInfo.city',
@@ -23,9 +27,10 @@
return targetField?.items || [];
};
$: places = getFieldItems(data.items, Field.CITY);
$: people = data.response.people;
$: hasPeople = data.response.total > 0;
let places = $derived(getFieldItems(data.items, Field.CITY));
let people = $state(data.response.people);
let hasPeople = $derived(data.response.total > 0);
onMount(() => {
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
@@ -51,13 +56,21 @@
draggable="false">{$t('view_all')}</a
>
</div>
<SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4" let:itemCount>
{#each people.slice(0, itemCount) as person (person.id)}
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</a>
{/each}
<SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#snippet children({ itemCount })}
{#each people.slice(0, itemCount) as person (person.id)}
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</a>
{/each}
{/snippet}
</SingleGridRow>
</div>
{/if}
@@ -72,23 +85,29 @@
draggable="false">{$t('view_all')}</a
>
</div>
<SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4" let:itemCount>
{#each places.slice(0, itemCount) as item (item.data.id)}
<a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}" draggable="false">
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
<img
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={item.value}
class="object-cover aspect-square w-full"
/>
</div>
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
<SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4">
{#snippet children({ itemCount })}
{#each places.slice(0, itemCount) as item (item.data.id)}
<a
class="relative"
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}"
draggable="false"
>
{item.value}
</span>
</a>
{/each}
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
<img
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={item.value}
class="object-cover aspect-square w-full"
/>
</div>
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
>
{item.value}
</span>
</a>
{/each}
{/snippet}
</SingleGridRow>
</div>
{/if}
@@ -21,13 +21,17 @@
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
const assetStore = new AssetStore({ isFavorite: true });
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived));
onDestroy(() => {
assetStore.destroy();
@@ -56,6 +60,8 @@
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
<EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
{#snippet empty()}
<EmptyPlaceholder text={$t('no_favorites_message')} />
{/snippet}
</AssetGrid>
</UserPageLayout>
@@ -18,15 +18,19 @@
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let selectedAssets: Set<AssetResponseDto> = new Set();
const viewport: Viewport = { width: 0, height: 0 };
let { data }: Props = $props();
$: pathSegments = data.path ? data.path.split('/') : [];
$: tree = buildTree($foldersStore?.uniquePaths || []);
$: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
$: currentTreeItems = currentPath ? data.currentFolders : Object.keys(tree);
let selectedAssets: Set<AssetResponseDto> = $state(new Set());
const viewport: Viewport = $state({ width: 0, height: 0 });
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree($foldersStore?.uniquePaths || []));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
onMount(async () => {
await foldersStore.fetchUniquePaths();
@@ -48,20 +52,22 @@
</script>
<UserPageLayout title={data.meta.title}>
<SideBarSection slot="sidebar">
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} />
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
items={tree}
active={currentPath}
{getLink}
/>
</div>
</section>
</SideBarSection>
{#snippet sidebar()}
<SideBarSection>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} />
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
items={tree}
active={currentPath}
{getLink}
/>
</div>
</section>
</SideBarSection>
{/snippet}
<Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} {getLink} />
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
@@ -17,15 +19,19 @@
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let abortController: AbortController;
let mapMarkers: MapMarkerResponseDto[] = [];
let viewingAssets: string[] = [];
let mapMarkers: MapMarkerResponseDto[] = $state([]);
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
let showSettingsModal = false;
let showSettingsModal = $state(false);
onMount(async () => {
mapMarkers = await loadMapMarkers();
@@ -36,9 +42,11 @@
assetViewingStore.showAssetViewer(false);
});
$: if (!$featureFlags.map) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
run(() => {
if (!$featureFlags.map) {
handlePromiseError(goto(AppRoute.PHOTOS));
}
});
const omit = (obj: MapSettings, key: string) => {
return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key));
};
@@ -15,7 +15,11 @@
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true });
const assetInteractionStore = createAssetInteractionStore();
@@ -39,11 +43,11 @@
</AssetSelectControlBar>
{:else}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}>
<svelte:fragment slot="leading">
{#snippet leading()}
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
{data.partner.name}'s photos
</p>
</svelte:fragment>
{/snippet}
</ControlAppBar>
{/if}
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} />
+64 -49
View File
@@ -38,34 +38,35 @@
import { fly } from 'svelte/transition';
import type { PageData } from './$types';
export let data: PageData;
interface Props {
data: PageData;
}
$: people = data.people.people;
$: visiblePeople = people.filter((people) => !people.isHidden);
$: countVisiblePeople = searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden;
$: showPeople = searchName ? searchedPeopleLocal : visiblePeople;
let selectHidden = false;
let searchName = '';
let showChangeNameModal = false;
let showSetBirthDateModal = false;
let showMergeModal = false;
let personName = '';
let nextPage = data.people.hasNextPage ? 2 : null;
let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto;
let potentialMergePeople: PersonResponseDto[] = [];
let edittingPerson: PersonResponseDto | null = null;
let searchedPeopleLocal: PersonResponseDto[] = [];
let handleSearchPeople: (force?: boolean, name?: string) => Promise<void>;
let changeNameInputEl: HTMLInputElement | null;
let innerHeight: number;
let { data }: Props = $props();
let selectHidden = $state(false);
let searchName = $state('');
let showChangeNameModal = $state(false);
let showSetBirthDateModal = $state(false);
let showMergeModal = $state(false);
let personName = $state('');
let nextPage = $state(data.people.hasNextPage ? 2 : null);
let personMerge1 = $state<PersonResponseDto>();
let personMerge2 = $state<PersonResponseDto>();
let potentialMergePeople: PersonResponseDto[] = $state([]);
let edittingPerson: PersonResponseDto | null = $state(null);
let searchedPeopleLocal: PersonResponseDto[] = $state([]);
// let handleSearchPeople: (force?: boolean, name?: string) => Promise<void> = $state();
let changeNameInputEl = $state<HTMLInputElement>();
let innerHeight = $state(0);
let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>();
onMount(() => {
const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
if (getSearchedPeople) {
searchName = getSearchedPeople;
handlePromiseError(handleSearchPeople(true, searchName));
if (searchPeopleElement) {
handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
}
}
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
for (const person of people) {
@@ -198,7 +199,9 @@
);
};
const submitNameChange = async () => {
const submitNameChange = async (event: Event) => {
event.preventDefault();
potentialMergePeople = [];
showChangeNameModal = false;
if (!edittingPerson || personName === edittingPerson.name) {
@@ -225,9 +228,9 @@
potentialMergePeople = people
.filter(
(person: PersonResponseDto) =>
personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
person.id !== personMerge2.id &&
person.id !== personMerge1.id &&
person.id !== personMerge1?.id &&
!person.isHidden,
)
.slice(0, 3);
@@ -293,11 +296,26 @@
const onResetSearchBar = async () => {
await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url);
};
let people = $state(data.people.people);
$effect(() => {
people = data.people.people;
});
let visiblePeople = $derived(people.filter((people) => !people.isHidden));
let countVisiblePeople = $derived(searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden);
let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople);
// const submitNameChange = (event: Event) => {
// event.preventDefault();
// if (searchPeopleElement) {
// handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
// }
// };
</script>
<svelte:window bind:innerHeight />
{#if showMergeModal}
{#if showMergeModal && personMerge1 && personMerge2}
<MergeSuggestionModal
{personMerge1}
{personMerge2}
@@ -312,23 +330,23 @@
title={$t('people')}
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
>
<svelte:fragment slot="buttons">
{#snippet buttons()}
{#if people.length > 0}
<div class="flex gap-2 items-center justify-center">
<div class="hidden sm:block">
<div class="w-40 lg:w-80 h-10">
<SearchPeople
bind:this={searchPeopleElement}
type="searchBar"
placeholder={$t('search_people')}
onReset={onResetSearchBar}
onSearch={handleSearch}
bind:searchName
bind:searchedPeopleLocal
bind:handleSearch={handleSearchPeople}
/>
</div>
</div>
<LinkButton on:click={() => (selectHidden = !selectHidden)}>
<LinkButton onclick={() => (selectHidden = !selectHidden)}>
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
<Icon path={mdiEyeOutline} size="18" />
<p class="ml-2">{$t('show_and_hide_people')}</p>
@@ -336,24 +354,20 @@
</LinkButton>
</div>
{/if}
</svelte:fragment>
{/snippet}
{#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)}
<PeopleInfiniteScroll
people={showPeople}
hasNextPage={!!nextPage && !searchName}
{loadNextPage}
let:person
let:index
>
<PeopleCard
{person}
preload={index < 20}
onChangeName={() => handleChangeName(person)}
onSetBirthDate={() => handleSetBirthDate(person)}
onMergePeople={() => handleMergePeople(person)}
onHidePerson={() => handleHidePerson(person)}
/>
<PeopleInfiniteScroll people={showPeople} hasNextPage={!!nextPage && !searchName} {loadNextPage}>
{#snippet children({ person, index })}
<PeopleCard
{person}
preload={index < 20}
onChangeName={() => handleChangeName(person)}
onSetBirthDate={() => handleSetBirthDate(person)}
onMergePeople={() => handleMergePeople(person)}
onHidePerson={() => handleHidePerson(person)}
/>
{/snippet}
</PeopleInfiniteScroll>
{:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
@@ -368,7 +382,7 @@
{#if showChangeNameModal}
<FullScreenModal title={$t('change_name')} onClose={() => (showChangeNameModal = false)}>
<form on:submit|preventDefault={submitNameChange} autocomplete="off" id="change-name-form">
<form onsubmit={submitNameChange} autocomplete="off" id="change-name-form">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="name">{$t('name')}</label>
<input
@@ -381,16 +395,17 @@
/>
</div>
</form>
<svelte:fragment slot="sticky-bottom">
{#snippet stickyBottom()}
<Button
color="gray"
fullwidth
on:click={() => {
onclick={() => {
showChangeNameModal = false;
}}>{$t('cancel')}</Button
>
<Button type="submit" fullwidth form="change-name-form">{$t('ok')}</Button>
</svelte:fragment>
{/snippet}
</FullScreenModal>
{/if}
@@ -25,7 +25,7 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
@@ -58,47 +58,33 @@
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let data: PageData;
let numberOfAssets = data.statistics.assets;
let { isViewing: showAssetViewer } = assetViewingStore;
enum ViewMode {
VIEW_ASSETS = 'view-assets',
SELECT_PERSON = 'select-person',
MERGE_PEOPLE = 'merge-people',
SUGGEST_MERGE = 'suggest-merge',
BIRTH_DATE = 'birth-date',
UNASSIGN_ASSETS = 'unassign-faces',
interface Props {
data: PageData;
}
let { data = $bindable() }: Props = $props();
let numberOfAssets = $state(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
let assetStore = new AssetStore({
isArchived: false,
personId: data.person.id,
});
$: person = data.person;
$: thumbnailData = getPeopleThumbnailUrl(person);
$: if (person) {
handlePromiseError(updateAssetCount());
handlePromiseError(assetStore.updateOptions({ personId: person.id }));
}
const assetInteractionStore = createAssetInteractionStore();
const { selectedAssets, isMultiSelectState } = assetInteractionStore;
let viewMode: ViewMode = ViewMode.VIEW_ASSETS;
let isEditingName = false;
let previousRoute: string = AppRoute.EXPLORE;
let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS);
let isEditingName = $state(false);
let previousRoute: string = $state(AppRoute.EXPLORE);
let people: PersonResponseDto[] = [];
let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto;
let potentialMergePeople: PersonResponseDto[] = [];
let refreshAssetGrid = false;
let personMerge1: PersonResponseDto | undefined = $state();
let personMerge2: PersonResponseDto | undefined = $state();
let potentialMergePeople: PersonResponseDto[] = $state([]);
let personName = '';
let suggestedPeople: PersonResponseDto[] = [];
let suggestedPeople: PersonResponseDto[] = $state([]);
/**
* Save the word used to search people name: for example,
@@ -107,11 +93,8 @@
* However, it needs to make a new api request if searching 'r' returns 20 names (arbitrary value, the limit sent back by the server).
* or if the new search word starts with another word / letter
**/
let isSearchingPeople = false;
let suggestionContainer: HTMLDivElement;
$: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
let isSearchingPeople = $state(false);
let suggestionContainer: HTMLElement | undefined = $state();
onMount(() => {
const action = $page.url.searchParams.get(QueryParameter.ACTION);
@@ -120,7 +103,7 @@
previousRoute = getPreviousRoute;
}
if (action == 'merge') {
viewMode = ViewMode.MERGE_PEOPLE;
viewMode = PersonPageViewMode.MERGE_PEOPLE;
}
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
@@ -131,7 +114,7 @@
});
const handleEscape = async () => {
if ($showAssetViewer || viewMode === ViewMode.SUGGEST_MERGE) {
if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) {
return;
}
if ($isMultiSelectState) {
@@ -162,11 +145,11 @@
const handleUnmerge = () => {
$assetStore.removeAssets([...$selectedAssets].map((a) => a.id));
assetInteractionStore.clearMultiselect();
viewMode = ViewMode.VIEW_ASSETS;
viewMode = PersonPageViewMode.VIEW_ASSETS;
};
const handleReassignAssets = () => {
viewMode = ViewMode.UNASSIGN_ASSETS;
viewMode = PersonPageViewMode.UNASSIGN_ASSETS;
};
const toggleHidePerson = async () => {
@@ -191,13 +174,11 @@
await updateAssetCount();
await handleGoBack();
data.person = person;
refreshAssetGrid = !refreshAssetGrid;
data = { ...data, person };
};
const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => {
if (viewMode !== ViewMode.SELECT_PERSON) {
if (viewMode !== PersonPageViewMode.SELECT_PERSON) {
return;
}
try {
@@ -209,12 +190,12 @@
assetInteractionStore.clearMultiselect();
viewMode = ViewMode.VIEW_ASSETS;
viewMode = PersonPageViewMode.VIEW_ASSETS;
};
const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => {
const [personToMerge, personToBeMergedIn] = response;
viewMode = ViewMode.VIEW_ASSETS;
viewMode = PersonPageViewMode.VIEW_ASSETS;
isEditingName = false;
try {
await mergePerson({
@@ -228,7 +209,6 @@
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
if (personToBeMergedIn.name != personName && person.id === personToBeMergedIn.id) {
await updateAssetCount();
refreshAssetGrid = !refreshAssetGrid;
return;
}
await goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
@@ -243,11 +223,11 @@
personName = person.name;
personMerge1 = person;
personMerge2 = person2;
viewMode = ViewMode.SUGGEST_MERGE;
viewMode = PersonPageViewMode.SUGGEST_MERGE;
};
const changeName = async () => {
viewMode = ViewMode.VIEW_ASSETS;
viewMode = PersonPageViewMode.VIEW_ASSETS;
person.name = personName;
try {
isEditingName = false;
@@ -264,7 +244,7 @@
};
const handleCancelEditName = () => {
if (viewMode === ViewMode.SUGGEST_MERGE) {
if (viewMode === PersonPageViewMode.SUGGEST_MERGE) {
return;
}
isSearchingPeople = false;
@@ -295,13 +275,13 @@
potentialMergePeople = result
.filter(
(person: PersonResponseDto) =>
personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
person.id !== personMerge2.id &&
person.id !== personMerge1.id &&
person.id !== personMerge1?.id &&
!person.isHidden,
)
.slice(0, 3);
viewMode = ViewMode.SUGGEST_MERGE;
viewMode = PersonPageViewMode.SUGGEST_MERGE;
return;
}
await changeName();
@@ -309,7 +289,7 @@
const handleSetBirthDate = async (birthDate: string) => {
try {
viewMode = ViewMode.VIEW_ASSETS;
viewMode = PersonPageViewMode.VIEW_ASSETS;
person.birthDate = birthDate;
const updatedPerson = await updatePerson({
@@ -331,7 +311,7 @@
};
const handleGoBack = async () => {
viewMode = ViewMode.VIEW_ASSETS;
viewMode = PersonPageViewMode.VIEW_ASSETS;
if ($page.url.searchParams.has(QueryParameter.ACTION)) {
$page.url.searchParams.delete(QueryParameter.ACTION);
await goto($page.url);
@@ -341,37 +321,50 @@
onDestroy(() => {
assetStore.destroy();
});
let person = $derived(data.person);
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
$effect(() => {
if (person) {
handlePromiseError(updateAssetCount());
handlePromiseError(assetStore.updateOptions({ personId: person.id }));
}
});
let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived));
let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
</script>
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS}
<UnMergeFaceSelector
assetIds={[...$selectedAssets].map((a) => a.id)}
personAssets={person}
onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
onConfirm={handleUnmerge}
/>
{/if}
{#if viewMode === ViewMode.SUGGEST_MERGE}
{#if viewMode === PersonPageViewMode.SUGGEST_MERGE && personMerge1 && personMerge2}
<MergeSuggestionModal
{personMerge1}
{personMerge2}
{potentialMergePeople}
onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
onReject={changeName}
onConfirm={handleMergeSamePerson}
/>
{/if}
{#if viewMode === ViewMode.BIRTH_DATE}
{#if viewMode === PersonPageViewMode.BIRTH_DATE}
<SetBirthDateModal
birthDate={person.birthDate ?? ''}
onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
onUpdate={handleSetBirthDate}
/>
{/if}
{#if viewMode === ViewMode.MERGE_PEOPLE}
{#if viewMode === PersonPageViewMode.MERGE_PEOPLE}
<MergeFaceSelector {person} onBack={handleGoBack} onMerge={handleMerge} />
{/if}
@@ -399,14 +392,14 @@
</ButtonContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
{#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
<svelte:fragment slot="trailing">
{#snippet trailing()}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<MenuOption
text={$t('select_featured_photo')}
icon={mdiAccountBoxOutline}
onClick={() => (viewMode = ViewMode.SELECT_PERSON)}
onClick={() => (viewMode = PersonPageViewMode.SELECT_PERSON)}
/>
<MenuOption
text={person.isHidden ? $t('unhide_person') : $t('hide_person')}
@@ -416,21 +409,23 @@
<MenuOption
text={$t('set_date_of_birth')}
icon={mdiCalendarEditOutline}
onClick={() => (viewMode = ViewMode.BIRTH_DATE)}
onClick={() => (viewMode = PersonPageViewMode.BIRTH_DATE)}
/>
<MenuOption
text={$t('merge_people')}
icon={mdiAccountMultipleCheckOutline}
onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)}
onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
/>
</ButtonContextMenu>
</svelte:fragment>
{/snippet}
</ControlAppBar>
{/if}
{#if viewMode === ViewMode.SELECT_PERSON}
<ControlAppBar onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}>
<svelte:fragment slot="leading">{$t('select_featured_photo')}</svelte:fragment>
{#if viewMode === PersonPageViewMode.SELECT_PERSON}
<ControlAppBar onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}>
{#snippet leading()}
{$t('select_featured_photo')}
{/snippet}
</ControlAppBar>
{/if}
{/if}
@@ -442,12 +437,12 @@
enableRouting={true}
{assetStore}
{assetInteractionStore}
isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
singleSelect={viewMode === ViewMode.SELECT_PERSON}
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
onSelect={handleSelectFeaturePhoto}
onEscape={handleEscape}
>
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
{#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE}
<!-- Person information block -->
<div
class="relative w-fit p-4 sm:px-6"
@@ -473,7 +468,7 @@
type="button"
class="flex items-center justify-center"
title={$t('edit_name')}
on:click={() => (isEditingName = true)}
onclick={() => (isEditingName = true)}
>
<ImageThumbnail
circle
@@ -510,11 +505,11 @@
{#each suggestedPeople as person, index (person.id)}
<button
type="button"
class="flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
class="flex w-full border border-gray-200 dark:border-immich-dark-gray h-14 place-items-center bg-gray-100 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
suggestedPeople.length - 1
? 'rounded-b-lg border-b'
: ''}"
on:click={() => handleSuggestPeople(person)}
onclick={() => handleSuggestPeople(person)}
>
<ImageThumbnail
circle
@@ -35,13 +35,12 @@
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
let isAllFavorite: boolean;
let isAllOwned: boolean;
let isAssetStackSelected: boolean;
let isLinkActionAvailable: boolean;
let isAllFavorite = $state(false);
let isAllOwned = $state(false);
let isAssetStackSelected = $state(false);
let isLinkActionAvailable = $state(false);
// svelte-ignore reactive_declaration_non_reactive_property
$: {
$effect(() => {
const selection = [...$selectedAssets];
isAllOwned = selection.every((asset) => asset.ownerId === $user.id);
isAllFavorite = selection.every((asset) => asset.isFavorite);
@@ -52,7 +51,7 @@
selection.some((asset) => asset.type === AssetTypeEnum.Image) &&
selection.some((asset) => asset.type === AssetTypeEnum.Image);
isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate);
}
});
const handleEscape = () => {
if ($showAssetViewer) {
@@ -134,6 +133,8 @@
{#if $preferences.memories.enabled}
<MemoryLane />
{/if}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} />
{/snippet}
</AssetGrid>
</UserPageLayout>
+8 -4
View File
@@ -9,7 +9,11 @@
import { t } from 'svelte-i18n';
import { getAssetThumbnailUrl } from '$lib/utils';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
type AssetWithCity = AssetResponseDto & {
exifInfo: {
@@ -17,10 +21,10 @@
};
};
$: places = data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city);
$: hasPlaces = places.length > 0;
let places = $derived(data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city));
let hasPlaces = $derived(places.length > 0);
let innerHeight: number;
let innerHeight: number = $state(0);
</script>
<svelte:window bind:innerHeight />
@@ -40,24 +40,40 @@
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { t } from 'svelte-i18n';
import { afterUpdate, tick } from 'svelte';
import { onMount, tick } from 'svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = { width: 0, height: 0 };
const viewport: Viewport = $state({ width: 0, height: 0 });
// The GalleryViewer pushes it's own history state, which causes weird
// behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that.
let previousRoute = AppRoute.EXPLORE as string;
let previousRoute = $state(AppRoute.EXPLORE as string);
let nextPage: number | null = 1;
let searchResultAlbums: AlbumResponseDto[] = [];
let searchResultAssets: AssetResponseDto[] = [];
let isLoading = true;
let scrollY = 0;
let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: AssetResponseDto[] = $state([]);
let isLoading = $state(true);
let scrollY = $state(0);
let scrollYHistory = 0;
let selectedAssets: Set<AssetResponseDto> = $state(new Set());
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
let isMultiSelectionMode = $derived(selectedAssets.size > 0);
let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived));
let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite));
let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY));
onMount(() => {
if (terms && $featureFlags.loaded) {
handlePromiseError(onSearchQueryUpdate());
}
});
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
const onEscape = () => {
if ($showAssetViewer) {
@@ -74,8 +90,7 @@
$preventRaceConditionSearchBar = false;
};
// save and restore scroll position
afterUpdate(() => {
$effect(() => {
if (scrollY) {
scrollYHistory = scrollY;
}
@@ -105,11 +120,6 @@
});
});
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
$: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived);
$: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite);
const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id));
@@ -118,16 +128,6 @@
selectedAssets = new Set(searchResultAssets);
};
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
$: searchQuery = $page.url.searchParams.get(QueryParameter.QUERY);
let terms: SearchTerms;
$: terms = searchQuery ? JSON.parse(searchQuery) : {};
$: if (terms && $featureFlags.loaded) {
handlePromiseError(onSearchQueryUpdate());
}
async function onSearchQueryUpdate() {
nextPage = 1;
searchResultAssets = [];
@@ -234,7 +234,7 @@
<div class="fixed z-[100] top-0 left-0 w-full">
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CreateSharedLink />
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
@@ -256,45 +256,52 @@
<div class="fixed z-[100] top-0 left-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} value={terms.query ?? ''} searchQuery={terms} />
<SearchBar
grayTheme={false}
value={terms?.query ?? ''}
searchQuery={terms}
onSearch={() => handlePromiseError(onSearchQueryUpdate())}
/>
</div>
</ControlAppBar>
</div>
{/if}
</section>
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each getObjectKeys(terms) as key (key)}
{@const value = terms[key]}
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{#if terms}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each getObjectKeys(terms) as key (key)}
{@const value = terms[key]}
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
>
{getHumanReadableSearchKey(key)}
</div>
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
{#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if key === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}
{value}
{/if}
>
{getHumanReadableSearchKey(key as keyof SearchTerms)}
</div>
{/if}
</div>
{/each}
</section>
{#if value !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
{#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
{getHumanReadableDate(value)}
{:else if key === 'personIds' && Array.isArray(value)}
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}
{value}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
<section
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
@@ -15,21 +15,24 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { tick } from 'svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
let { title, description } = meta;
let isOwned = $user ? $user.id === sharedLink?.userId : false;
let password = '';
let innerWidth: number;
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
let password = $state('');
let innerWidth: number = $state(0);
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key });
setSharedLink(sharedLink);
passwordRequired = false;
isOwned = $user ? $user.id === sharedLink.userId : false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
description =
sharedLink.description ||
@@ -43,6 +46,11 @@
handleError(error, $t('errors.unable_to_get_shared_link'));
}
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handlePasswordSubmit();
};
</script>
<svelte:window bind:innerWidth />
@@ -54,13 +62,13 @@
{#if passwordRequired}
<header>
<ControlAppBar showBackButton={false}>
<svelte:fragment slot="leading">
{#snippet leading()}
<ImmichLogoSmallLink width={innerWidth} />
</svelte:fragment>
{/snippet}
<svelte:fragment slot="trailing">
{#snippet trailing()}
<ThemeButton />
</svelte:fragment>
{/snippet}
</ControlAppBar>
</header>
<main
@@ -72,7 +80,7 @@
{$t('sharing_enter_password')}
</div>
<div class="mt-4">
<form novalidate autocomplete="off" on:submit|preventDefault={handlePasswordSubmit}>
<form novalidate autocomplete="off" {onsubmit}>
<input type="password" class="immich-form-input mr-2" placeholder={$t('password')} bind:value={password} />
<Button type="submit">{$t('submit')}</Button>
</form>
+24 -16
View File
@@ -20,7 +20,11 @@
import Albums from '$lib/components/album-page/albums-list.svelte';
import { t } from 'svelte-i18n';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
const settings: AlbumViewSettings = {
view: AlbumViewMode.Cover,
@@ -34,21 +38,23 @@
</script>
<UserPageLayout title={data.meta.title}>
<div class="flex" slot="buttons">
<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">{$t('create_album')}</span>
</div>
</LinkButton>
{#snippet buttons()}
<div class="flex">
<LinkButton onclick={() => 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">{$t('create_album')}</span>
</div>
</LinkButton>
<LinkButton href={AppRoute.SHARED_LINKS}>
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
<Icon path={mdiLink} size="18" class="shrink-0" />
<span class="leading-none max-sm:text-xs">{$t('shared_links')}</span>
</div>
</LinkButton>
</div>
<LinkButton href={AppRoute.SHARED_LINKS}>
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
<Icon path={mdiLink} size="18" class="shrink-0" />
<span class="leading-none max-sm:text-xs">{$t('shared_links')}</span>
</div>
</LinkButton>
</div>
{/snippet}
<div class="flex flex-col">
{#if data.partners.length > 0}
@@ -89,7 +95,9 @@
<!-- Shared Album List -->
<Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
<!-- Empty List -->
<EmptyPlaceholder slot="empty" text={$t('no_shared_albums_message')} src={empty2Url} />
{#snippet empty()}
<EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} />
{/snippet}
</Albums>
</div>
</div>
@@ -15,8 +15,8 @@
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
let sharedLinks: SharedLinkResponseDto[] = [];
let editSharedLink: SharedLinkResponseDto | null = null;
let sharedLinks: SharedLinkResponseDto[] = $state([]);
let editSharedLink: SharedLinkResponseDto | null = $state(null);
const refresh = async () => {
sharedLinks = await getAllSharedLinks();
@@ -53,7 +53,9 @@
</script>
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}>
<svelte:fragment slot="leading">{$t('shared_links')}</svelte:fragment>
{#snippet leading()}
{$t('shared_links')}
{/snippet}
</ControlAppBar>
<section class="mt-[120px] flex flex-col pb-[120px] container max-w-screen-lg mx-auto px-3">
@@ -11,13 +11,11 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { AssetStore } from '$lib/stores/assets.store';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
@@ -29,10 +27,14 @@
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
export let data: PageData;
interface Props {
data: PageData;
}
$: pathSegments = data.path ? data.path.split('/') : [];
$: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
let { data }: Props = $props();
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
const assetInteractionStore = createAssetInteractionStore();
@@ -42,14 +44,19 @@
const assetStore = new AssetStore({});
$: tags = data.tags;
$: tagsMap = buildMap(tags);
$: tag = currentPath ? tagsMap[currentPath] : null;
$: tagId = tag?.id;
$: tree = buildTree(tags.map((tag) => tag.value));
$: {
let tags = $state<TagResponseDto[]>([]);
$effect(() => {
tags = data.tags;
});
let tagsMap = $derived(buildMap(tags));
let tag = $derived(currentPath ? tagsMap[currentPath] : null);
let tagId = $derived(tag?.id);
let tree = $derived(buildTree(tags.map((tag) => tag.value)));
$effect.pre(() => {
void assetStore.updateOptions({ tagId });
}
});
const handleNavigation = async (tag: string) => {
await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
@@ -67,15 +74,15 @@
const navigateToView = (path: string) => goto(getLink(path));
let isNewOpen = false;
let newTagValue = '';
let isNewOpen = $state(false);
let newTagValue = $state('');
const handleCreate = () => {
newTagValue = tag ? tag.value + '/' : '';
isNewOpen = true;
};
let isEditOpen = false;
let newTagColor = '';
let isEditOpen = $state(false);
let newTagColor = $state('');
const handleEdit = () => {
newTagColor = tag?.color ?? '';
isEditOpen = true;
@@ -135,49 +142,66 @@
const parentPath = pathSegments.slice(0, -1).join('/');
await navigateToView(parentPath);
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleSubmit();
};
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
<SideBarSection slot="sidebar">
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} />
{#snippet sidebar()}
<SideBarSection>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} />
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems
icons={{ default: mdiTag, active: mdiTag }}
items={tree}
active={currentPath}
{getLink}
{getColor}
/>
</div>
</section>
</SideBarSection>
{/snippet}
{#snippet buttons()}
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems icons={{ default: mdiTag, active: mdiTag }} items={tree} active={currentPath} {getLink} {getColor} />
</div>
<LinkButton onclick={handleCreate}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlus} size="18" />
<p class="hidden md:block">{$t('create_tag')}</p>
</div>
</LinkButton>
{#if pathSegments.length > 0 && tag}
<LinkButton onclick={handleEdit}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPencil} size="18" />
<p class="hidden md:block">{$t('edit_tag')}</p>
</div>
</LinkButton>
<LinkButton onclick={handleDelete}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiTrashCanOutline} size="18" />
<p class="hidden md:block">{$t('delete_tag')}</p>
</div>
</LinkButton>
{/if}
</section>
</SideBarSection>
<section slot="buttons">
<LinkButton on:click={handleCreate}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlus} size="18" />
<p class="hidden md:block">{$t('create_tag')}</p>
</div>
</LinkButton>
{#if pathSegments.length > 0 && tag}
<LinkButton on:click={handleEdit}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPencil} size="18" />
<p class="hidden md:block">{$t('edit_tag')}</p>
</div>
</LinkButton>
<LinkButton on:click={handleDelete}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiTrashCanOutline} size="18" />
<p class="hidden md:block">{$t('delete_tag')}</p>
</div>
</LinkButton>
{/if}
</section>
{/snippet}
<Breadcrumbs {pathSegments} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
<section class="mt-2 h-full">
{#if tag}
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
<TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} slot="empty" />
{#snippet empty()}
<TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} />
{/snippet}
</AssetGrid>
{:else}
<TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} />
@@ -193,7 +217,7 @@
</p>
</div>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
<form {onsubmit} autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
@@ -204,16 +228,17 @@
/>
</div>
</form>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
{#snippet stickyBottom()}
<Button color="gray" fullwidth onclick={() => handleCancel()}>{$t('cancel')}</Button>
<Button type="submit" fullwidth form="create-tag-form">{$t('create')}</Button>
</svelte:fragment>
{/snippet}
</FullScreenModal>
{/if}
{#if isEditOpen}
<FullScreenModal title={$t('edit_tag')} icon={mdiTag} onClose={handleCancel}>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="edit-tag-form">
<form {onsubmit} autocomplete="off" id="edit-tag-form">
<div class="my-4 flex flex-col gap-2">
<SettingInputField
inputType={SettingInputFieldType.COLOR}
@@ -222,9 +247,10 @@
/>
</div>
</form>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
{#snippet stickyBottom()}
<Button color="gray" fullwidth onclick={() => handleCancel()}>{$t('cancel')}</Button>
<Button type="submit" fullwidth form="edit-tag-form">{$t('save')}</Button>
</svelte:fragment>
{/snippet}
</FullScreenModal>
{/if}
@@ -27,7 +27,11 @@
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
if (!$featureFlags.trash) {
handlePromiseError(goto(AppRoute.PHOTOS));
@@ -99,26 +103,30 @@
{#if $featureFlags.loaded && $featureFlags.trash}
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
<div class="flex place-items-center gap-2" slot="buttons">
<LinkButton on:click={handleRestoreTrash} disabled={$isMultiSelectState}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiHistory} size="18" />
{$t('restore_all')}
</div>
</LinkButton>
<LinkButton on:click={() => handleEmptyTrash()} disabled={$isMultiSelectState}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiDeleteForeverOutline} size="18" />
{$t('empty_trash')}
</div>
</LinkButton>
</div>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<LinkButton onclick={handleRestoreTrash} disabled={$isMultiSelectState}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiHistory} size="18" />
{$t('restore_all')}
</div>
</LinkButton>
<LinkButton onclick={() => handleEmptyTrash()} disabled={$isMultiSelectState}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiDeleteForeverOutline} size="18" />
{$t('empty_trash')}
</div>
</LinkButton>
</div>
{/snippet}
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}>
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
</p>
<EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} slot="empty" />
{#snippet empty()}
<EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} />
{/snippet}
</AssetGrid>
</UserPageLayout>
{/if}
@@ -7,18 +7,22 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
export let data: PageData;
export let isShowKeyboardShortcut = false;
interface Props {
data: PageData;
isShowKeyboardShortcut?: boolean;
}
let { data, isShowKeyboardShortcut = $bindable(false) }: Props = $props();
</script>
<UserPageLayout title={data.meta.title}>
<svelte:fragment slot="buttons">
{#snippet buttons()}
<CircleIconButton
icon={mdiKeyboard}
title={$t('show_keyboard_shortcuts')}
on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
/>
</svelte:fragment>
{/snippet}
<section class="mx-4 flex place-content-center">
<div class="w-full max-w-3xl">
<UserSettingsList keys={data.keys} sessions={data.sessions} />
+5 -1
View File
@@ -3,7 +3,11 @@
import type { PageData } from './$types';
import UtilitiesMenu from '$lib/components/utilities-page/utilities-menu.svelte';
export let data: PageData;
interface Props {
data: PageData;
}
let { data }: Props = $props();
</script>
<UserPageLayout title={data.meta.title}>
@@ -22,8 +22,12 @@
import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store';
export let data: PageData;
export let isShowKeyboardShortcut = false;
interface Props {
data: PageData;
isShowKeyboardShortcut?: boolean;
}
let { data = $bindable(), isShowKeyboardShortcut = $bindable(false) }: Props = $props();
interface Shortcuts {
general: ExplainedShortcut[];
@@ -46,8 +50,8 @@
],
};
$: hasDuplicates = data.duplicates.length > 0;
let duplicates = $state(data.duplicates);
let hasDuplicates = $derived(duplicates.length > 0);
const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
if (prompt && confirmText) {
const isConfirmed = await dialogController.show({ prompt, confirmText });
@@ -82,7 +86,7 @@
await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } });
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
deletedNotification(trashIds.length);
},
@@ -95,14 +99,12 @@
await stackAssets(assets, false);
const duplicateAssetIds = assets.map((asset) => asset.id);
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
};
const handleDeduplicateAll = async () => {
const idsToKeep = data.duplicates
.map((group) => suggestDuplicateByFileSize(group.assets))
.map((asset) => asset?.id);
const idsToDelete = data.duplicates.flatMap((group, i) =>
const idsToKeep = duplicates.map((group) => suggestDuplicateByFileSize(group.assets)).map((asset) => asset?.id);
const idsToDelete = duplicates.flatMap((group, i) =>
group.assets.map((asset) => asset.id).filter((asset) => asset !== idsToKeep[i]),
);
@@ -125,7 +127,7 @@
},
});
data.duplicates = [];
duplicates = [];
deletedNotification(idsToDelete.length);
},
@@ -135,12 +137,12 @@
};
const handleKeepAll = async () => {
const ids = data.duplicates.flatMap((group) => group.assets.map((asset) => asset.id));
const ids = duplicates.flatMap((group) => group.assets.map((asset) => asset.id));
return withConfirmation(
async () => {
await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } });
data.duplicates = [];
duplicates = [];
notificationController.show({
message: $t('resolved_all_duplicates'),
@@ -153,38 +155,40 @@
};
</script>
<UserPageLayout title={data.meta.title + ` (${data.duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
<div class="flex place-items-center gap-2" slot="buttons">
<LinkButton on:click={() => handleDeduplicateAll()} disabled={!hasDuplicates}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiTrashCanOutline} size="18" />
{$t('deduplicate_all')}
</div>
</LinkButton>
<LinkButton on:click={() => handleKeepAll()} disabled={!hasDuplicates}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiCheckOutline} size="18" />
{$t('keep_all')}
</div>
</LinkButton>
<CircleIconButton
icon={mdiKeyboard}
title={$t('show_keyboard_shortcuts')}
on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
/>
</div>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<LinkButton onclick={() => handleDeduplicateAll()} disabled={!hasDuplicates}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiTrashCanOutline} size="18" />
{$t('deduplicate_all')}
</div>
</LinkButton>
<LinkButton onclick={() => handleKeepAll()} disabled={!hasDuplicates}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiCheckOutline} size="18" />
{$t('keep_all')}
</div>
</LinkButton>
<CircleIconButton
icon={mdiKeyboard}
title={$t('show_keyboard_shortcuts')}
onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
/>
</div>
{/snippet}
<div class="mt-4">
{#if data.duplicates && data.duplicates.length > 0}
{#if duplicates && duplicates.length > 0}
<div class="mb-4 text-sm dark:text-white">
<p>{$t('duplicates_description')}</p>
</div>
{#key data.duplicates[0].duplicateId}
{#key duplicates[0].duplicateId}
<DuplicatesCompareControl
assets={data.duplicates[0].assets}
assets={duplicates[0].assets}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)}
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}
/>
{/key}
{:else}