feat: facial recognition (#2180)

This commit is contained in:
Jason Rasmussen
2023-05-17 13:07:17 -04:00
committed by GitHub
parent 115a47d4c6
commit 93863b0629
107 changed files with 3943 additions and 133 deletions

View File

@@ -8,6 +8,7 @@ import {
ConfigurationParameters,
JobApi,
OAuthApi,
PersonApi,
PartnerApi,
SearchApi,
ServerInfoApi,
@@ -31,6 +32,7 @@ export class ImmichApi {
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public shareApi: ShareApi;
public personApi: PersonApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
@@ -49,6 +51,7 @@ export class ImmichApi {
this.searchApi = new SearchApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.shareApi = new ShareApi(this.config);
this.personApi = new PersonApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);
}
@@ -98,6 +101,11 @@ export class ImmichApi {
const path = `/user/profile-image/${userId}`;
return this.createUrl(path);
}
public getPeopleThumbnailUrl(personId: string) {
const path = `/person/${personId}/thumbnail`;
return this.createUrl(path);
}
}
export const api = new ImmichApi({ basePath: '/api' });

View File

@@ -339,6 +339,12 @@ export interface AllJobStatusResponseDto {
* @memberof AllJobStatusResponseDto
*/
'search-queue': JobStatusDto;
/**
*
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'recognize-faces-queue': JobStatusDto;
}
/**
*
@@ -566,6 +572,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'tags'?: Array<TagResponseDto>;
/**
*
* @type {Array<PersonResponseDto>}
* @memberof AssetResponseDto
*/
'people'?: Array<PersonResponseDto>;
}
@@ -1329,6 +1341,7 @@ export const JobName = {
MetadataExtractionQueue: 'metadata-extraction-queue',
VideoConversionQueue: 'video-conversion-queue',
ObjectTaggingQueue: 'object-tagging-queue',
RecognizeFacesQueue: 'recognize-faces-queue',
ClipEncodingQueue: 'clip-encoding-queue',
BackgroundTaskQueue: 'background-task-queue',
StorageTemplateMigrationQueue: 'storage-template-migration-queue',
@@ -1546,6 +1559,44 @@ export interface OAuthConfigResponseDto {
*/
'autoLaunch'?: boolean;
}
/**
*
* @export
* @interface PersonResponseDto
*/
export interface PersonResponseDto {
/**
*
* @type {string}
* @memberof PersonResponseDto
*/
'id': string;
/**
*
* @type {string}
* @memberof PersonResponseDto
*/
'name': string;
/**
*
* @type {string}
* @memberof PersonResponseDto
*/
'thumbnailPath': string;
}
/**
*
* @export
* @interface PersonUpdateDto
*/
export interface PersonUpdateDto {
/**
*
* @type {string}
* @memberof PersonUpdateDto
*/
'name': string;
}
/**
*
* @export
@@ -7460,6 +7511,406 @@ export class PartnerApi extends BaseAPI {
}
/**
* PersonApi - axios parameter creator
* @export
*/
export const PersonApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPerson: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getPerson', 'id', id)
const localVarPath = `/person/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonAssets: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getPersonAssets', 'id', id)
const localVarPath = `/person/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonThumbnail: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getPersonThumbnail', 'id', id)
const localVarPath = `/person/{id}/thumbnail`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {PersonUpdateDto} personUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePerson: async (id: string, personUpdateDto: PersonUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('updatePerson', 'id', id)
// verify required parameter 'personUpdateDto' is not null or undefined
assertParamExists('updatePerson', 'personUpdateDto', personUpdateDto)
const localVarPath = `/person/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(personUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* PersonApi - functional programming interface
* @export
*/
export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPerson(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPerson(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPersonAssets(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPersonThumbnail(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {PersonUpdateDto} personUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updatePerson(id: string, personUpdateDto: PersonUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updatePerson(id, personUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* PersonApi - factory interface
* @export
*/
export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = PersonApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople(options?: any): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPerson(id: string, options?: any): AxiosPromise<PersonResponseDto> {
return localVarFp.getPerson(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonAssets(id: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getPersonAssets(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonThumbnail(id: string, options?: any): AxiosPromise<File> {
return localVarFp.getPersonThumbnail(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {PersonUpdateDto} personUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePerson(id: string, personUpdateDto: PersonUpdateDto, options?: any): AxiosPromise<PersonResponseDto> {
return localVarFp.updatePerson(id, personUpdateDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* PersonApi - object-oriented interface
* @export
* @class PersonApi
* @extends {BaseAPI}
*/
export class PersonApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getAllPeople(options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getPerson(id: string, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getPerson(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getPersonAssets(id: string, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getPersonAssets(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getPersonThumbnail(id: string, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getPersonThumbnail(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {PersonUpdateDto} personUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public updatePerson(id: string, personUpdateDto: PersonUpdateDto, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).updatePerson(id, personUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* SearchApi - axios parameter creator
* @export

View File

@@ -36,6 +36,10 @@
title: 'Encode Clip',
subtitle: 'Run machine learning to generate clip embeddings'
},
[JobName.RecognizeFacesQueue]: {
title: 'Recognize Faces',
subtitle: 'Run machine learning to recognize faces'
},
[JobName.VideoConversionQueue]: {
title: 'Transcode Videos',
subtitle: 'Transcode videos not in the desired format'

View File

@@ -120,10 +120,6 @@
}
});
const clearMultiSelectAssetAssetHandler = () => {
multiSelectAsset = new Set();
};
// Update Album Name
$: {
if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
@@ -340,7 +336,7 @@
{#if isMultiSelectionMode}
<AssetSelectControlBar
assets={multiSelectAsset}
clearSelect={clearMultiSelectAssetAssetHandler}
clearSelect={() => (multiSelectAsset = new Set())}
>
<DownloadFiles filename={album.albumName} sharedLinkKey={sharedLink?.key} />
{#if isOwned}

View File

@@ -91,6 +91,11 @@
}
};
const handleCloseViewer = () => {
isShowDetail = false;
closeViewer();
};
const closeViewer = () => {
dispatch('close');
};
@@ -398,6 +403,7 @@
{asset}
albums={appearsInAlbums}
on:close={() => (isShowDetail = false)}
on:close-viewer={handleCloseViewer}
on:description-focus-in={disableKeyDownEvent}
on:description-focus-out={enableKeyDownEvent}
/>

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import Close from 'svelte-material-icons/Close.svelte';
import { page } from '$app/stores';
import { locale } from '$lib/stores/preferences.store';
import type { LatLngTuple } from 'leaflet';
import { DateTime } from 'luxon';
import Calendar from 'svelte-material-icons/Calendar.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
import { createEventDispatcher } from 'svelte';
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { asByteUnitString } from '../../utils/byte-units';
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
import type { LatLngTuple } from 'leaflet';
import { page } from '$app/stores';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@@ -20,9 +21,10 @@
$: {
// Get latest description from server
if (asset.id) {
api.assetApi
.getAssetById(asset.id)
.then((res) => (textarea.value = res.data?.exifInfo?.description || ''));
api.assetApi.getAssetById(asset.id).then((res) => {
people = res.data?.people || [];
textarea.value = res.data?.exifInfo?.description || '';
});
}
}
@@ -35,6 +37,8 @@
}
})();
$: people = asset.people || [];
const dispatch = createEventDispatcher();
const getMegapixel = (width: number, height: number): number | undefined => {
@@ -81,7 +85,7 @@
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
</div>
<div class="mx-4 mt-10">
<section class="mx-4 mt-10">
<textarea
bind:this={textarea}
class="max-h-[500px]
@@ -96,13 +100,35 @@
bind:value={description}
disabled={$page?.data?.user?.id !== asset.ownerId}
/>
</div>
</section>
{#if people.length > 0}
<section class="px-4 py-4 text-sm">
<h2>PEOPLE</h2>
<div class="flex flex-wrap gap-2 mt-4">
{#each people as person (person.id)}
<a href="/people/{person.id}" class="w-[90px]" on:click={() => dispatch('close-viewer')}>
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="90px"
heightStyle="90px"
/>
<p class="font-medium mt-1 truncate">{person.name}</p>
</a>
{/each}
</div>
</section>
{/if}
<div class="px-4 py-4">
{#if !asset.exifInfo}
<p class="text-sm pb-4">NO EXIF INFO AVAILABLE</p>
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
{:else}
<p class="text-sm pb-4">DETAILS</p>
<p class="text-sm">DETAILS</p>
{/if}
{#if asset.exifInfo?.dateTimeOriginal}

View File

@@ -1,9 +1,13 @@
<script lang="ts">
import { imageLoad } from '$lib/utils/image-load';
export let url: string;
export let altText: string;
export let heightStyle: string;
export let heightStyle: string | undefined = undefined;
export let widthStyle: string;
export let curve = false;
export let shadow = false;
export let circle = false;
let loading = true;
</script>
@@ -13,7 +17,11 @@
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:opacity-0={loading}
draggable="false"
on:load|once={() => (loading = false)}
use:imageLoad
on:image-load|once={() => (loading = false)}
/>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { PersonResponseDto, api } from '@api';
import { createEventDispatcher } from 'svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
export let person: PersonResponseDto;
let name = person.name;
const dispatch = createEventDispatcher<{ change: string }>();
const handleNameChange = () => dispatch('change', name);
</script>
<div
class="flex place-items-center max-w-lg rounded-lg border dark:border-transparent p-2 bg-gray-100 dark:bg-gray-700"
>
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<form
class="ml-4 flex justify-between w-full gap-16"
autocomplete="off"
on:submit|preventDefault={handleNameChange}
>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
class="gap-2 w-full bg-gray-100 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="New name or nickname"
required
bind:value={name}
on:blur
/>
<Button size="sm" type="submit">Done</Button>
</form>
</div>

View File

@@ -10,6 +10,7 @@ export enum AppRoute {
ALBUMS = '/albums',
ARCHIVE = '/archive',
FAVORITES = '/favorites',
PEOPLE = '/people',
PHOTOS = '/photos',
EXPLORE = '/explore',
SHARING = '/sharing',

View File

@@ -8,10 +8,11 @@ export const load = (async ({ locals, parent }) => {
}
const { data: items } = await locals.api.searchApi.getExploreData();
const { data: people } = await locals.api.personApi.getAllPeople();
return {
user,
items,
people,
meta: {
title: 'Explore'
}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute } from '$lib/constants';
import { AssetTypeEnum, SearchExploreItem } from '@api';
import { AssetTypeEnum, SearchExploreResponseDto, api } from '@api';
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
import HeartMultipleOutline from 'svelte-material-icons/HeartMultipleOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import HeartMultipleOutline from 'svelte-material-icons/HeartMultipleOutline.svelte';
import type { PageData } from './$types';
export let data: PageData;
@@ -19,27 +20,47 @@
const MAX_ITEMS = 12;
let things: SearchExploreItem[] = [];
let places: SearchExploreItem[] = [];
const getFieldItems = (items: SearchExploreResponseDto[], field: Field) => {
const targetField = items.find((item) => item.fieldName === field);
return targetField?.items || [];
};
for (const item of data.items) {
switch (item.fieldName) {
case Field.OBJECTS:
things = item.items;
break;
case Field.CITY:
places = item.items;
break;
}
}
things = things.slice(0, MAX_ITEMS);
places = places.slice(0, MAX_ITEMS);
$: things = getFieldItems(data.items, Field.OBJECTS);
$: places = getFieldItems(data.items, Field.CITY);
$: people = data.people.slice(0, MAX_ITEMS);
</script>
<UserPageLayout user={data.user} title={data.meta.title}>
<div class="mx-4 flex flex-col">
<div class="mx-4">
{#if people.length > 0}
<div class="mb-6 mt-2">
<div class="flex justify-between">
<p class="mb-4 dark:text-immich-dark-fg font-medium">People</p>
{#if data.people.length > MAX_ITEMS}
<a
href={AppRoute.PEOPLE}
class="font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg"
draggable="false">View All</a
>
{/if}
</div>
<div class="flex flex-row flex-wrap gap-4">
{#each people as person (person.id)}
<a href="/people/{person.id}" class="w-24 text-center">
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
/>
<p class="font-medium mt-2 text-ellipsis text-sm dark:text-white">{person.name}</p>
</a>
{/each}
</div>
</div>
{/if}
{#if places.length > 0}
<div class="mb-6 mt-2">
<div>

View File

@@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
const { data: people } = await locals.api.personApi.getAllPeople();
return {
user,
people,
meta: {
title: 'People'
}
};
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { api } from '@api';
import AccountOff from 'svelte-material-icons/AccountOff.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<UserPageLayout user={data.user} showUploadButton title="People">
{#if data.people.length > 0}
<div class="pl-4">
<div class="flex flex-row flex-wrap gap-1">
{#each data.people as person (person.id)}
<div class="relative">
<a href="/people/{person.id}" draggable="false">
<div class="filter brightness-75 rounded-xl w-48">
<ImageThumbnail
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
/>
</div>
{#if person.name}
<span
class="absolute bottom-2 w-full text-center font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
>
{person.name}
</span>
{/if}
</a>
</div>
{/each}
</div>
</div>
{:else}
<div
class="flex items-center place-content-center w-full min-h-[calc(66vh_-_11rem)] dark:text-white"
>
<div class="flex flex-col content-center items-center text-center">
<AccountOff size="3.5em" />
<p class="font-medium text-3xl mt-5">No people</p>
</div>
</div>
{/if}
</UserPageLayout>

View File

@@ -0,0 +1,21 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, parent, params }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
const { data: person } = await locals.api.personApi.getPerson(params.personId);
const { data: assets } = await locals.api.personApi.getPersonAssets(params.personId);
return {
user,
assets,
person,
meta: {
title: person.name || 'Person'
}
};
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadFiles from '$lib/components/photos-page/actions/download-files.svelte';
import MoveToArchive from '$lib/components/photos-page/actions/move-to-archive.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import OptionAddToAlbum from '$lib/components/photos-page/menu-options/option-add-to-album.svelte';
import OptionAddToFavorites from '$lib/components/photos-page/menu-options/option-add-to-favorites.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, api } from '@api';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import type { PageData } from './$types';
export let data: PageData;
let isEditName = false;
let multiSelectAsset: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = multiSelectAsset.size > 0;
const handleNameChange = async (name: string) => {
try {
isEditName = false;
data.person.name = name;
await api.personApi.updatePerson(data.person.id, { name });
} catch (error) {
handleError(error, 'Unable to save name');
}
};
const handleAssetDelete = (assetId: string) => {
data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
};
</script>
{#if isMultiSelectionMode}
<AssetSelectControlBar
assets={multiSelectAsset}
clearSelect={() => (multiSelectAsset = new Set())}
>
<CreateSharedLink />
<MoveToArchive />
<DownloadFiles filename={data.person.name} />
<AssetSelectContextMenu icon={Plus} title="Add">
<OptionAddToFavorites />
<OptionAddToAlbum />
<OptionAddToAlbum shared />
</AssetSelectContextMenu>
<DeleteAssets onAssetDelete={handleAssetDelete} />
</AssetSelectControlBar>
{:else}
<ControlAppBar
showBackButton
backIcon={ArrowLeft}
on:close-button-click={() => goto(AppRoute.EXPLORE)}
/>
{/if}
<!-- Face information block -->
<section class="pt-24 px-4 sm:px-6 flex place-items-center">
{#if isEditName}
<EditNameInput
person={data.person}
on:change={(event) => handleNameChange(event.detail)}
on:blur={() => (isEditName = false)}
/>
{:else}
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(data.person.id)}
altText={data.person.name}
widthStyle="3.375rem"
heightStyle="3.375rem"
/>
<button
title="Edit name"
class="px-4 text-immich-primary dark:text-immich-dark-primary"
on:click={() => (isEditName = true)}
>
{#if data.person.name}
<p class="font-medium py-2">{data.person.name}</p>
{:else}
<p class="font-medium w-fit">Add a name</p>
<p class="text-sm text-gray-500 dark:text-immich-gray">
Find them fast by name with search
</p>
{/if}
</button>
{/if}
</section>
<!-- Gallery Block -->
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
<section class="overflow-y-auto relative immich-scrollbar">
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
<GalleryViewer
assets={data.assets}
viewFrom="search-page"
showArchiveIcon={true}
bind:selectedAssets={multiSelectAsset}
/>
</section>
</section>
</section>

View File

@@ -91,7 +91,7 @@
</div>
{:else}
<div
class="flex items-center place-content-center w-full min-h-[calc(100vh_-_11rem)] dark:text-white"
class="flex items-center place-content-center w-full min-h-[calc(66vh_-_11rem)] dark:text-white"
>
<div class="flex flex-col content-center items-center text-center">
<ImageOffOutline size="3.5em" />