mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web): use time buckets of person detail page (3) (#3557)
* feat: add personId to time bucket endpoints * chore: open api * feat(web): time bucket on person detail page
This commit is contained in:
parent
68b5202730
commit
ff32506c5e
19 changed files with 402 additions and 316 deletions
|
|
@ -69,6 +69,6 @@
|
|||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
<section class="grid h-screen bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg">
|
||||
<AssetGrid {assetStore} {assetInteractionStore} isAlbumSelectionMode={true} />
|
||||
<AssetGrid {assetStore} {assetInteractionStore} isSelectionMode={true} />
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import AssetSelectionViewer from '../shared-components/gallery-viewer/asset-selection-viewer.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
|
||||
let selectedAsset: AssetResponseDto | undefined = undefined;
|
||||
|
||||
const handleSelectedAsset = async (event: CustomEvent) => {
|
||||
const { asset }: { asset: AssetResponseDto } = event.detail;
|
||||
selectedAsset = asset;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
dispatch('go-back', { selectedAsset });
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
|
||||
>
|
||||
<ControlAppBar on:close-button-click={onClose}>
|
||||
<svelte:fragment slot="leading">Select feature photo</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
<section class="bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg">
|
||||
<AssetSelectionViewer {assets} on:select={handleSelectedAsset} />
|
||||
</section>
|
||||
</section>
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
|
||||
class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
|
||||
style:max-height={screenHeight - 200 - 200 + 'px'}
|
||||
>
|
||||
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script lang="ts">
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import { api, type PersonResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import type { PersonResponseDto } from '../../../api/open-api';
|
||||
import { api } from '@api';
|
||||
import Merge from 'svelte-material-icons/Merge.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import Merge from 'svelte-material-icons/Merge.svelte';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
reject: void;
|
||||
|
|
@ -39,90 +39,92 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<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 faces - {title}
|
||||
</h1>
|
||||
<CircleIconButton logo={Close} on:click={() => dispatch('close')} />
|
||||
</div>
|
||||
<FullScreenModal on:clickOutside={() => 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 faces - {title}
|
||||
</h1>
|
||||
<CircleIconButton logo={Close} on:click={() => dispatch('close')} />
|
||||
</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={api.getPeopleThumbnailUrl(personMerge1.id)}
|
||||
altText={personMerge1.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="mx-0.5 flex md:mx-2">
|
||||
<CircleIconButton
|
||||
logo={Merge}
|
||||
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={api.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)}> <ArrowLeft /></button>
|
||||
<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={api.getPeopleThumbnailUrl(personMerge1.id)}
|
||||
altText={personMerge1.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</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" on:click={() => changePersonToMerge(person)}>
|
||||
<ImageThumbnail
|
||||
border={true}
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
on:click={() => changePersonToMerge(person)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mx-0.5 flex md:mx-2">
|
||||
<CircleIconButton
|
||||
logo={Merge}
|
||||
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={api.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)}> <ArrowLeft /></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" on:click={() => changePersonToMerge(person)}>
|
||||
<ImageThumbnail
|
||||
border={true}
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
on:click={() => changePersonToMerge(person)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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 face?</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 color="gray" fullwidth on:click={() => dispatch('reject')}>No</Button>
|
||||
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
|
||||
<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 face?</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 color="gray" fullwidth on:click={() => dispatch('reject')}>No</Button>
|
||||
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
export let assets: AssetResponseDto[];
|
||||
export let bucketDate: string;
|
||||
export let bucketHeight: number;
|
||||
export let isAlbumSelectionMode = false;
|
||||
export let isSelectionMode = false;
|
||||
export let viewport: Viewport;
|
||||
export let singleSelect = false;
|
||||
|
||||
export let assetStore: AssetStore;
|
||||
export let assetInteractionStore: AssetInteractionStore;
|
||||
|
|
@ -90,16 +91,12 @@
|
|||
assetsInDateGroup: AssetResponseDto[],
|
||||
dateGroupTitle: string,
|
||||
) => {
|
||||
if (isAlbumSelectionMode) {
|
||||
if (isSelectionMode || $isMultiSelectState) {
|
||||
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($isMultiSelectState) {
|
||||
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
|
||||
} else {
|
||||
assetViewingStore.setAssetId(asset.id);
|
||||
}
|
||||
assetViewingStore.setAssetId(asset.id);
|
||||
};
|
||||
|
||||
const selectAssetGroupHandler = (selectAssetGroupHandler: AssetResponseDto[], dateGroupTitle: string) => {
|
||||
|
|
@ -166,7 +163,7 @@
|
|||
class="mb-2 flex h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
style="width: {geometry[groupIndex].containerWidth}px"
|
||||
>
|
||||
{#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
|
||||
{#if !singleSelect && ((hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle))}
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
|
|
@ -19,7 +19,8 @@
|
|||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
|
||||
export let isAlbumSelectionMode = false;
|
||||
export let isSelectionMode = false;
|
||||
export let singleSelect = false;
|
||||
export let assetStore: AssetStore;
|
||||
export let assetInteractionStore: AssetInteractionStore;
|
||||
export let removeAction: AssetAction | null = null;
|
||||
|
|
@ -33,6 +34,7 @@
|
|||
$: timelineY = element?.scrollTop || 0;
|
||||
|
||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||
const dispatch = createEventDispatcher<{ select: AssetResponseDto }>();
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
|
|
@ -173,11 +175,17 @@
|
|||
};
|
||||
|
||||
const handleSelectAssets = async (e: CustomEvent) => {
|
||||
const asset = e.detail.asset;
|
||||
const asset = e.detail.asset as AssetResponseDto;
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('select', asset);
|
||||
|
||||
if (singleSelect) {
|
||||
element.scrollTop = 0;
|
||||
}
|
||||
|
||||
const rangeSelection = $assetSelectionCandidates.size > 0;
|
||||
const deselect = $selectedAssets.has(asset);
|
||||
|
||||
|
|
@ -308,7 +316,8 @@
|
|||
<AssetDateGroup
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
{isAlbumSelectionMode}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
on:shift={handleScrollTimeline}
|
||||
on:selectAssetCandidates={handleSelectAssetCandidates}
|
||||
on:selectAssets={handleSelectAssets}
|
||||
|
|
|
|||
|
|
@ -132,10 +132,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
/* Used by layouts to ensure proper spacing between navbar and content */
|
||||
--navbar-height: calc(theme(spacing.18) + 4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue