1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-21 15:36:26 +02:00

feat(web): revamp places ()

* revamp places

* add english translations

* migrate places page and components to svelte 5

* fix lint

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Krassimir Valev 2025-02-06 21:54:01 +01:00 committed by GitHub
parent 45f7401513
commit 6aad9fae8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 445 additions and 39 deletions

View file

@ -768,8 +768,10 @@
"go_to_search": "Go to search",
"go_to_folder": "Go to folder",
"group_albums_by": "Group albums by...",
"group_country": "Group by country",
"group_no": "No grouping",
"group_owner": "Group by owner",
"group_places_by": "Group places by...",
"group_year": "Group by year",
"has_quota": "Has quota",
"hi_user": "Hi {name} ({email})",
@ -987,6 +989,7 @@
"pick_a_location": "Pick a location",
"place": "Place",
"places": "Places",
"places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
"play": "Play",
"play_memories": "Play memories",
"play_motion_photo": "Play Motion Photo",
@ -1278,6 +1281,7 @@
"unfavorite": "Unfavorite",
"unhide_person": "Unhide person",
"unknown": "Unknown",
"unknown_country": "Unknown Country",
"unknown_year": "Unknown Year",
"unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video",

View file

@ -26,12 +26,14 @@
controlable?: boolean;
hideTextOnSmallScreen?: boolean;
title?: string | undefined;
position?: 'bottom-left' | 'bottom-right';
onSelect: (option: T) => void;
onClickOutside?: () => void;
render?: (item: T) => string | RenderedOption;
}
let {
position = 'bottom-left',
class: className = '',
options,
selectedOption = $bindable(options[0]),
@ -76,9 +78,24 @@
};
let renderedSelectedOption = $derived(renderOption(selectedOption));
const getAlignClass = (position: 'bottom-left' | 'bottom-right') => {
switch (position) {
case 'bottom-left': {
return 'left-0';
}
case 'bottom-right': {
return 'right-0';
}
default: {
return '';
}
}
};
</script>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} class="relative">
<!-- BUTTON TITLE -->
<Button onclick={() => (showMenu = true)} fullWidth {title} variant="ghost" color="secondary" size="small">
{#if renderedSelectedOption?.icon}
@ -91,7 +108,9 @@
{#if showMenu}
<div
transition:fly={{ y: -30, duration: 250 }}
class="text-sm font-medium fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
class="text-sm font-medium absolute z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
position,
)}"
>
{#each options as option (option)}
{@const renderedOption = renderOption(option)}

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { placesViewSettings } from '$lib/stores/preferences.store';
import { type PlacesGroup, isPlacesGroupCollapsed, togglePlacesGroupCollapsing } from '$lib/utils/places-utils';
import { mdiChevronRight } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { t } from 'svelte-i18n';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
interface Props {
places: AssetResponseDto[];
group?: PlacesGroup | undefined;
}
let { places, group = undefined }: Props = $props();
let isCollapsed = $derived(!!group && isPlacesGroupCollapsed($placesViewSettings, group.id));
let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
</script>
{#if group}
<div class="grid">
<button
type="button"
onclick={() => togglePlacesGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed}
>
<Icon
path={mdiChevronRight}
size="24"
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<span class="ml-1.5">({$t('places_count', { values: { count: places.length } })})</span>
</button>
<hr class="dark:border-immich-dark-gray" />
</div>
{/if}
<div class="mt-4">
{#if !isCollapsed}
<div class="flex flex-row flex-wrap gap-4">
{#each places as item}
{@const city = item.exifInfo?.city}
<a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city })}" draggable="false">
<div
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-[156px] justify-center overflow-hidden rounded-xl brightness-75 filter"
>
<img
src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
alt={city}
class="object-cover w-[156px] h-[156px]"
/>
</div>
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
>
{city}
</span>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
import {
mdiFolderArrowUpOutline,
mdiFolderRemoveOutline,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
} from '@mdi/js';
import {
type PlacesGroupOptionMetadata,
findGroupOptionMetadata,
getSelectedPlacesGroupOption,
groupOptionsMetadata,
expandAllPlacesGroups,
collapseAllPlacesGroups,
} from '$lib/utils/places-utils';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
interface Props {
placesGroups: string[];
searchQuery: string;
}
let { placesGroups, searchQuery = $bindable() }: Props = $props();
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
$placesViewSettings.groupBy = id;
};
let groupIcon = $derived.by(() => {
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
});
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
[PlacesGroupBy.None]: $t('group_no'),
[PlacesGroupBy.Country]: $t('group_country'),
});
</script>
<!-- Search Places -->
<div class="hidden md:block h-10 xl:w-60 2xl:w-80">
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
</div>
<!-- Group Places -->
<Dropdown
position="bottom-right"
title={$t('group_places_by')}
options={Object.values(groupOptionsMetadata)}
selectedOption={selectedGroupOption}
onSelect={handleChangeGroupBy}
render={({ id, isDisabled }) => ({
title: placesGroupByNames[id],
icon: groupIcon,
disabled: isDisabled(),
})}
/>
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
<span in:fly={{ x: -50, duration: 250 }}>
<!-- Expand Countries Groups -->
<div class="hidden xl:flex gap-0">
<div class="block">
<IconButton
title={$t('expand_all')}
onclick={() => expandAllPlacesGroups()}
variant="ghost"
color="secondary"
shape="round"
icon={mdiUnfoldMoreHorizontal}
/>
</div>
<!-- Collapse Countries Groups -->
<div class="block">
<IconButton
title={$t('collapse_all')}
onclick={() => collapseAllPlacesGroups(placesGroups)}
variant="ghost"
color="secondary"
shape="round"
icon={mdiUnfoldLessHorizontal}
/>
</div>
</div>
</span>
{/if}

