Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): revamp places #12219

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -766,8 +766,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})",
Expand Down Expand Up @@ -985,6 +987,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",
Expand Down Expand Up @@ -1276,6 +1279,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",
Expand Down
67 changes: 67 additions & 0 deletions web/src/lib/components/places-page/places-card-group.svelte
Original file line number Diff line number Diff line change
@@ -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>
92 changes: 92 additions & 0 deletions web/src/lib/components/places-page/places-controls.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<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 xl:block h-10 xl:w-60 2xl:w-80">
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
</div>

<!-- Group Places -->
<Dropdown
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}
121 changes: 121 additions & 0 deletions web/src/lib/components/places-page/places-list.svelte
Original file line number Diff line number Diff line change
@@ -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}
18 changes: 18 additions & 0 deletions web/src/lib/stores/preferences.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, {});
Expand Down
Loading