feat(web,server): explore (#1926)

* feat: explore

* chore: generate open api

* styling explore page

* styling no result page

* style overlay

* style: bluring text on thumbnail card for readability

* explore page tweaks

* fix(web): search urls

* feat(web): use objects for things

* feat(server): filter by motion, sort by createdAt

* More styling

* better navigation

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
Jason Rasmussen
2023-03-05 15:44:31 -05:00
committed by GitHub
parent 1f631eafce
commit 2ca560ebf8
35 changed files with 1079 additions and 63 deletions

View File

@@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto {
*/
'enabled': boolean;
}
/**
*
* @export
* @interface SearchExploreItem
*/
export interface SearchExploreItem {
/**
*
* @type {string}
* @memberof SearchExploreItem
*/
'value': string;
/**
*
* @type {AssetResponseDto}
* @memberof SearchExploreItem
*/
'data': AssetResponseDto;
}
/**
*
* @export
* @interface SearchExploreResponseDto
*/
export interface SearchExploreResponseDto {
/**
*
* @type {string}
* @memberof SearchExploreResponseDto
*/
'fieldName': string;
/**
*
* @type {Array<SearchExploreItem>}
* @memberof SearchExploreResponseDto
*/
'items': Array<SearchExploreItem>;
}
/**
*
* @export
@@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI {
*/
export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getExploreData: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search/explore`;
// 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 bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
// authentication cookie required
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
@@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
}
if (recent !== undefined) {
localVarQueryParameter['recent'] = recent;
}
if (motion !== undefined) {
localVarQueryParameter['motion'] = motion;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
export const SearchApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<SearchExploreResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) {
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options);
async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) {
export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = SearchApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getExploreData(options?: any): AxiosPromise<Array<SearchExploreResponseDto>> {
return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath));
search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
},
};
};
@@ -6833,6 +6937,16 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @extends {BaseAPI}
*/
export class SearchApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public getExploreData(options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
@@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI {
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {boolean} [recent]
* @param {boolean} [motion]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath));
public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@@ -19,6 +19,7 @@
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let disabled = false;
export let readonly = false;
export let publicSharedKey = '';
export let isRoundedCorner = false;
@@ -56,6 +57,7 @@
};
const parseVideoDuration = (duration: string) => {
duration = duration || '0:00:00.00000';
const timePart = duration.split(':');
const hours = timePart[0];
const minutes = timePart[1];
@@ -118,7 +120,7 @@
} else if (disabled) {
return 'border-[20px] border-gray-300';
} else if (isRoundedCorner) {
return 'rounded-[20px]';
return 'rounded-lg';
} else {
return '';
}
@@ -157,7 +159,7 @@
on:click={thumbnailClickedHandler}
on:keydown={thumbnailClickedHandler}
>
{#if mouseOver || selected || disabled}
{#if (mouseOver || selected || disabled) && !readonly}
<div
in:fade={{ duration: 200 }}
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}

View File

@@ -4,6 +4,7 @@
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import Magnify from 'svelte-material-icons/Magnify.svelte';
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
import { AppRoute } from '../../../constants';
import LoadingSpinner from '../loading-spinner.svelte';
@@ -62,6 +63,18 @@
</svelte:fragment>
</SideBarButton>
</a>
<a
data-sveltekit-preload-data="hover"
data-sveltekit-noscroll
href={AppRoute.EXPLORE}
draggable="false"
>
<SideBarButton
title="Explore"
logo={Magnify}
isSelected={$page.route.id === '/(user)/explore'}
/>
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
<SideBarButton
title="Sharing"

View File

@@ -10,7 +10,7 @@ export enum AppRoute {
ALBUMS = '/albums',
FAVORITES = '/favorites',
PHOTOS = '/photos',
EXPLORE = '/explore',
SHARING = '/sharing',
AUTH_LOGIN = '/auth/login'
}

View File

@@ -0,0 +1,13 @@
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: items } = await locals.api.searchApi.getExploreData();
return { user, items };
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import { AppRoute } from '$lib/constants';
import { AssetTypeEnum, SearchExploreItem } from '@api';
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
import type { PageData } from './$types';
export let data: PageData;
enum Field {
CITY = 'exifInfo.city',
TAGS = 'smartInfo.tags',
OBJECTS = 'smartInfo.objects'
}
const MAX_ITEMS = 12;
let things: SearchExploreItem[] = [];
let places: SearchExploreItem[] = [];
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);
</script>
<section>
<NavigationBar user={data.user} shouldShowUploadButton={false} />
</section>
<section
class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg"
>
<SideBar />
<section class="overflow-y-auto relative immich-scrollbar">
<section
id="album-content"
class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
>
<!-- Main Section -->
<div class="px-4 flex justify-between place-items-center dark:text-immich-dark-fg">
<div>
<p class="font-medium">Explore</p>
</div>
</div>
<div class="my-4">
<hr class="dark:border-immich-dark-gray" />
</div>
<div class="mx-4 flex flex-col">
{#if places.length > 0}
<div class="mb-6 mt-2">
<div>
<p class="mb-4 dark:text-immich-dark-fg font-medium">Places</p>
</div>
<div class="flex flex-row flex-wrap gap-4">
{#each places as item}
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
<div class="filter brightness-75 rounded-xl overflow-hidden">
<ImmichThumbnail
isRoundedCorner={true}
thumbnailSize={156}
asset={item.data}
readonly={true}
/>
</div>
<span
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
>
{item.value}
</span>
</a>
{/each}
</div>
</div>
{/if}
{#if things.length > 0}
<div class="mb-6 mt-2">
<div>
<p class="mb-4 dark:text-immich-dark-fg font-medium">Things</p>
</div>
<div class="flex flex-row flex-wrap gap-4">
{#each things as item}
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
<div class="filter brightness-75 rounded-xl overflow-hidden">
<ImmichThumbnail
isRoundedCorner={true}
thumbnailSize={156}
asset={item.data}
readonly={true}
/>
</div>
<span
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
>
{item.value}
</span>
</a>
{/each}
</div>
</div>
{/if}
<hr class="dark:border-immich-dark-gray mb-4" />
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8"
>
<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
<p class="text-sm">YOUR ACTIVITY</p>
<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
<a
href={AppRoute.FAVORITES}
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
draggable="false"
>
<StarOutline size={24} />
<span>Favorites</span>
</a>
<a
href="/search?recent=true"
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
draggable="false"
>
<ClockOutline size={24} />
<span>Recently added</span>
</a>
</div>
</div>
<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
<p class="text-sm">CATEGORIES</p>
<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
<a
href="/search?type={AssetTypeEnum.Video}"
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
>
<PlayCircleOutline size={24} />
<span>Videos</span>
</a>
<div>
<a
href="/search?motion=true"
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
>
<MotionPlayOutline size={24} />
<span>Motion photos</span>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
</section>

View File

@@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => {
}
const term = url.searchParams.get('q') || undefined;
const { data: results } = await locals.api.searchApi.search(
term,
undefined,
@@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => {
undefined,
undefined,
undefined,
undefined,
undefined,
{ params: url.searchParams }
);
return { user, term, results };

View File

@@ -1,16 +1,34 @@
<script lang="ts">
import { page } from '$app/stores';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import type { PageData } from './$types';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import ImageOffOutline from 'svelte-material-icons/ImageOffOutline.svelte';
import { afterNavigate, goto } from '$app/navigation';
export let data: PageData;
const term = $page.url.searchParams.get('q') || data.term || '';
const term = $page.url.searchParams.get('q') || '';
let goBackRoute = '/explore';
afterNavigate((r) => {
if (r.from) {
goBackRoute = r.from.url.href;
}
});
</script>
<section>
<NavigationBar {term} user={data.user} shouldShowUploadButton={false} />
<ControlAppBar on:close-button-click={() => goto(goBackRoute)} backIcon={ArrowLeft}>
<svelte:fragment slot="leading">
<p class="text-xl capitalize">
Search
{#if term}
- {term}
{/if}
</p>
</svelte:fragment>
</ControlAppBar>
</section>
<section class="relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg">
@@ -19,8 +37,16 @@
id="search-content"
class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
>
{#if data.results?.assets?.items}
{#if data.results?.assets?.items.length != 0}
<GalleryViewer assets={data.results.assets.items} />
{:else}
<div class="w-full text-center dark:text-white ">
<div class="mt-60 flex flex-col place-content-center place-items-center">
<ImageOffOutline size="56" />
<p class="font-medium text-3xl mt-5">No results</p>
<p class="text-base font-normal">Try a synonym or more general keyword</p>
</div>
</div>
{/if}
</section>
</section>