mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
refactor(web): search box (#7397)
* refactor search suggestion handling * chore: open api * revert server changes * chore: open api * update location filters * location filter cleanup * refactor people filter * refactor camera filter * refactor display filter * cleanup --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
parent
45ecb629a1
commit
3e8af16270
9 changed files with 468 additions and 491 deletions
|
@ -195,26 +195,22 @@ export class SearchService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
|
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
|
||||||
if (dto.type === SearchSuggestionType.COUNTRY) {
|
switch (dto.type) {
|
||||||
return this.metadataRepository.getCountries(auth.user.id);
|
case SearchSuggestionType.COUNTRY: {
|
||||||
|
return this.metadataRepository.getCountries(auth.user.id);
|
||||||
|
}
|
||||||
|
case SearchSuggestionType.STATE: {
|
||||||
|
return this.metadataRepository.getStates(auth.user.id, dto.country);
|
||||||
|
}
|
||||||
|
case SearchSuggestionType.CITY: {
|
||||||
|
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
|
||||||
|
}
|
||||||
|
case SearchSuggestionType.CAMERA_MAKE: {
|
||||||
|
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
|
||||||
|
}
|
||||||
|
case SearchSuggestionType.CAMERA_MODEL: {
|
||||||
|
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.type === SearchSuggestionType.STATE) {
|
|
||||||
return this.metadataRepository.getStates(auth.user.id, dto.country);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.type === SearchSuggestionType.CITY) {
|
|
||||||
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
|
|
||||||
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
|
|
||||||
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,10 @@
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function toComboBoxOptions(items: string[]) {
|
||||||
|
return items.map<ComboBoxOption>((item) => ({ label: item, value: item }));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface SearchCameraFilter {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||||
|
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
|
||||||
|
|
||||||
|
export let filters: SearchCameraFilter;
|
||||||
|
|
||||||
|
let makes: string[] = [];
|
||||||
|
let models: string[] = [];
|
||||||
|
|
||||||
|
$: makeFilter = filters.make;
|
||||||
|
$: modelFilter = filters.model;
|
||||||
|
$: updateMakes(modelFilter);
|
||||||
|
$: updateModels(makeFilter);
|
||||||
|
|
||||||
|
async function updateMakes(model?: string) {
|
||||||
|
makes = await getSearchSuggestions({
|
||||||
|
$type: SearchSuggestionType.CameraMake,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateModels(make?: string) {
|
||||||
|
models = await getSearchSuggestions({
|
||||||
|
$type: SearchSuggestionType.CameraModel,
|
||||||
|
make,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="camera-selection">
|
||||||
|
<p class="immich-form-label">CAMERA</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||||
|
<div class="w-full">
|
||||||
|
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
||||||
|
<Combobox
|
||||||
|
id="search-camera-make"
|
||||||
|
options={toComboBoxOptions(makes)}
|
||||||
|
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
|
||||||
|
on:select={({ detail }) => (filters.make = detail?.value)}
|
||||||
|
placeholder="Search camera make..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
||||||
|
<Combobox
|
||||||
|
id="search-camera-model"
|
||||||
|
options={toComboBoxOptions(models)}
|
||||||
|
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
|
||||||
|
on:select={({ detail }) => (filters.model = detail?.value)}
|
||||||
|
placeholder="Search camera model..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface SearchDateFilter {
|
||||||
|
takenBefore?: string;
|
||||||
|
takenAfter?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let filters: SearchDateFilter;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="date-range-selection" class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5">
|
||||||
|
<label class="immich-form-label" for="start-date">
|
||||||
|
<span>START DATE</span>
|
||||||
|
<input
|
||||||
|
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
||||||
|
type="date"
|
||||||
|
id="start-date"
|
||||||
|
name="start-date"
|
||||||
|
max={filters.takenBefore}
|
||||||
|
bind:value={filters.takenAfter}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="immich-form-label" for="end-date">
|
||||||
|
<span>END DATE</span>
|
||||||
|
<input
|
||||||
|
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
||||||
|
type="date"
|
||||||
|
id="end-date"
|
||||||
|
name="end-date"
|
||||||
|
placeholder=""
|
||||||
|
min={filters.takenAfter}
|
||||||
|
bind:value={filters.takenBefore}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface SearchDisplayFilters {
|
||||||
|
isNotInAlbum?: boolean;
|
||||||
|
isArchive?: boolean;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export let filters: SearchDisplayFilters;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="display-options-selection" class="text-sm">
|
||||||
|
<p class="immich-form-label">DISPLAY OPTIONS</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isNotInAlbum} />
|
||||||
|
<span class="pt-1">Not in any album</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isArchive} />
|
||||||
|
<span class="pt-1">Archive</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isFavorite} />
|
||||||
|
<span class="pt-1">Favorite</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,242 +1,97 @@
|
||||||
<script lang="ts">
|
<script lang="ts" context="module">
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import type { SearchLocationFilter } from './search-location-section.svelte';
|
||||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
import type { SearchDisplayFilters } from './search-display-section.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import type { SearchDateFilter } from './search-date-section.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import {
|
|
||||||
AssetTypeEnum,
|
|
||||||
SearchSuggestionType,
|
|
||||||
type PersonResponseDto,
|
|
||||||
type SmartSearchDto,
|
|
||||||
type MetadataSearchDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { getAllPeople, getSearchSuggestions } from '@immich/sdk';
|
|
||||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
|
|
||||||
import { parseUtcDate } from '$lib/utils/date-time';
|
|
||||||
|
|
||||||
enum MediaType {
|
export enum MediaType {
|
||||||
All = 'all',
|
All = 'all',
|
||||||
Image = 'image',
|
Image = 'image',
|
||||||
Video = 'video',
|
Video = 'video',
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchSuggestion = {
|
export type SearchFilter = {
|
||||||
people: PersonResponseDto[];
|
|
||||||
country: ComboBoxOption[];
|
|
||||||
state: ComboBoxOption[];
|
|
||||||
city: ComboBoxOption[];
|
|
||||||
make: ComboBoxOption[];
|
|
||||||
model: ComboBoxOption[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchParams = {
|
|
||||||
state?: string;
|
|
||||||
country?: string;
|
|
||||||
city?: string;
|
|
||||||
cameraMake?: string;
|
|
||||||
cameraModel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchFilter = {
|
|
||||||
context?: string;
|
context?: string;
|
||||||
people: (PersonResponseDto | Pick<PersonResponseDto, 'id'>)[];
|
personIds: Set<string>;
|
||||||
|
location: SearchLocationFilter;
|
||||||
location: {
|
camera: SearchCameraFilter;
|
||||||
country?: ComboBoxOption;
|
date: SearchDateFilter;
|
||||||
state?: ComboBoxOption;
|
display: SearchDisplayFilters;
|
||||||
city?: ComboBoxOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
camera: {
|
|
||||||
make?: ComboBoxOption;
|
|
||||||
model?: ComboBoxOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
date: {
|
|
||||||
takenAfter?: string;
|
|
||||||
takenBefore?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
isArchive?: boolean;
|
|
||||||
isFavorite?: boolean;
|
|
||||||
isNotInAlbum?: boolean;
|
|
||||||
|
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import SearchPeopleSection from './search-people-section.svelte';
|
||||||
|
import SearchLocationSection from './search-location-section.svelte';
|
||||||
|
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
|
||||||
|
import SearchDateSection from './search-date-section.svelte';
|
||||||
|
import SearchMediaSection from './search-media-section.svelte';
|
||||||
|
import { parseUtcDate } from '$lib/utils/date-time';
|
||||||
|
import SearchDisplaySection from './search-display-section.svelte';
|
||||||
|
|
||||||
export let searchQuery: MetadataSearchDto | SmartSearchDto;
|
export let searchQuery: MetadataSearchDto | SmartSearchDto;
|
||||||
|
|
||||||
let suggestions: SearchSuggestion = {
|
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
||||||
people: [],
|
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||||
country: [],
|
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
|
||||||
state: [],
|
|
||||||
city: [],
|
|
||||||
make: [],
|
|
||||||
model: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter: SearchFilter = {
|
let filter: SearchFilter = {
|
||||||
context: undefined,
|
context: 'query' in searchQuery ? searchQuery.query : '',
|
||||||
people: [],
|
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||||
location: {
|
location: {
|
||||||
country: undefined,
|
country: searchQuery.country,
|
||||||
state: undefined,
|
state: searchQuery.state,
|
||||||
city: undefined,
|
city: searchQuery.city,
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
make: undefined,
|
make: searchQuery.make,
|
||||||
model: undefined,
|
model: searchQuery.model,
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
takenAfter: undefined,
|
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
||||||
takenBefore: undefined,
|
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
|
||||||
},
|
},
|
||||||
isArchive: undefined,
|
display: {
|
||||||
isFavorite: undefined,
|
isArchive: searchQuery.isArchived,
|
||||||
isNotInAlbum: undefined,
|
isFavorite: searchQuery.isFavorite,
|
||||||
mediaType: MediaType.All,
|
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
|
||||||
|
},
|
||||||
|
mediaType:
|
||||||
|
searchQuery.type === AssetTypeEnum.Image
|
||||||
|
? MediaType.Image
|
||||||
|
: searchQuery.type === AssetTypeEnum.Video
|
||||||
|
? MediaType.Video
|
||||||
|
: MediaType.All,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
|
|
||||||
let showAllPeople = false;
|
|
||||||
|
|
||||||
let filterBoxWidth = 0;
|
let filterBoxWidth = 0;
|
||||||
$: numberOfPeople = (filterBoxWidth - 80) / 85;
|
|
||||||
$: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, numberOfPeople);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
getPeople();
|
|
||||||
populateExistingFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
function orderBySelectedPeopleFirst<T extends Pick<PersonResponseDto, 'id'>>(people: T[]) {
|
|
||||||
return people.sort((a, _) => {
|
|
||||||
if (filter.people.some((p) => p.id === a.id)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPeople = async () => {
|
|
||||||
try {
|
|
||||||
const { people } = await getAllPeople({ withHidden: false });
|
|
||||||
suggestions.people = orderBySelectedPeopleFirst(people);
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, 'Failed to get people');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePeopleSelection = (id: string) => {
|
|
||||||
if (filter.people.some((p) => p.id === id)) {
|
|
||||||
filter.people = filter.people.filter((p) => p.id !== id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const person = suggestions.people.find((p) => p.id === id);
|
|
||||||
if (person) {
|
|
||||||
filter.people = [...filter.people, person];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSuggestion = async (type: SearchSuggestionType, params: SearchParams) => {
|
|
||||||
if (
|
|
||||||
type === SearchSuggestionType.City ||
|
|
||||||
type === SearchSuggestionType.State ||
|
|
||||||
type === SearchSuggestionType.Country
|
|
||||||
) {
|
|
||||||
suggestions = { ...suggestions, city: [], state: [], country: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) {
|
|
||||||
suggestions = { ...suggestions, make: [], model: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await getSearchSuggestions({
|
|
||||||
$type: type,
|
|
||||||
country: params.country,
|
|
||||||
state: params.state,
|
|
||||||
make: params.cameraMake,
|
|
||||||
model: params.cameraModel,
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case SearchSuggestionType.Country: {
|
|
||||||
for (const country of data) {
|
|
||||||
suggestions.country = [...suggestions.country, { label: country, value: country }];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case SearchSuggestionType.State: {
|
|
||||||
for (const state of data) {
|
|
||||||
suggestions.state = [...suggestions.state, { label: state, value: state }];
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case SearchSuggestionType.City: {
|
|
||||||
for (const city of data) {
|
|
||||||
suggestions.city = [...suggestions.city, { label: city, value: city }];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case SearchSuggestionType.CameraMake: {
|
|
||||||
for (const make of data) {
|
|
||||||
suggestions.make = [...suggestions.make, { label: make, value: make }];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case SearchSuggestionType.CameraModel: {
|
|
||||||
for (const model of data) {
|
|
||||||
suggestions.model = [...suggestions.model, { label: model, value: model }];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, 'Failed to get search suggestions');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
filter = {
|
filter = {
|
||||||
context: undefined,
|
personIds: new Set(),
|
||||||
people: [],
|
location: {},
|
||||||
location: {
|
camera: {},
|
||||||
country: undefined,
|
date: {},
|
||||||
state: undefined,
|
display: {},
|
||||||
city: undefined,
|
|
||||||
},
|
|
||||||
camera: {
|
|
||||||
make: undefined,
|
|
||||||
model: undefined,
|
|
||||||
},
|
|
||||||
date: {
|
|
||||||
takenAfter: undefined,
|
|
||||||
takenBefore: undefined,
|
|
||||||
},
|
|
||||||
isArchive: undefined,
|
|
||||||
isFavorite: undefined,
|
|
||||||
isNotInAlbum: undefined,
|
|
||||||
mediaType: MediaType.All,
|
mediaType: MediaType.All,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
|
||||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
let type: AssetTypeEnum | undefined = undefined;
|
if (filter.context && filter.personIds.size > 0) {
|
||||||
|
handleError(
|
||||||
|
new Error('Context search does not support people filter'),
|
||||||
|
'Context search does not support people filter',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let type: AssetTypeEnum | undefined = undefined;
|
||||||
if (filter.mediaType === MediaType.Image) {
|
if (filter.mediaType === MediaType.Image) {
|
||||||
type = AssetTypeEnum.Image;
|
type = AssetTypeEnum.Image;
|
||||||
} else if (filter.mediaType === MediaType.Video) {
|
} else if (filter.mediaType === MediaType.Video) {
|
||||||
|
@ -244,70 +99,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: SmartSearchDto | MetadataSearchDto = {
|
let payload: SmartSearchDto | MetadataSearchDto = {
|
||||||
country: filter.location.country?.value,
|
query: filter.context || undefined,
|
||||||
state: filter.location.state?.value,
|
country: filter.location.country,
|
||||||
city: filter.location.city?.value,
|
state: filter.location.state,
|
||||||
make: filter.camera.make?.value,
|
city: filter.location.city,
|
||||||
model: filter.camera.model?.value,
|
make: filter.camera.make,
|
||||||
|
model: filter.camera.model,
|
||||||
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
|
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
|
||||||
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
|
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
|
||||||
isArchived: filter.isArchive || undefined,
|
isArchived: filter.display.isArchive || undefined,
|
||||||
isFavorite: filter.isFavorite || undefined,
|
isFavorite: filter.display.isFavorite || undefined,
|
||||||
isNotInAlbum: filter.isNotInAlbum || undefined,
|
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
||||||
personIds: filter.people && filter.people.length > 0 ? filter.people.map((p) => p.id) : undefined,
|
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
|
||||||
type,
|
type,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filter.context) {
|
|
||||||
if (payload.personIds && payload.personIds.length > 0) {
|
|
||||||
handleError(
|
|
||||||
new Error('Context search does not support people filter'),
|
|
||||||
'Context search does not support people filter',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
...payload,
|
|
||||||
query: filter.context,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch('search', payload);
|
dispatch('search', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
function populateExistingFilters() {
|
|
||||||
if (searchQuery) {
|
|
||||||
const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : [];
|
|
||||||
|
|
||||||
filter = {
|
|
||||||
context: 'query' in searchQuery ? searchQuery.query : '',
|
|
||||||
people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))),
|
|
||||||
location: {
|
|
||||||
country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined,
|
|
||||||
state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined,
|
|
||||||
city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined,
|
|
||||||
},
|
|
||||||
camera: {
|
|
||||||
make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined,
|
|
||||||
model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined,
|
|
||||||
},
|
|
||||||
date: {
|
|
||||||
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
|
||||||
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
|
|
||||||
},
|
|
||||||
isArchive: searchQuery.isArchived,
|
|
||||||
isFavorite: searchQuery.isFavorite,
|
|
||||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
|
|
||||||
mediaType:
|
|
||||||
searchQuery.type === AssetTypeEnum.Image
|
|
||||||
? MediaType.Image
|
|
||||||
: searchQuery.type === AssetTypeEnum.Video
|
|
||||||
? MediaType.Video
|
|
||||||
: MediaType.All,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -323,55 +131,7 @@
|
||||||
>
|
>
|
||||||
<div class="px-4 sm:px-6 py-4 space-y-10 max-h-[calc(100dvh-12rem)] overflow-y-auto immich-scrollbar">
|
<div class="px-4 sm:px-6 py-4 space-y-10 max-h-[calc(100dvh-12rem)] overflow-y-auto immich-scrollbar">
|
||||||
<!-- PEOPLE -->
|
<!-- PEOPLE -->
|
||||||
{#if suggestions.people.length > 0}
|
<SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} />
|
||||||
<div id="people-selection" class="-mb-4">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<p class="immich-form-label">PEOPLE</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar">
|
|
||||||
{#each peopleList as person (person.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {filter.people.some(
|
|
||||||
(p) => p.id === person.id,
|
|
||||||
)
|
|
||||||
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
|
||||||
: ''}"
|
|
||||||
on:click={() => handlePeopleSelection(person.id)}
|
|
||||||
>
|
|
||||||
<ImageThumbnail
|
|
||||||
circle
|
|
||||||
shadow
|
|
||||||
url={getPeopleThumbnailUrl(person.id)}
|
|
||||||
altText={person.name}
|
|
||||||
widthStyle="100%"
|
|
||||||
/>
|
|
||||||
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showAllPeople || suggestions.people.length > peopleList.length}
|
|
||||||
<div class="flex justify-center mt-2">
|
|
||||||
<Button
|
|
||||||
shadow={false}
|
|
||||||
color="text-primary"
|
|
||||||
class="flex gap-2 place-items-center"
|
|
||||||
on:click={() => (showAllPeople = !showAllPeople)}
|
|
||||||
>
|
|
||||||
{#if showAllPeople}
|
|
||||||
<span><Icon path={mdiClose} /></span>
|
|
||||||
Collapse
|
|
||||||
{:else}
|
|
||||||
<span><Icon path={mdiArrowRight} /></span>
|
|
||||||
See all people
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- CONTEXT -->
|
<!-- CONTEXT -->
|
||||||
<div>
|
<div>
|
||||||
|
@ -389,173 +149,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LOCATION -->
|
<!-- LOCATION -->
|
||||||
<div id="location-selection">
|
<SearchLocationSection bind:filters={filter.location} />
|
||||||
<p class="immich-form-label">PLACE</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
|
||||||
<div class="w-full">
|
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
|
||||||
<Combobox
|
|
||||||
id="search-place-country"
|
|
||||||
options={suggestions.country}
|
|
||||||
bind:selectedOption={filter.location.country}
|
|
||||||
placeholder="Search country..."
|
|
||||||
on:click={() => updateSuggestion(SearchSuggestionType.Country, {})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
|
||||||
<Combobox
|
|
||||||
id="search-place-state"
|
|
||||||
options={suggestions.state}
|
|
||||||
bind:selectedOption={filter.location.state}
|
|
||||||
placeholder="Search state..."
|
|
||||||
on:click={() => updateSuggestion(SearchSuggestionType.State, { country: filter.location.country?.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
|
||||||
<Combobox
|
|
||||||
id="search-place-city"
|
|
||||||
options={suggestions.city}
|
|
||||||
bind:selectedOption={filter.location.city}
|
|
||||||
placeholder="Search city..."
|
|
||||||
on:click={() =>
|
|
||||||
updateSuggestion(SearchSuggestionType.City, {
|
|
||||||
country: filter.location.country?.value,
|
|
||||||
state: filter.location.state?.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CAMERA MODEL -->
|
<!-- CAMERA MODEL -->
|
||||||
<div id="camera-selection">
|
<SearchCameraSection bind:filters={filter.camera} />
|
||||||
<p class="immich-form-label">CAMERA</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
|
||||||
<div class="w-full">
|
|
||||||
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
|
|
||||||
<Combobox
|
|
||||||
id="search-camera-make"
|
|
||||||
options={suggestions.make}
|
|
||||||
bind:selectedOption={filter.camera.make}
|
|
||||||
placeholder="Search camera make..."
|
|
||||||
on:click={() =>
|
|
||||||
updateSuggestion(SearchSuggestionType.CameraMake, { cameraModel: filter.camera.model?.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
|
|
||||||
<Combobox
|
|
||||||
id="search-camera-model"
|
|
||||||
options={suggestions.model}
|
|
||||||
bind:selectedOption={filter.camera.model}
|
|
||||||
placeholder="Search camera model..."
|
|
||||||
on:click={() =>
|
|
||||||
updateSuggestion(SearchSuggestionType.CameraModel, { cameraMake: filter.camera.make?.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DATE RANGE -->
|
<!-- DATE RANGE -->
|
||||||
<div id="date-range-selection" class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5">
|
<SearchDateSection bind:filters={filter.date} />
|
||||||
<label class="immich-form-label" for="start-date">
|
|
||||||
<span>START DATE</span>
|
|
||||||
<input
|
|
||||||
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
|
||||||
type="date"
|
|
||||||
id="start-date"
|
|
||||||
name="start-date"
|
|
||||||
max={filter.date.takenBefore}
|
|
||||||
bind:value={filter.date.takenAfter}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="immich-form-label" for="end-date">
|
|
||||||
<span>END DATE</span>
|
|
||||||
<input
|
|
||||||
class="immich-form-input w-full mt-1 hover:cursor-pointer"
|
|
||||||
type="date"
|
|
||||||
id="end-date"
|
|
||||||
name="end-date"
|
|
||||||
placeholder=""
|
|
||||||
min={filter.date.takenAfter}
|
|
||||||
bind:value={filter.date.takenBefore}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-x-5 gap-y-8">
|
<div class="grid md:grid-cols-2 gap-x-5 gap-y-8">
|
||||||
<!-- MEDIA TYPE -->
|
<!-- MEDIA TYPE -->
|
||||||
<div id="media-type-selection">
|
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
|
||||||
<p class="immich-form-label">MEDIA TYPE</p>
|
|
||||||
|
|
||||||
<div class="flex gap-5 mt-1 text-base">
|
|
||||||
<label for="type-all" class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
bind:group={filter.mediaType}
|
|
||||||
value={MediaType.All}
|
|
||||||
type="radio"
|
|
||||||
name="radio-type"
|
|
||||||
id="type-all"
|
|
||||||
class="size-4"
|
|
||||||
/>
|
|
||||||
<span class="pt-0.5">All</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label for="type-image" class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
bind:group={filter.mediaType}
|
|
||||||
value={MediaType.Image}
|
|
||||||
type="radio"
|
|
||||||
name="media-type"
|
|
||||||
id="type-image"
|
|
||||||
class="size-4"
|
|
||||||
/>
|
|
||||||
<span class="pt-0.5">Image</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label for="type-video" class="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
bind:group={filter.mediaType}
|
|
||||||
value={MediaType.Video}
|
|
||||||
type="radio"
|
|
||||||
name="radio-type"
|
|
||||||
id="type-video"
|
|
||||||
class="size-4"
|
|
||||||
/>
|
|
||||||
<span class="pt-0.5">Video</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DISPLAY OPTIONS -->
|
<!-- DISPLAY OPTIONS -->
|
||||||
<div id="display-options-selection" class="text-sm">
|
<SearchDisplaySection bind:filters={filter.display} />
|
||||||
<p class="immich-form-label">DISPLAY OPTIONS</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filter.isNotInAlbum} />
|
|
||||||
<span class="pt-1">Not in any album</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filter.isArchive} />
|
|
||||||
<span class="pt-1">Archive</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filter.isFavorite} />
|
|
||||||
<span class="pt-1">Favorite</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface SearchLocationFilter {
|
||||||
|
country?: string;
|
||||||
|
state?: string;
|
||||||
|
city?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||||
|
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
|
||||||
|
|
||||||
|
export let filters: SearchLocationFilter;
|
||||||
|
|
||||||
|
let countries: string[] = [];
|
||||||
|
let states: string[] = [];
|
||||||
|
let cities: string[] = [];
|
||||||
|
|
||||||
|
$: countryFilter = filters.country;
|
||||||
|
$: stateFilter = filters.state;
|
||||||
|
$: updateCountries();
|
||||||
|
$: updateStates(countryFilter);
|
||||||
|
$: updateCities(countryFilter, stateFilter);
|
||||||
|
|
||||||
|
async function updateCountries() {
|
||||||
|
countries = await getSearchSuggestions({
|
||||||
|
$type: SearchSuggestionType.Country,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters.country && !countries.includes(filters.country)) {
|
||||||
|
filters.country = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStates(country?: string) {
|
||||||
|
states = await getSearchSuggestions({
|
||||||
|
$type: SearchSuggestionType.State,
|
||||||
|
country,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters.state && !states.includes(filters.state)) {
|
||||||
|
filters.state = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCities(country?: string, state?: string) {
|
||||||
|
cities = await getSearchSuggestions({
|
||||||
|
$type: SearchSuggestionType.City,
|
||||||
|
country,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters.city && !cities.includes(filters.city)) {
|
||||||
|
filters.city = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="location-selection">
|
||||||
|
<p class="immich-form-label">PLACE</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
|
||||||
|
<div class="w-full">
|
||||||
|
<label class="text-sm text-black dark:text-white" for="search-place-country">Country</label>
|
||||||
|
<Combobox
|
||||||
|
id="search-place-country"
|
||||||
|
options={toComboBoxOptions(countries)}
|
||||||
|
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
|
||||||
|
on:select={({ detail }) => (filters.country = detail?.value)}
|
||||||
|
placeholder="Search country..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<label class="text-sm text-black dark:text-white" for="search-place-state">State</label>
|
||||||
|
<Combobox
|
||||||
|
id="search-place-state"
|
||||||
|
options={toComboBoxOptions(states)}
|
||||||
|
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
|
||||||
|
on:select={({ detail }) => (filters.state = detail?.value)}
|
||||||
|
placeholder="Search state..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<label class="text-sm text-black dark:text-white" for="search-place-city">City</label>
|
||||||
|
<Combobox
|
||||||
|
id="search-place-city"
|
||||||
|
options={toComboBoxOptions(cities)}
|
||||||
|
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
|
||||||
|
on:select={({ detail }) => (filters.city = detail?.value)}
|
||||||
|
placeholder="Search city..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,47 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { MediaType } from './search-filter-box.svelte';
|
||||||
|
|
||||||
|
export let filteredMedia: MediaType;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="media-type-selection">
|
||||||
|
<p class="immich-form-label">MEDIA TYPE</p>
|
||||||
|
|
||||||
|
<div class="flex gap-5 mt-1 text-base">
|
||||||
|
<label for="type-all" class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
bind:group={filteredMedia}
|
||||||
|
value={MediaType.All}
|
||||||
|
type="radio"
|
||||||
|
name="radio-type"
|
||||||
|
id="type-all"
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
<span class="pt-0.5">All</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="type-image" class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
bind:group={filteredMedia}
|
||||||
|
value={MediaType.Image}
|
||||||
|
type="radio"
|
||||||
|
name="media-type"
|
||||||
|
id="type-image"
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
<span class="pt-0.5">Image</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="type-video" class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
bind:group={filteredMedia}
|
||||||
|
value={MediaType.Video}
|
||||||
|
type="radio"
|
||||||
|
name="radio-type"
|
||||||
|
id="type-video"
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
<span class="pt-0.5">Video</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
|
import { mdiClose, mdiArrowRight } from '@mdi/js';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
|
||||||
|
export let width: number;
|
||||||
|
export let selectedPeople: Set<string>;
|
||||||
|
|
||||||
|
let peoplePromise = getPeople();
|
||||||
|
let showAllPeople = false;
|
||||||
|
$: numberOfPeople = (width - 80) / 85;
|
||||||
|
|
||||||
|
function orderBySelectedPeopleFirst(people: PersonResponseDto[]) {
|
||||||
|
return [
|
||||||
|
...people.filter((p) => selectedPeople.has(p.id)), //
|
||||||
|
...people.filter((p) => !selectedPeople.has(p.id)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPeople() {
|
||||||
|
try {
|
||||||
|
const res = await getAllPeople({ withHidden: false });
|
||||||
|
return orderBySelectedPeopleFirst(res.people);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Failed to get people');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePersonSelection(id: string) {
|
||||||
|
if (selectedPeople.has(id)) {
|
||||||
|
selectedPeople.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedPeople.add(id);
|
||||||
|
}
|
||||||
|
selectedPeople = selectedPeople;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await peoplePromise then people}
|
||||||
|
{#if people && people.length > 0}
|
||||||
|
{@const peopleList = showAllPeople ? people : people.slice(0, numberOfPeople)}
|
||||||
|
|
||||||
|
<div id="people-selection" class="-mb-4">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<p class="immich-form-label">PEOPLE</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar">
|
||||||
|
{#each peopleList as person (person.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
|
||||||
|
person.id,
|
||||||
|
)
|
||||||
|
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
||||||
|
: ''}"
|
||||||
|
on:click={() => togglePersonSelection(person.id)}
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
circle
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(person.id)}
|
||||||
|
altText={person.name}
|
||||||
|
widthStyle="100%"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAllPeople || people.length > peopleList.length}
|
||||||
|
<div class="flex justify-center mt-2">
|
||||||
|
<Button
|
||||||
|
shadow={false}
|
||||||
|
color="text-primary"
|
||||||
|
class="flex gap-2 place-items-center"
|
||||||
|
on:click={() => (showAllPeople = !showAllPeople)}
|
||||||
|
>
|
||||||
|
{#if showAllPeople}
|
||||||
|
<span><Icon path={mdiClose} /></span>
|
||||||
|
Collapse
|
||||||
|
{:else}
|
||||||
|
<span><Icon path={mdiArrowRight} /></span>
|
||||||
|
See all people
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
Loading…
Add table
Reference in a new issue