mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05: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
					
				@ -195,26 +195,22 @@ export class SearchService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
 | 
			
		||||
    if (dto.type === SearchSuggestionType.COUNTRY) {
 | 
			
		||||
    switch (dto.type) {
 | 
			
		||||
      case SearchSuggestionType.COUNTRY: {
 | 
			
		||||
        return this.metadataRepository.getCountries(auth.user.id);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    if (dto.type === SearchSuggestionType.STATE) {
 | 
			
		||||
      case SearchSuggestionType.STATE: {
 | 
			
		||||
        return this.metadataRepository.getStates(auth.user.id, dto.country);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    if (dto.type === SearchSuggestionType.CITY) {
 | 
			
		||||
      case SearchSuggestionType.CITY: {
 | 
			
		||||
        return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
 | 
			
		||||
      case SearchSuggestionType.CAMERA_MAKE: {
 | 
			
		||||
        return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
 | 
			
		||||
      case SearchSuggestionType.CAMERA_MODEL: {
 | 
			
		||||
        return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    return [];
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,10 @@
 | 
			
		||||
    label: string;
 | 
			
		||||
    value: string;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export function toComboBoxOptions(items: string[]) {
 | 
			
		||||
    return items.map<ComboBoxOption>((item) => ({ label: item, value: item }));
 | 
			
		||||
  }
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<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,304 +1,66 @@
 | 
			
		||||
<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 { 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';
 | 
			
		||||
<script lang="ts" context="module">
 | 
			
		||||
  import type { SearchLocationFilter } from './search-location-section.svelte';
 | 
			
		||||
  import type { SearchDisplayFilters } from './search-display-section.svelte';
 | 
			
		||||
  import type { SearchDateFilter } from './search-date-section.svelte';
 | 
			
		||||
 | 
			
		||||
  enum MediaType {
 | 
			
		||||
  export enum MediaType {
 | 
			
		||||
    All = 'all',
 | 
			
		||||
    Image = 'image',
 | 
			
		||||
    Video = 'video',
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type SearchSuggestion = {
 | 
			
		||||
    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 = {
 | 
			
		||||
  export type SearchFilter = {
 | 
			
		||||
    context?: string;
 | 
			
		||||
    people: (PersonResponseDto | Pick<PersonResponseDto, 'id'>)[];
 | 
			
		||||
 | 
			
		||||
    location: {
 | 
			
		||||
      country?: ComboBoxOption;
 | 
			
		||||
      state?: ComboBoxOption;
 | 
			
		||||
      city?: ComboBoxOption;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    camera: {
 | 
			
		||||
      make?: ComboBoxOption;
 | 
			
		||||
      model?: ComboBoxOption;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    date: {
 | 
			
		||||
      takenAfter?: string;
 | 
			
		||||
      takenBefore?: string;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    isArchive?: boolean;
 | 
			
		||||
    isFavorite?: boolean;
 | 
			
		||||
    isNotInAlbum?: boolean;
 | 
			
		||||
 | 
			
		||||
    personIds: Set<string>;
 | 
			
		||||
    location: SearchLocationFilter;
 | 
			
		||||
    camera: SearchCameraFilter;
 | 
			
		||||
    date: SearchDateFilter;
 | 
			
		||||
    display: SearchDisplayFilters;
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
  let suggestions: SearchSuggestion = {
 | 
			
		||||
    people: [],
 | 
			
		||||
    country: [],
 | 
			
		||||
    state: [],
 | 
			
		||||
    city: [],
 | 
			
		||||
    make: [],
 | 
			
		||||
    model: [],
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let filter: SearchFilter = {
 | 
			
		||||
    context: undefined,
 | 
			
		||||
    people: [],
 | 
			
		||||
    location: {
 | 
			
		||||
      country: undefined,
 | 
			
		||||
      state: undefined,
 | 
			
		||||
      city: undefined,
 | 
			
		||||
    },
 | 
			
		||||
    camera: {
 | 
			
		||||
      make: undefined,
 | 
			
		||||
      model: undefined,
 | 
			
		||||
    },
 | 
			
		||||
    date: {
 | 
			
		||||
      takenAfter: undefined,
 | 
			
		||||
      takenBefore: undefined,
 | 
			
		||||
    },
 | 
			
		||||
    isArchive: undefined,
 | 
			
		||||
    isFavorite: undefined,
 | 
			
		||||
    isNotInAlbum: undefined,
 | 
			
		||||
    mediaType: MediaType.All,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
 | 
			
		||||
  let showAllPeople = false;
 | 
			
		||||
 | 
			
		||||
  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 = () => {
 | 
			
		||||
    filter = {
 | 
			
		||||
      context: undefined,
 | 
			
		||||
      people: [],
 | 
			
		||||
      location: {
 | 
			
		||||
        country: undefined,
 | 
			
		||||
        state: undefined,
 | 
			
		||||
        city: undefined,
 | 
			
		||||
      },
 | 
			
		||||
      camera: {
 | 
			
		||||
        make: undefined,
 | 
			
		||||
        model: undefined,
 | 
			
		||||
      },
 | 
			
		||||
      date: {
 | 
			
		||||
        takenAfter: undefined,
 | 
			
		||||
        takenBefore: undefined,
 | 
			
		||||
      },
 | 
			
		||||
      isArchive: undefined,
 | 
			
		||||
      isFavorite: undefined,
 | 
			
		||||
      isNotInAlbum: undefined,
 | 
			
		||||
      mediaType: MediaType.All,
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
 | 
			
		||||
  const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
 | 
			
		||||
  const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
 | 
			
		||||
 | 
			
		||||
  const search = async () => {
 | 
			
		||||
    let type: AssetTypeEnum | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
    if (filter.mediaType === MediaType.Image) {
 | 
			
		||||
      type = AssetTypeEnum.Image;
 | 
			
		||||
    } else if (filter.mediaType === MediaType.Video) {
 | 
			
		||||
      type = AssetTypeEnum.Video;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let payload: SmartSearchDto | MetadataSearchDto = {
 | 
			
		||||
      country: filter.location.country?.value,
 | 
			
		||||
      state: filter.location.state?.value,
 | 
			
		||||
      city: filter.location.city?.value,
 | 
			
		||||
      make: filter.camera.make?.value,
 | 
			
		||||
      model: filter.camera.model?.value,
 | 
			
		||||
      takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
 | 
			
		||||
      takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
 | 
			
		||||
      isArchived: filter.isArchive || undefined,
 | 
			
		||||
      isFavorite: filter.isFavorite || undefined,
 | 
			
		||||
      isNotInAlbum: filter.isNotInAlbum || undefined,
 | 
			
		||||
      personIds: filter.people && filter.people.length > 0 ? filter.people.map((p) => p.id) : undefined,
 | 
			
		||||
      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);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  function populateExistingFilters() {
 | 
			
		||||
    if (searchQuery) {
 | 
			
		||||
      const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : [];
 | 
			
		||||
 | 
			
		||||
      filter = {
 | 
			
		||||
  let filter: SearchFilter = {
 | 
			
		||||
    context: 'query' in searchQuery ? searchQuery.query : '',
 | 
			
		||||
        people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))),
 | 
			
		||||
    personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
 | 
			
		||||
    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,
 | 
			
		||||
      country: searchQuery.country,
 | 
			
		||||
      state: searchQuery.state,
 | 
			
		||||
      city: searchQuery.city,
 | 
			
		||||
    },
 | 
			
		||||
    camera: {
 | 
			
		||||
          make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined,
 | 
			
		||||
          model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined,
 | 
			
		||||
      make: searchQuery.make,
 | 
			
		||||
      model: searchQuery.model,
 | 
			
		||||
    },
 | 
			
		||||
    date: {
 | 
			
		||||
      takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
 | 
			
		||||
      takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
 | 
			
		||||
    },
 | 
			
		||||
    display: {
 | 
			
		||||
      isArchive: searchQuery.isArchived,
 | 
			
		||||
      isFavorite: searchQuery.isFavorite,
 | 
			
		||||
      isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
 | 
			
		||||
    },
 | 
			
		||||
    mediaType:
 | 
			
		||||
      searchQuery.type === AssetTypeEnum.Image
 | 
			
		||||
        ? MediaType.Image
 | 
			
		||||
@ -306,8 +68,54 @@
 | 
			
		||||
          ? MediaType.Video
 | 
			
		||||
          : MediaType.All,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let filterBoxWidth = 0;
 | 
			
		||||
 | 
			
		||||
  const resetForm = () => {
 | 
			
		||||
    filter = {
 | 
			
		||||
      personIds: new Set(),
 | 
			
		||||
      location: {},
 | 
			
		||||
      camera: {},
 | 
			
		||||
      date: {},
 | 
			
		||||
      display: {},
 | 
			
		||||
      mediaType: MediaType.All,
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const search = async () => {
 | 
			
		||||
    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) {
 | 
			
		||||
      type = AssetTypeEnum.Image;
 | 
			
		||||
    } else if (filter.mediaType === MediaType.Video) {
 | 
			
		||||
      type = AssetTypeEnum.Video;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let payload: SmartSearchDto | MetadataSearchDto = {
 | 
			
		||||
      query: filter.context || undefined,
 | 
			
		||||
      country: filter.location.country,
 | 
			
		||||
      state: filter.location.state,
 | 
			
		||||
      city: filter.location.city,
 | 
			
		||||
      make: filter.camera.make,
 | 
			
		||||
      model: filter.camera.model,
 | 
			
		||||
      takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
 | 
			
		||||
      takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
 | 
			
		||||
      isArchived: filter.display.isArchive || undefined,
 | 
			
		||||
      isFavorite: filter.display.isFavorite || undefined,
 | 
			
		||||
      isNotInAlbum: filter.display.isNotInAlbum || undefined,
 | 
			
		||||
      personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
 | 
			
		||||
      type,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    dispatch('search', payload);
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<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">
 | 
			
		||||
      <!-- PEOPLE -->
 | 
			
		||||
      {#if suggestions.people.length > 0}
 | 
			
		||||
        <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}
 | 
			
		||||
      <SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} />
 | 
			
		||||
 | 
			
		||||
      <!-- CONTEXT -->
 | 
			
		||||
      <div>
 | 
			
		||||
@ -389,173 +149,20 @@
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <!-- LOCATION -->
 | 
			
		||||
      <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={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>
 | 
			
		||||
      <SearchLocationSection bind:filters={filter.location} />
 | 
			
		||||
 | 
			
		||||
      <!-- CAMERA MODEL -->
 | 
			
		||||
      <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={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>
 | 
			
		||||
      <SearchCameraSection bind:filters={filter.camera} />
 | 
			
		||||
 | 
			
		||||
      <!-- DATE RANGE -->
 | 
			
		||||
      <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={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>
 | 
			
		||||
      <SearchDateSection bind:filters={filter.date} />
 | 
			
		||||
 | 
			
		||||
      <div class="grid md:grid-cols-2 gap-x-5 gap-y-8">
 | 
			
		||||
        <!-- MEDIA TYPE -->
 | 
			
		||||
        <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={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>
 | 
			
		||||
        <SearchMediaSection bind:filteredMedia={filter.mediaType} />
 | 
			
		||||
 | 
			
		||||
        <!-- DISPLAY OPTIONS -->
 | 
			
		||||
        <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={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>
 | 
			
		||||
        <SearchDisplaySection bind:filters={filter.display} />
 | 
			
		||||
      </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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user