View file

@ -0,0 +1,121 @@
<script lang="ts">
import PlacesCardGroup from './places-card-group.svelte';
import { groupBy } from 'lodash-es';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiMapMarkerOff } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { PlacesGroupBy, type PlacesViewSettings } from '$lib/stores/preferences.store';
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
places?: AssetResponseDto[];
searchQuery?: string;
searchResultCount: number;
userSettings: PlacesViewSettings;
placesGroupIds?: string[];
}
let {
places = $bindable([]),
searchQuery = '',
searchResultCount = $bindable(0),
userSettings,
placesGroupIds = $bindable([]),
}: Props = $props();
interface PlacesGroupOption {
[option: string]: (places: AssetResponseDto[]) => PlacesGroup[];
}
const groupOptions: PlacesGroupOption = {
/** No grouping */
[PlacesGroupBy.None]: (places): PlacesGroup[] => {
return [
{
id: $t('places'),
name: $t('places'),
places,
},
];
},
/** Group by year */
[PlacesGroupBy.Country]: (places): PlacesGroup[] => {
const unknownCountry = $t('unknown_country');
const groupedByCountry = groupBy(places, (place) => {
return place.exifInfo?.country ?? unknownCountry;
});
const sortedByCountryName = Object.entries(groupedByCountry).sort(([a], [b]) => {
// We make sure empty albums stay at the end of the list
if (a === unknownCountry) {
return 1;
} else if (b === unknownCountry) {
return -1;
} else {
return a.localeCompare(b);
}
});
return sortedByCountryName.map(([country, places]) => ({
id: country,
name: country,
places,
}));
},
};
let filteredPlaces: AssetResponseDto[] = $state([]);
let groupedPlaces: PlacesGroup[] = $state([]);
let placesGroupOption: string = $state(PlacesGroupBy.None);
let hasPlaces = $derived(places.length > 0);
// Step 1: Filter using the given search query.
run(() => {
if (searchQuery) {
const searchQueryNormalized = normalizeSearchString(searchQuery);
filteredPlaces = places.filter((place) => {
return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized);
});
} else {
filteredPlaces = places;
}
searchResultCount = filteredPlaces.length;
});
// Step 2: Group places.
run(() => {
placesGroupOption = getSelectedPlacesGroupOption(userSettings);
const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None];
groupedPlaces = groupFunc(filteredPlaces);
placesGroupIds = groupedPlaces.map(({ id }) => id);
});
</script>
{#if hasPlaces}
<!-- Album Cards -->
{#if placesGroupOption === PlacesGroupBy.None}
<PlacesCardGroup places={groupedPlaces[0].places} />
{:else}
{#each groupedPlaces as placeGroup (placeGroup.id)}
<PlacesCardGroup places={placeGroup.places} group={placeGroup} />
{/each}
{/if}
{:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<Icon path={mdiMapMarkerOff} size="3.5em" />
<p class="mt-5 text-3xl font-medium">{$t('no_places')}</p>
</div>
</div>
{/if}

View file

@ -101,6 +101,14 @@ export interface AlbumViewSettings {
};
}
export interface PlacesViewSettings {
groupBy: string;
collapsedGroups: {
// Grouping Option => Array<Group ID>
[group: string]: string[];
};
}
export interface SidebarSettings {
people: boolean;
sharing: boolean;
@ -147,6 +155,16 @@ export const albumViewSettings = persisted<AlbumViewSettings>('album-view-settin
collapsedGroups: {},
});
export enum PlacesGroupBy {
None = 'None',
Country = 'Country',
}
export const placesViewSettings = persisted<PlacesViewSettings>('places-view-settings', {
groupBy: PlacesGroupBy.None,
collapsedGroups: {},
});
export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {});
export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-file', false, {});

View file

@ -0,0 +1,95 @@
import { PlacesGroupBy, placesViewSettings, type PlacesViewSettings } from '$lib/stores/preferences.store';
import { type AssetResponseDto } from '@immich/sdk';
import { get } from 'svelte/store';
/**
* --------------
* Places Grouping
* --------------
*/
export interface PlacesGroup {
id: string;
name: string;
places: AssetResponseDto[];
}
export interface PlacesGroupOptionMetadata {
id: PlacesGroupBy;
isDisabled: () => boolean;
}
export const groupOptionsMetadata: PlacesGroupOptionMetadata[] = [
{
id: PlacesGroupBy.None,
isDisabled: () => false,
},
{
id: PlacesGroupBy.Country,
isDisabled: () => false,
},
];
export const findGroupOptionMetadata = (groupBy: string) => {
// Default is no grouping
const defaultGroupOption = groupOptionsMetadata[0];
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
};
export const getSelectedPlacesGroupOption = (settings: PlacesViewSettings) => {
const defaultGroupOption = PlacesGroupBy.None;
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
return defaultGroupOption;
}
return albumGroupOption;
};
/**
* ----------------------------
* Places Groups Collapse/Expand
* ----------------------------
*/
const getCollapsedPlacesGroups = (settings: PlacesViewSettings) => {
settings.collapsedGroups ??= {};
const { collapsedGroups, groupBy } = settings;
collapsedGroups[groupBy] ??= [];
return collapsedGroups[groupBy];
};
export const isPlacesGroupCollapsed = (settings: PlacesViewSettings, groupId: string) => {
if (settings.groupBy === PlacesGroupBy.None) {
return false;
}
return getCollapsedPlacesGroups(settings).includes(groupId);
};
export const togglePlacesGroupCollapsing = (groupId: string) => {
const settings = get(placesViewSettings);
if (settings.groupBy === PlacesGroupBy.None) {
return;
}
const collapsedGroups = getCollapsedPlacesGroups(settings);
const groupIndex = collapsedGroups.indexOf(groupId);
if (groupIndex === -1) {
// Collapse
collapsedGroups.push(groupId);
} else {
// Expand
collapsedGroups.splice(groupIndex, 1);
}
placesViewSettings.set(settings);
};
export const collapseAllPlacesGroups = (groupIds: string[]) => {
placesViewSettings.update((settings) => {
const collapsedGroups = getCollapsedPlacesGroups(settings);
collapsedGroups.length = 0;
collapsedGroups.push(...groupIds);
return settings;
});
};
export const expandAllPlacesGroups = () => {
collapseAllPlacesGroups([]);
};

View file

@ -1,13 +1,12 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute } from '$lib/constants';
import { mdiMapMarkerOff } from '@mdi/js';
import PlacesControls from '$lib/components/places-page/places-controls.svelte';
import type { PageData } from './$types';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { getAssetThumbnailUrl } from '$lib/utils';
import { locale } from '$lib/stores/preferences.store';
import Places from '$lib/components/places-page/places-list.svelte';
import { placesViewSettings } from '$lib/stores/preferences.store';
interface Props {
data: PageData;
@ -21,43 +20,33 @@
};
};
let searchQuery = $state('');
let searchResultCount = $state(0);
let placesGroups: string[] = $state([]);
let places = $derived(data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city));
let hasPlaces = $derived(places.length > 0);
let countVisiblePlaces = $derived(searchQuery ? searchResultCount : places.length);
let innerHeight: number = $state(0);
</script>
<svelte:window bind:innerHeight />
<UserPageLayout title={$t('places')}>
{#if hasPlaces}
<div class="flex flex-row flex-wrap gap-4">
{#each places as item (item.id)}
{@const city = item.exifInfo.city}
<a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city })}" draggable="false">
<div
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-[156px] justify-center overflow-hidden rounded-xl brightness-75 filter"
>
<img
src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
alt={city}
class="object-cover w-[156px] h-[156px]"
/>
</div>
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
>
{city}
</span>
</a>
{/each}
<UserPageLayout
title={$t('places')}
description={countVisiblePlaces === 0 && !searchQuery ? undefined : `(${countVisiblePlaces.toLocaleString($locale)})`}
>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<PlacesControls {placesGroups} bind:searchQuery />
</div>
{:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<Icon path={mdiMapMarkerOff} size="3.5em" />
<p class="mt-5 text-3xl font-medium">{$t('no_places')}</p>
</div>
</div>
{/if}
{/snippet}
<Places
{places}
userSettings={$placesViewSettings}
{searchQuery}
bind:searchResultCount
bind:placesGroupIds={placesGroups}
/>
</UserPageLayout>