add faces
This commit is contained in:
parent
a7b62a5ad3
commit
e2ad4ac5b3
4 changed files with 215 additions and 43 deletions
|
@ -1,31 +1,65 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { PeopleResponseDto, PersonResponseDto, api } from '@api';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
|
||||
import { PersonResponseDto, api } from '@api';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import FaceThumbnail from '$lib/components/assets/thumbnail/face-thumbnail.svelte';
|
||||
|
||||
export let selectedPeopleIds: string[] = [];
|
||||
let people: PersonResponseDto[] = [];
|
||||
let selectedPeople: PersonResponseDto[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void; confirm: { people: PersonResponseDto[] } }>();
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void }>();
|
||||
onMount(async () => {
|
||||
const { data } = await api.personApi.getAllPeople({ withHidden: false });
|
||||
|
||||
people = data.people;
|
||||
|
||||
selectedPeople = people.filter((p) => selectedPeopleIds.includes(p.id));
|
||||
});
|
||||
|
||||
const handleSelection = (e: CustomEvent<{ person: PersonResponseDto }>) => {
|
||||
const person = e.detail.person;
|
||||
|
||||
if (selectedPeople.some((p) => p.id === person.id)) {
|
||||
selectedPeople = selectedPeople.filter((p) => p.id !== person.id);
|
||||
} else {
|
||||
selectedPeople = [...selectedPeople, person];
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmClicked = () => {
|
||||
dispatch('confirm', { people: selectedPeople });
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => dispatch('close')}>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg font-medium">Select a face</p>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => dispatch('close')}>
|
||||
<svelte:fragment slot="leading">
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg font-medium">
|
||||
{#if selectedPeople.length === 0}
|
||||
Select faces
|
||||
{:else}
|
||||
{selectedPeople.length} people selected
|
||||
{/if}
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="mt-24 flex flex-wrap gap-2 px-8">
|
||||
{#each people as person}
|
||||
<PeopleCard {person} selectionMode disableContextMenu />
|
||||
{/each}
|
||||
</div>
|
||||
<svelte:fragment slot="trailing">
|
||||
<Button disabled={selectedPeople.length === 0} size="sm" title="Confirm" on:click={onConfirmClicked}>Confirm</Button
|
||||
>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
|
||||
<div class="mt-24 flex flex-wrap gap-2 px-8">
|
||||
{#each people as person}
|
||||
<FaceThumbnail
|
||||
{person}
|
||||
thumbnailSize={180}
|
||||
on:select={handleSelection}
|
||||
on:click={handleSelection}
|
||||
selected={selectedPeople.some((p) => p.id === person.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import BaseModal from '$lib/components/shared-components/base-modal.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { AlbumResponseDto } from '@api';
|
||||
import { RuleKey, type AlbumResponseDto, type PersonResponseDto } from '@api';
|
||||
import Plus from 'svelte-material-icons/Plus.svelte';
|
||||
import Button from '../../elements/buttons/button.svelte';
|
||||
import Portal from '../../shared-components/portal/portal.svelte';
|
||||
|
@ -12,8 +12,17 @@
|
|||
|
||||
let peopleSelection = false;
|
||||
let locationSelection = false;
|
||||
$: selectedFaces = album.rules.map((r) => {
|
||||
if (r.key === RuleKey.Person) {
|
||||
return String(r.value);
|
||||
}
|
||||
}) as string[];
|
||||
|
||||
const dispatch = createEventDispatcher<{ close: void }>();
|
||||
|
||||
const handleFaceSelected = (e: CustomEvent<{ people: PersonResponseDto[] }>) => {
|
||||
peopleSelection = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<BaseModal
|
||||
|
@ -84,9 +93,13 @@
|
|||
{#if peopleSelection}
|
||||
<section
|
||||
transition:fly={{ y: 500 }}
|
||||
class="dark:bg-immich-dark-bg absolute left-0 top-0 z-[10000] h-full min-h-max w-full overflow-scroll bg-gray-200"
|
||||
class="dark:bg-immich-dark-bg absolute left-0 top-0 z-[10000] h-full min-h-max w-full overflow-y-auto bg-gray-200"
|
||||
>
|
||||
<FaceSelection on:close={() => (peopleSelection = false)} />
|
||||
<FaceSelection
|
||||
on:close={() => (peopleSelection = false)}
|
||||
on:confirm={handleFaceSelected}
|
||||
selectedPeopleIds={selectedFaces}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
</Portal>
|
||||
|
|
130
web/src/lib/components/assets/thumbnail/face-thumbnail.svelte
Normal file
130
web/src/lib/components/assets/thumbnail/face-thumbnail.svelte
Normal file
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||
import { api, PersonResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
|
||||
export let person: PersonResponseDto;
|
||||
export let groupIndex = 0;
|
||||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let thumbnailWidth: number | undefined = undefined;
|
||||
export let thumbnailHeight: number | undefined = undefined;
|
||||
export let selected = false;
|
||||
export let selectionCandidate = false;
|
||||
export let disabled = false;
|
||||
export let readonly = false;
|
||||
|
||||
let mouseOver = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
click: { person: PersonResponseDto };
|
||||
select: { person: PersonResponseDto };
|
||||
'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
|
||||
}>();
|
||||
|
||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
$: [width, height] = ((): [number, number] => {
|
||||
if (thumbnailSize) {
|
||||
return [thumbnailSize, thumbnailSize];
|
||||
}
|
||||
|
||||
if (thumbnailWidth && thumbnailHeight) {
|
||||
return [thumbnailWidth, thumbnailHeight];
|
||||
}
|
||||
|
||||
return [235, 235];
|
||||
})();
|
||||
|
||||
const thumbnailClickedHandler = () => {
|
||||
if (!disabled) {
|
||||
dispatch('click', { person });
|
||||
}
|
||||
};
|
||||
|
||||
const thumbnailKeyDownHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
thumbnailClickedHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const onIconClickedHandler = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
dispatch('select', { person });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<IntersectionObserver once={false} let:intersecting>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class="group relative overflow-hidden {disabled
|
||||
? 'bg-gray-300'
|
||||
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
||||
class:cursor-not-allowed={disabled}
|
||||
class:hover:cursor-pointer={!disabled}
|
||||
on:mouseenter={() => (mouseOver = true)}
|
||||
on:mouseleave={() => (mouseOver = false)}
|
||||
on:click={thumbnailClickedHandler}
|
||||
on:keydown={thumbnailKeyDownHandler}
|
||||
>
|
||||
{#if intersecting}
|
||||
<div class="absolute z-20 h-full w-full">
|
||||
<!-- Select asset button -->
|
||||
{#if !readonly && (mouseOver || selected || selectionCandidate)}
|
||||
<button
|
||||
on:click={onIconClickedHandler}
|
||||
class="absolute p-2 focus:outline-none"
|
||||
class:cursor-not-allowed={disabled}
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
{disabled}
|
||||
>
|
||||
{#if disabled}
|
||||
<CheckCircle size="24" class="text-zinc-800" />
|
||||
{:else if selected}
|
||||
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
|
||||
<CheckCircle size="24" class="text-immich-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<CheckCircle size="24" class="text-white/80 hover:text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dark:bg-immich-dark-gray absolute h-full w-full select-none bg-gray-100 transition-transform"
|
||||
class:scale-[0.85]={selected}
|
||||
class:rounded-xl={selected}
|
||||
>
|
||||
<!-- Gradient overlay on hover -->
|
||||
<div
|
||||
class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
class:rounded-xl={selected}
|
||||
/>
|
||||
|
||||
<ImageThumbnail
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
</div>
|
||||
{#if selectionCandidate}
|
||||
<div
|
||||
class="bg-immich-primary absolute top-0 h-full w-full opacity-40"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</IntersectionObserver>
|
|
@ -9,11 +9,8 @@
|
|||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let person: PersonResponseDto;
|
||||
export let selectionMode = false;
|
||||
export let disableContextMenu = false;
|
||||
|
||||
let showContextMenu = false;
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
const onChangeNameClicked = () => {
|
||||
|
@ -30,7 +27,7 @@
|
|||
</script>
|
||||
|
||||
<div id="people-card" class="relative">
|
||||
<a href={!selectionMode ? '/people/{person.id}' : ''} draggable="false">
|
||||
<a href="/people/{person.id}" draggable="false">
|
||||
<div class="h-48 w-48 rounded-xl brightness-95 filter">
|
||||
<ImageThumbnail shadow url={api.getPeopleThumbnailUrl(person.id)} altText={person.name} widthStyle="100%" />
|
||||
</div>
|
||||
|
@ -41,28 +38,26 @@
|
|||
{/if}
|
||||
</a>
|
||||
|
||||
{#if !disableContextMenu}
|
||||
<button
|
||||
class="absolute right-2 top-2 z-20"
|
||||
on:click|stopPropagation|preventDefault={() => {
|
||||
showContextMenu = !showContextMenu;
|
||||
}}
|
||||
data-testid="context-button-parent"
|
||||
id={`icon-${person.id}`}
|
||||
>
|
||||
<IconButton color="transparent-primary">
|
||||
<DotsVertical size="20" />
|
||||
</IconButton>
|
||||
<button
|
||||
class="absolute right-2 top-2 z-20"
|
||||
on:click|stopPropagation|preventDefault={() => {
|
||||
showContextMenu = !showContextMenu;
|
||||
}}
|
||||
data-testid="context-button-parent"
|
||||
id={`icon-${person.id}`}
|
||||
>
|
||||
<IconButton color="transparent-primary">
|
||||
<DotsVertical size="20" />
|
||||
</IconButton>
|
||||
|
||||
{#if showContextMenu}
|
||||
<ContextMenu on:outclick={() => (showContextMenu = false)}>
|
||||
<MenuOption on:click={() => onHideFaceClicked()} text="Hide face" />
|
||||
<MenuOption on:click={() => onChangeNameClicked()} text="Change name" />
|
||||
<MenuOption on:click={() => onMergeFacesClicked()} text="Merge faces" />
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if showContextMenu}
|
||||
<ContextMenu on:outclick={() => (showContextMenu = false)}>
|
||||
<MenuOption on:click={() => onHideFaceClicked()} text="Hide face" />
|
||||
<MenuOption on:click={() => onChangeNameClicked()} text="Change name" />
|
||||
<MenuOption on:click={() => onMergeFacesClicked()} text="Merge faces" />
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showContextMenu}
|
||||
|
|
Loading…
Reference in a new issue