diff --git a/i18n/en.json b/i18n/en.json
index ad48a969913f0..7dca9170f6aae 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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})",
@@ -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",
@@ -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",
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..87b9870888f75
--- /dev/null
+++ b/web/src/lib/components/places-page/places-card-group.svelte
@@ -0,0 +1,67 @@
+
+
+{#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..7feaafb7f79f5
--- /dev/null
+++ b/web/src/lib/components/places-page/places-controls.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+ ({
+ title: placesGroupByNames[id],
+ icon: groupIcon,
+ disabled: isDisabled(),
+ })}
+/>
+
+{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
+
+
+
+
+ expandAllPlacesGroups()}
+ variant="ghost"
+ color="secondary"
+ shape="round"
+ icon={mdiUnfoldMoreHorizontal}
+ />
+
+
+
+
+ collapseAllPlacesGroups(placesGroups)}
+ variant="ghost"
+ color="secondary"
+ shape="round"
+ icon={mdiUnfoldLessHorizontal}
+ />
+
+
+
+{/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..27eea3c5a8e8e
--- /dev/null
+++ b/web/src/lib/components/places-page/places-list.svelte
@@ -0,0 +1,121 @@
+
+
+{#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 1ec0853dc0696..818800755cc79 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -101,6 +101,14 @@ export interface AlbumViewSettings {
};
}
+export interface PlacesViewSettings {
+ groupBy: string;
+ collapsedGroups: {
+ // Grouping Option => Array
+ [group: string]: string[];
+ };
+}
+
export interface SidebarSettings {
people: boolean;
sharing: boolean;
@@ -147,6 +155,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 1808755482672..636f28013fedc 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}
-
-
+
+ {#snippet buttons()}
+
- {/if}
+ {/snippet}
+
+