diff --git a/web/src/lib/components/places-page/places-card-group.svelte b/web/src/lib/components/places-page/places-card-group.svelte
new file mode 100644
index 0000000000000..d3b3a00cbeefd
--- /dev/null
+++ b/web/src/lib/components/places-page/places-card-group.svelte
@@ -0,0 +1,64 @@
+
+
+{#if group}
+
+
+
+
+{/if}
+
+
+ {#if !isCollapsed}
+
+ {/if}
+
diff --git a/web/src/lib/components/places-page/places-controls.svelte b/web/src/lib/components/places-page/places-controls.svelte
new file mode 100644
index 0000000000000..b22037ff56311
--- /dev/null
+++ b/web/src/lib/components/places-page/places-controls.svelte
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+ handleChangeGroupBy(detail)}
+ render={({ id, isDisabled }) => ({
+ title: placesGroupByNames[id],
+ icon: groupIcon,
+ disabled: isDisabled(),
+ })}
+/>
+
+{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
+
+
+
+
+
expandAllPlacesGroups()}>
+
+
+
+
+
+
+
+
+
collapseAllPlacesGroups(placesGroups)}>
+
+
+
+
+
+
+
+{/if}
diff --git a/web/src/lib/components/places-page/places-list.svelte b/web/src/lib/components/places-page/places-list.svelte
new file mode 100644
index 0000000000000..3a846683f1b60
--- /dev/null
+++ b/web/src/lib/components/places-page/places-list.svelte
@@ -0,0 +1,111 @@
+
+
+{#if hasPlaces}
+
+ {#if placesGroupOption === PlacesGroupBy.None}
+
+ {:else}
+ {#each groupedPlaces as placeGroup (placeGroup.id)}
+
+ {/each}
+ {/if}
+{:else}
+
+{/if}
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts
index de80702b95406..ead30bf9f4e63 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -91,6 +91,14 @@ export interface AlbumViewSettings {
};
}
+export interface PlacesViewSettings {
+ groupBy: string;
+ collapsedGroups: {
+ // Grouping Option => Array
+ [group: string]: string[];
+ };
+}
+
export interface SidebarSettings {
people: boolean;
sharing: boolean;
@@ -137,6 +145,16 @@ export const albumViewSettings = persisted('album-view-settin
collapsedGroups: {},
});
+export enum PlacesGroupBy {
+ None = 'None',
+ Country = 'Country',
+}
+
+export const placesViewSettings = persisted('places-view-settings', {
+ groupBy: PlacesGroupBy.None,
+ collapsedGroups: {},
+});
+
export const showDeleteModal = persisted('delete-confirm-dialog', true, {});
export const alwaysLoadOriginalFile = persisted('always-load-original-file', false, {});
diff --git a/web/src/lib/utils/places-utils.ts b/web/src/lib/utils/places-utils.ts
new file mode 100644
index 0000000000000..625f42d147a89
--- /dev/null
+++ b/web/src/lib/utils/places-utils.ts
@@ -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([]);
+};
diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte
index 28c8e95cb1331..ca8b0f55b2d67 100644
--- a/web/src/routes/(user)/places/+page.svelte
+++ b/web/src/routes/(user)/places/+page.svelte
@@ -1,13 +1,12 @@
-
- {#if hasPlaces}
-
- {#each places as item (item.id)}
- {@const city = item.exifInfo.city}
-
-
-

-
-
- {city}
-
-
- {/each}
-
- {:else}
-
- {/if}
+
+
+
+