mirror of
https://github.com/immich-app/immich.git
synced 2024-12-31 00:43:56 -05:00
feat: add a timeline to the map
This commit is contained in:
parent
233372303b
commit
ab0080a2c9
24 changed files with 706 additions and 180 deletions
BIN
mobile/openapi/lib/api/timeline_api.dart
generated
BIN
mobile/openapi/lib/api/timeline_api.dart
generated
Binary file not shown.
|
@ -6632,6 +6632,50 @@
|
|||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x1",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -180,
|
||||
"maximum": 180,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x2",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -180,
|
||||
"maximum": 180,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "y1",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -90,
|
||||
"maximum": 90,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "y2",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -90,
|
||||
"maximum": 90,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -6768,6 +6812,50 @@
|
|||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x1",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -180,
|
||||
"maximum": 180,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x2",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -180,
|
||||
"maximum": 180,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "y1",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -90,
|
||||
"maximum": 90,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "y2",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": -90,
|
||||
"maximum": 90,
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
@ -2986,7 +2986,7 @@ export function tagAssets({ id, bulkIdsDto }: {
|
|||
body: bulkIdsDto
|
||||
})));
|
||||
}
|
||||
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: {
|
||||
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked, x1, x2, y1, y2 }: {
|
||||
albumId?: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
|
@ -3000,6 +3000,10 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
|
|||
userId?: string;
|
||||
withPartners?: boolean;
|
||||
withStacked?: boolean;
|
||||
x1?: number;
|
||||
x2?: number;
|
||||
y1?: number;
|
||||
y2?: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
|
@ -3017,12 +3021,16 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
|
|||
timeBucket,
|
||||
userId,
|
||||
withPartners,
|
||||
withStacked
|
||||
withStacked,
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: {
|
||||
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked, x1, x2, y1, y2 }: {
|
||||
albumId?: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
|
@ -3035,6 +3043,10 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
|
|||
userId?: string;
|
||||
withPartners?: boolean;
|
||||
withStacked?: boolean;
|
||||
x1?: number;
|
||||
x2?: number;
|
||||
y1?: number;
|
||||
y2?: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
|
@ -3051,7 +3063,11 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
|
|||
tagId,
|
||||
userId,
|
||||
withPartners,
|
||||
withStacked
|
||||
withStacked,
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/interfaces/asset.interface';
|
||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
@ -41,6 +42,38 @@ export class TimeBucketDto {
|
|||
@Optional()
|
||||
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
|
||||
order?: AssetOrder;
|
||||
|
||||
@Optional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
x1?: number;
|
||||
|
||||
@Optional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
y1?: number;
|
||||
|
||||
@Optional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
x2?: number;
|
||||
|
||||
@Optional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
y2?: number;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
|
|
|
@ -57,6 +57,10 @@ export interface AssetBuilderOptions {
|
|||
withStacked?: boolean;
|
||||
exifInfo?: boolean;
|
||||
assetType?: AssetType;
|
||||
x1?: number;
|
||||
x2?: number;
|
||||
y1?: number;
|
||||
y2?: number;
|
||||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
|
|
|
@ -694,7 +694,13 @@ FROM
|
|||
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
("asset"."isVisible" = true)
|
||||
(
|
||||
"asset"."isVisible" = true
|
||||
AND "exifInfo"."longitude" > $1
|
||||
AND "exifInfo"."longitude" < $2
|
||||
AND "exifInfo"."latitude" > $3
|
||||
AND "exifInfo"."latitude" < $4
|
||||
)
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
GROUP BY
|
||||
(
|
||||
|
|
|
@ -633,7 +633,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
return builder.orderBy('RANDOM()').limit(count).getMany();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
|
||||
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH, x1: 1, x2: 2, y1: 2, y2: 1 }] })
|
||||
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||
const truncated = dateTrunc(options);
|
||||
return this.getBuilder(options)
|
||||
|
@ -789,6 +789,34 @@ export class AssetRepository implements IAssetRepository {
|
|||
);
|
||||
}
|
||||
|
||||
if (options.x1 && options.x2 && options.y1 && options.y2) {
|
||||
/**
|
||||
/* the API already makes sure that -180 < x1, x2 < 180
|
||||
/* the first case is when you search an asset with the International Date Line (x1 is on the west side, x2 on the east)
|
||||
*/
|
||||
if (options.x1 > options.x2) {
|
||||
builder.andWhere(
|
||||
'(exifInfo.longitude > :x1 OR exifInfo.longitude < :x2) AND exifInfo.latitude > :y2 AND exifInfo.latitude < :y1',
|
||||
{
|
||||
x1: options.x1,
|
||||
x2: options.x2,
|
||||
y1: options.y1,
|
||||
y2: options.y2,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
builder.andWhere(
|
||||
'exifInfo.longitude > :x1 AND exifInfo.longitude < :x2 AND exifInfo.latitude > :y2 AND exifInfo.latitude < :y1',
|
||||
{
|
||||
x1: options.x1,
|
||||
x2: options.x2,
|
||||
y1: options.y1,
|
||||
y2: options.y2,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
|
|
@ -197,8 +197,8 @@
|
|||
<a
|
||||
class="w-[90px]"
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
|
||||
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
|
||||
: AppRoute.PHOTOS}"
|
||||
? `${encodeURIComponent(`${AppRoute.ALBUMS}/${currentAlbum?.id}`)}`
|
||||
: encodeURIComponent(AppRoute.PHOTOS)}"
|
||||
on:focus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:blur={() => ($boundingBoxesArray = [])}
|
||||
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
|
||||
>
|
||||
<a
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={encodeURIComponent(AppRoute.PEOPLE)}"
|
||||
draggable="false"
|
||||
on:focus={() => (showVerticalDots = true)}
|
||||
>
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
export let description: string | undefined = undefined;
|
||||
export let scrollbar = true;
|
||||
export let admin = false;
|
||||
export let showCustomSidebar: boolean = false;
|
||||
|
||||
$: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden';
|
||||
$: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
|
||||
|
@ -34,7 +35,9 @@
|
|||
{#if admin}
|
||||
<AdminSideBar />
|
||||
{:else}
|
||||
<SideBar />
|
||||
<SideBar {showCustomSidebar}>
|
||||
<slot name="customSidebar" slot="customSidebar" />
|
||||
</SideBar>
|
||||
{/if}
|
||||
</slot>
|
||||
|
||||
|
|
|
@ -4,17 +4,16 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { AppRoute, AssetGridOptionsValues, QueryParameter, Theme } from '$lib/constants';
|
||||
import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
|
||||
import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils';
|
||||
import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
|
||||
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import { mdiArrowDown, mdiArrowUp, mdiCog, mdiMap, mdiMapMarker, mdiOpenInNew } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import {
|
||||
AttributionControl,
|
||||
Control,
|
||||
|
@ -30,6 +29,11 @@
|
|||
ScaleControl,
|
||||
type Map,
|
||||
} from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||
import { AssetStore } from '$lib/stores/assets.store';
|
||||
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
|
||||
export let mapMarkers: MapMarkerResponseDto[];
|
||||
export let showSettingsModal: boolean | undefined = undefined;
|
||||
|
@ -39,6 +43,19 @@
|
|||
export let simplified = false;
|
||||
export let clickable = false;
|
||||
export let useLocationPin = false;
|
||||
|
||||
// if you want to have the assetGrid with the map
|
||||
export let showAssetGrid = false;
|
||||
|
||||
// to open the assetGrid on pageLoad
|
||||
export let isAssetGridOpenedOnInit = false;
|
||||
|
||||
onMount(() => {
|
||||
if (isAssetGridOpenedOnInit) {
|
||||
handleOpenAssetGird();
|
||||
}
|
||||
});
|
||||
|
||||
export function addClipMapMarker(lng: number, lat: number) {
|
||||
if (map) {
|
||||
if (marker) {
|
||||
|
@ -56,6 +73,34 @@
|
|||
let map: maplibregl.Map;
|
||||
let marker: maplibregl.Marker | null = null;
|
||||
|
||||
let x1: number | undefined = undefined;
|
||||
let x2: number | undefined = undefined;
|
||||
let y1: number | undefined = undefined;
|
||||
let y2: number | undefined = undefined;
|
||||
let currentAssets: MapMarkerResponseDto[] = [];
|
||||
let previousUrl: string | undefined = undefined;
|
||||
let isAssetGridOpened: boolean = false;
|
||||
let numberOfAssets: number | undefined = undefined;
|
||||
|
||||
let timelineStore: AssetStore | undefined = undefined;
|
||||
const timelineInteractionStore = createAssetInteractionStore();
|
||||
|
||||
$: {
|
||||
let url = `${AppRoute.PHOTOS}?${QueryParameter.COORDINATESX1}=${x1}&${QueryParameter.COORDINATESX2}=${x2}&${QueryParameter.COORDINATESY1}=${y1}&${QueryParameter.COORDINATESY2}=${y2}&${QueryParameter.PREVIOUS_ROUTE}=${encodeURIComponent(`${AppRoute.MAP}?${QueryParameter.IS_TIMELINE_OPENED}=${isAssetGridOpened}${location.hash}`)}`;
|
||||
let options: string[] = [];
|
||||
if ($mapSettings.withPartners) {
|
||||
options.push(AssetGridOptionsValues.withPartners);
|
||||
}
|
||||
if ($mapSettings.onlyFavorites) {
|
||||
options.push(AssetGridOptionsValues.onlyFavorites);
|
||||
}
|
||||
if (options.length > 0) {
|
||||
url += `&${QueryParameter.ASSET_GRID_OPTIONS}=${options.join(',')}`;
|
||||
}
|
||||
|
||||
previousUrl = url;
|
||||
}
|
||||
|
||||
$: style = (() =>
|
||||
getMapStyle({
|
||||
theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme,
|
||||
|
@ -125,6 +170,84 @@
|
|||
country: featurePoint.properties.country,
|
||||
};
|
||||
};
|
||||
|
||||
const isAssetinBounds = (mapMarker: MapMarkerResponseDto, x1: number, x2: number, y1: number, y2: number) => {
|
||||
// if x1 is before the international date line, and x2 after it, you want to check if the asset is after x1 OR after x2
|
||||
const isLonWithinBounds =
|
||||
x1 > x2 ? mapMarker.lon >= x1 || mapMarker.lon <= x2 : mapMarker.lon >= x1 && mapMarker.lon <= x2;
|
||||
const isLatWithinBounds = mapMarker.lat >= y2 && mapMarker.lat <= y1;
|
||||
|
||||
return isLonWithinBounds && isLatWithinBounds;
|
||||
};
|
||||
|
||||
const changeBounds = () => {
|
||||
if (!showAssetGrid) {
|
||||
return;
|
||||
}
|
||||
if (map) {
|
||||
const bounds = map.getBounds();
|
||||
|
||||
let lng_e: number, lng_w: number, lat_e: number, lat_w: number;
|
||||
|
||||
/*
|
||||
/*
|
||||
/* longitude and latitude can be >180 and <180 with maplibre
|
||||
/* that part fixes it to always have longitude coordinates -180 < x1, x2 < 180
|
||||
/*
|
||||
*/
|
||||
if (Math.abs(bounds._ne.lng) + Math.abs(bounds._sw.lng) > 360) {
|
||||
lng_e = -180;
|
||||
lng_w = 180;
|
||||
} else if (Math.abs(bounds._sw.lng) > 180) {
|
||||
lng_e = bounds._sw.lng + 360;
|
||||
lng_w = bounds._ne.lng;
|
||||
} else if (Math.abs(bounds._ne.lng) > 180) {
|
||||
lng_e = bounds._sw.lng;
|
||||
lng_w = bounds._ne.lng - 360;
|
||||
} else {
|
||||
lng_e = bounds._sw.lng;
|
||||
lng_w = bounds._ne.lng;
|
||||
}
|
||||
|
||||
lat_e = bounds._ne.lat;
|
||||
lat_w = bounds._sw.lat;
|
||||
|
||||
// TODO: get the number of assets from the server?
|
||||
let assetsInBounds = mapMarkers.filter((mapMarker) => isAssetinBounds(mapMarker, lng_e, lng_w, lat_e, lat_w));
|
||||
numberOfAssets = assetsInBounds.length;
|
||||
|
||||
[x1, x2, y1, y2] = [lng_e, lng_w, lat_e, lat_w];
|
||||
|
||||
// refresh only if the assets displayed on screen has changed to avoid refresh the AssetGrid when nothing has changed on screen
|
||||
if (currentAssets.length === 0 || assetsInBounds.toString() !== currentAssets.toString()) {
|
||||
newGrid();
|
||||
currentAssets = assetsInBounds;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const newGrid = () => {
|
||||
// TODO: missing includeArchived and withSharedAlbums
|
||||
const isFavorite = $mapSettings.onlyFavorites ? true : undefined;
|
||||
timelineStore = new AssetStore({
|
||||
isArchived: false,
|
||||
withPartners: $mapSettings.withPartners,
|
||||
isFavorite,
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAssetGird = () => {
|
||||
isAssetGridOpened = !isAssetGridOpened;
|
||||
|
||||
if (isAssetGridOpened) {
|
||||
changeBounds();
|
||||
newGrid();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#await style then style}
|
||||
|
@ -140,6 +263,7 @@
|
|||
on:load={(event) => event.detail.setMaxZoom(18)}
|
||||
on:load={(event) => event.detail.on('click', handleMapClick)}
|
||||
bind:map
|
||||
on:moveend={changeBounds}
|
||||
>
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
|
||||
|
@ -222,6 +346,51 @@
|
|||
{/if}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
|
||||
{#if showAssetGrid && !$mapSettings.withSharedAlbums && !$mapSettings.includeArchived}
|
||||
<div
|
||||
class="absolute transition ease-in-out inset-x-0 bottom-0 px-2 rounded-t-lg rounded-x-lg z-50 {isAssetGridOpened
|
||||
? 'h-[50%] bg-immich-bg dark:bg-immich-dark-bg border-t-2 border-x-2 dark:border-immich-dark-gray'
|
||||
: ''}"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-4 py-2 w-full content-start dark:text-immich-dark-primary">
|
||||
<div>
|
||||
{#if isAssetGridOpened}
|
||||
<div
|
||||
title={$t('number_of_assets')}
|
||||
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray text-center h-8 w-8 p-2 rounded-full"
|
||||
>
|
||||
{numberOfAssets}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class=" text-white text-center">
|
||||
<Button class="h-14 w-14 p-2" rounded="full" size="tiny" on:click={handleOpenAssetGird}>
|
||||
<Icon path={isAssetGridOpened ? mdiArrowDown : mdiArrowUp} size="36" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if isAssetGridOpened && numberOfAssets && previousUrl}
|
||||
<a href={previousUrl}>
|
||||
<Button class="h-8 w-8 p-2 " rounded="full" size="tiny" on:click={handleOpenAssetGird}>
|
||||
<Icon path={mdiOpenInNew} size="12" />
|
||||
</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if isAssetGridOpened && timelineStore}
|
||||
{#key timelineStore}
|
||||
<AssetGrid
|
||||
enableRouting={false}
|
||||
isSelectionMode={false}
|
||||
assetStore={timelineStore}
|
||||
assetInteractionStore={timelineInteractionStore}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</MapLibre>
|
||||
<style>
|
||||
.location-pin {
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiInformationOutline } from '@mdi/js';
|
||||
|
||||
export let title: string;
|
||||
export let icon: string;
|
||||
export let flippedLogo = false;
|
||||
export let isSelected = false;
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
export let moreInformation: boolean;
|
||||
|
||||
let showMoreInformation = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
draggable="false"
|
||||
aria-current={isSelected ? 'page' : undefined}
|
||||
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
||||
{isSelected
|
||||
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
||||
: ''}
|
||||
pl-5 group-hover:sm:px-5 md:px-5
|
||||
"
|
||||
on:click={onClick}
|
||||
>
|
||||
<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
|
||||
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
||||
<span class="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
|
||||
>
|
||||
{#if moreInformation}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="relative flex cursor-default select-none justify-center"
|
||||
on:mouseenter={() => (showMoreInformation = true)}
|
||||
on:mouseleave={() => (showMoreInformation = false)}
|
||||
>
|
||||
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
|
||||
<Icon path={mdiInformationOutline} />
|
||||
</div>
|
||||
|
||||
{#if showMoreInformation}
|
||||
<div class="absolute right-6 top-0">
|
||||
<div
|
||||
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
|
||||
class:hidden={!showMoreInformation}
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<slot name="moreInformation" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
|
@ -1,9 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiInformationOutline } from '@mdi/js';
|
||||
import { resolveRoute } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let routeId: string;
|
||||
|
@ -12,7 +10,6 @@
|
|||
export let isSelected = false;
|
||||
export let preloadData = true;
|
||||
|
||||
let showMoreInformation = false;
|
||||
$: routePath = resolveRoute(routeId, {});
|
||||
$: isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId;
|
||||
</script>
|
||||
|
@ -22,44 +19,8 @@
|
|||
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
|
||||
draggable="false"
|
||||
aria-current={isSelected ? 'page' : undefined}
|
||||
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
||||
{isSelected
|
||||
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
||||
: ''}
|
||||
pl-5 group-hover:sm:px-5 md:px-5
|
||||
"
|
||||
>
|
||||
<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
|
||||
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
||||
<span class="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
|
||||
>
|
||||
{#if $$slots.moreInformation}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="relative flex cursor-default select-none justify-center"
|
||||
on:mouseenter={() => (showMoreInformation = true)}
|
||||
on:mouseleave={() => (showMoreInformation = false)}
|
||||
>
|
||||
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
|
||||
<Icon path={mdiInformationOutline} />
|
||||
</div>
|
||||
|
||||
{#if showMoreInformation}
|
||||
<div class="absolute right-6 top-0">
|
||||
<div
|
||||
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
|
||||
class:hidden={!showMoreInformation}
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<slot name="moreInformation" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<SideBarButton {title} {icon} {flippedLogo} {isSelected} moreInformation={$$slots.moreInformation}>
|
||||
<slot name="moreInformation" slot="moreInformation" />
|
||||
</SideBarButton>
|
||||
</a>
|
||||
|
|
|
@ -38,113 +38,119 @@
|
|||
let isSharingSelected: boolean;
|
||||
let isTrashSelected: boolean;
|
||||
let isUtilitiesSelected: boolean;
|
||||
|
||||
export let showCustomSidebar: boolean = false;
|
||||
</script>
|
||||
|
||||
<SideBarSection>
|
||||
<nav aria-label={$t('primary')}>
|
||||
<SideBarLink
|
||||
title={$t('photos')}
|
||||
routeId="/(user)/photos"
|
||||
bind:isSelected={isPhotosSelected}
|
||||
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isArchived: false }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
{#if $featureFlags.search}
|
||||
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
|
||||
{/if}
|
||||
|
||||
{#if $featureFlags.map}
|
||||
{#if !showCustomSidebar}
|
||||
<SideBarLink
|
||||
title={$t('map')}
|
||||
routeId="/(user)/map"
|
||||
bind:isSelected={isMapSelected}
|
||||
icon={isMapSelected ? mdiMap : mdiMapOutline}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
|
||||
<SideBarLink
|
||||
title={$t('people')}
|
||||
routeId="/(user)/people"
|
||||
bind:isSelected={isPeopleSelected}
|
||||
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<SideBarLink
|
||||
title={$t('sharing')}
|
||||
routeId="/(user)/sharing"
|
||||
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
|
||||
bind:isSelected={isSharingSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAlbums albumType="shared" />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
||||
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
|
||||
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
|
||||
</div>
|
||||
|
||||
<SideBarLink
|
||||
title={$t('favorites')}
|
||||
routeId="/(user)/favorites"
|
||||
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
|
||||
bind:isSelected={isFavoritesSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isFavorite: true }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAlbums albumType="owned" />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
||||
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
|
||||
{/if}
|
||||
|
||||
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
|
||||
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
|
||||
{/if}
|
||||
|
||||
<SideBarLink
|
||||
title={$t('utilities')}
|
||||
routeId="/(user)/utilities"
|
||||
bind:isSelected={isUtilitiesSelected}
|
||||
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
|
||||
></SideBarLink>
|
||||
|
||||
<SideBarLink
|
||||
title={$t('archive')}
|
||||
routeId="/(user)/archive"
|
||||
bind:isSelected={isArchiveSelected}
|
||||
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isArchived: true }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
{#if $featureFlags.trash}
|
||||
<SideBarLink
|
||||
title={$t('trash')}
|
||||
routeId="/(user)/trash"
|
||||
bind:isSelected={isTrashSelected}
|
||||
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
|
||||
title={$t('photos')}
|
||||
routeId="/(user)/photos"
|
||||
bind:isSelected={isPhotosSelected}
|
||||
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isTrashed: true }} />
|
||||
<MoreInformationAssets assetStats={{ isArchived: false }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
{#if $featureFlags.search}
|
||||
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
|
||||
{/if}
|
||||
|
||||
{#if $featureFlags.map}
|
||||
<SideBarLink
|
||||
title={$t('map')}
|
||||
routeId="/(user)/map"
|
||||
bind:isSelected={isMapSelected}
|
||||
icon={isMapSelected ? mdiMap : mdiMapOutline}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
|
||||
<SideBarLink
|
||||
title={$t('people')}
|
||||
routeId="/(user)/people"
|
||||
bind:isSelected={isPeopleSelected}
|
||||
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<SideBarLink
|
||||
title={$t('sharing')}
|
||||
routeId="/(user)/sharing"
|
||||
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
|
||||
bind:isSelected={isSharingSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAlbums albumType="shared" />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
||||
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
|
||||
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
|
||||
</div>
|
||||
|
||||
<SideBarLink
|
||||
title={$t('favorites')}
|
||||
routeId="/(user)/favorites"
|
||||
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
|
||||
bind:isSelected={isFavoritesSelected}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isFavorite: true }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAlbums albumType="owned" />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
||||
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
|
||||
{/if}
|
||||
|
||||
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
|
||||
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
|
||||
{/if}
|
||||
|
||||
<SideBarLink
|
||||
title={$t('utilities')}
|
||||
routeId="/(user)/utilities"
|
||||
bind:isSelected={isUtilitiesSelected}
|
||||
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
|
||||
></SideBarLink>
|
||||
|
||||
<SideBarLink
|
||||
title={$t('archive')}
|
||||
routeId="/(user)/archive"
|
||||
bind:isSelected={isArchiveSelected}
|
||||
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isArchived: true }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
|
||||
{#if $featureFlags.trash}
|
||||
<SideBarLink
|
||||
title={$t('trash')}
|
||||
routeId="/(user)/trash"
|
||||
bind:isSelected={isTrashSelected}
|
||||
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
|
||||
>
|
||||
<svelte:fragment slot="moreInformation">
|
||||
<MoreInformationAssets assetStats={{ isTrashed: true }} />
|
||||
</svelte:fragment>
|
||||
</SideBarLink>
|
||||
{/if}
|
||||
{:else}
|
||||
<slot name="customSidebar" />
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
|
|
|
@ -69,10 +69,51 @@ export const dateFormats = {
|
|||
},
|
||||
};
|
||||
|
||||
interface Coordinates {
|
||||
x1: number;
|
||||
x2: number;
|
||||
y1: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export enum AssetGridOptionsValues {
|
||||
onlyFavorites = 'onlyFavorites',
|
||||
withPartners = 'withPartners',
|
||||
}
|
||||
|
||||
export interface AssetGridOptions {
|
||||
onlyFavorites: boolean;
|
||||
withPartners: boolean;
|
||||
}
|
||||
|
||||
export interface IQueryParameter {
|
||||
previousRoute?: string;
|
||||
coordinates?: Coordinates;
|
||||
assetGridOptions?: AssetGridOptions;
|
||||
}
|
||||
|
||||
export const createAssetGridOptionsFromArray = (enumArray: string[]): AssetGridOptions => {
|
||||
const defaultValues: AssetGridOptions = {} as AssetGridOptions;
|
||||
|
||||
for (const key of Object.keys(AssetGridOptionsValues)) {
|
||||
defaultValues[key as keyof AssetGridOptions] = false;
|
||||
}
|
||||
|
||||
for (const enumValue of Object.values(AssetGridOptionsValues)) {
|
||||
if (enumArray.includes(enumValue)) {
|
||||
defaultValues[enumValue as keyof AssetGridOptions] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValues;
|
||||
};
|
||||
|
||||
export enum QueryParameter {
|
||||
ACTION = 'action',
|
||||
ASSET_INDEX = 'assetIndex',
|
||||
ASSET_GRID_OPTIONS = 'assetGridOptions',
|
||||
IS_OPEN = 'isOpen',
|
||||
IS_TIMELINE_OPENED = 'isTimelineOpened',
|
||||
MEMORY_INDEX = 'memoryIndex',
|
||||
ONBOARDING_STEP = 'step',
|
||||
OPEN_SETTING = 'openSetting',
|
||||
|
@ -82,6 +123,10 @@ export enum QueryParameter {
|
|||
SMART_SEARCH = 'smartSearch',
|
||||
PAGE = 'page',
|
||||
PATH = 'path',
|
||||
COORDINATESX1 = 'coordinatesx1',
|
||||
COORDINATESX2 = 'coordinatesx2',
|
||||
COORDINATESY1 = 'coordinatesy1',
|
||||
COORDINATESY2 = 'coordinatesY2',
|
||||
}
|
||||
|
||||
export enum OpenSettingQueryParameterValue {
|
||||
|
|
|
@ -758,6 +758,7 @@
|
|||
"include_archived": "Include archived",
|
||||
"include_shared_albums": "Include shared albums",
|
||||
"include_shared_partner_assets": "Include shared partner assets",
|
||||
"include_timeline": "Include timeline",
|
||||
"individual_share": "Individual share",
|
||||
"info": "Info",
|
||||
"interval": {
|
||||
|
@ -872,6 +873,7 @@
|
|||
"notification_toggle_setting_description": "Enable email notifications",
|
||||
"notifications": "Notifications",
|
||||
"notifications_setting_description": "Manage notifications",
|
||||
"number_of_assets": "Number of assets",
|
||||
"oauth": "OAuth",
|
||||
"offline": "Offline",
|
||||
"offline_paths": "Offline paths",
|
||||
|
|
|
@ -792,6 +792,7 @@
|
|||
"include_archived": "Inclure les archives",
|
||||
"include_shared_albums": "Inclure les albums partagés",
|
||||
"include_shared_partner_assets": "Inclure les médias partagés du partenaire",
|
||||
"include_timeline": "Inclure la chronologie",
|
||||
"individual_share": "Partage individuel",
|
||||
"info": "Information",
|
||||
"interval": {
|
||||
|
@ -931,6 +932,7 @@
|
|||
"notification_toggle_setting_description": "Activer les notifications par courriel",
|
||||
"notifications": "Notifications",
|
||||
"notifications_setting_description": "Gérer les notifications",
|
||||
"number_of_assets": "Nombre de médias",
|
||||
"oauth": "OAuth",
|
||||
"offline": "Hors ligne",
|
||||
"offline_paths": "Chemins hors ligne",
|
||||
|
|
|
@ -336,3 +336,13 @@ export const suggestDuplicateByFileSize = (assets: AssetResponseDto[]): AssetRes
|
|||
|
||||
// eslint-disable-next-line unicorn/prefer-code-point
|
||||
export const decodeBase64 = (data: string) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
||||
|
||||
export const parseNumberOrUndefined = (value: string | null): number | undefined => {
|
||||
return value !== null && !Number.isNaN(Number(value)) ? Number(value) : undefined;
|
||||
};
|
||||
|
||||
export const checkEnumInArray = <T extends Record<string, string>>(enumArray: string[], enumType: T): boolean => {
|
||||
const enumValues = Object.values(enumType) as string[];
|
||||
|
||||
return enumArray.some((value) => enumValues.includes(value));
|
||||
};
|
||||
|
|
|
@ -113,7 +113,14 @@
|
|||
{#if $featureFlags.loaded && $featureFlags.map}
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<div class="isolate h-full w-full">
|
||||
<Map hash bind:mapMarkers bind:showSettingsModal on:selected={(event) => onViewAssets(event.detail)} />
|
||||
<Map
|
||||
hash
|
||||
showAssetGrid
|
||||
bind:mapMarkers
|
||||
bind:showSettingsModal
|
||||
isAssetGridOpenedOnInit={data.isTimelineOpened}
|
||||
on:selected={(event) => onViewAssets(event.detail)}
|
||||
/>
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
<Portal target="body">
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import { QueryParameter } from '$lib/constants';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
export const load = (async ({ params, url }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
const $t = await getFormatter();
|
||||
|
||||
const isTimelineOpened = url.searchParams.get(QueryParameter.IS_TIMELINE_OPENED) === 'true';
|
||||
return {
|
||||
asset,
|
||||
isTimelineOpened,
|
||||
meta: {
|
||||
title: $t('map'),
|
||||
},
|
||||
|
|
|
@ -194,7 +194,7 @@
|
|||
|
||||
const handleMergePeople = async (detail: PersonResponseDto) => {
|
||||
await goto(
|
||||
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
|
||||
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${encodeURIComponent(AppRoute.PEOPLE)}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -116,10 +116,10 @@
|
|||
onMount(() => {
|
||||
const action = $page.url.searchParams.get(QueryParameter.ACTION);
|
||||
const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
|
||||
if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) {
|
||||
previousRoute = getPreviousRoute;
|
||||
if (getPreviousRoute && !isExternalUrl(decodeURIComponent(getPreviousRoute))) {
|
||||
previousRoute = decodeURIComponent(getPreviousRoute);
|
||||
}
|
||||
if (action == 'merge') {
|
||||
if (action === 'merge') {
|
||||
viewMode = ViewMode.MERGE_PEOPLE;
|
||||
}
|
||||
|
||||
|
|
|
@ -18,19 +18,39 @@
|
|||
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { AssetAction, QueryParameter } from '$lib/constants';
|
||||
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { AssetStore } from '$lib/stores/assets.store';
|
||||
import { mdiArrowLeft, mdiClose, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import SideBarLink from '$lib/components/shared-components/side-bar/side-bar-link.svelte';
|
||||
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
$: options = data.options;
|
||||
|
||||
$: x1 = options.coordinates ? options.coordinates.x1 : undefined;
|
||||
$: x2 = options.coordinates ? options.coordinates.x2 : undefined;
|
||||
$: y1 = options.coordinates ? options.coordinates.y1 : undefined;
|
||||
$: y2 = options.coordinates ? options.coordinates.y2 : undefined;
|
||||
|
||||
// TODO: when getTimebuckets support withArchived
|
||||
const isArchived = false;
|
||||
const withStacked = undefined;
|
||||
$: withPartners = options.assetGridOptions ? options.assetGridOptions.withPartners : true;
|
||||
$: isFavorite = options.assetGridOptions?.onlyFavorites ? true : undefined;
|
||||
$: showCustomSidebar = options.previousRoute !== undefined || options.coordinates !== undefined;
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
|
||||
$: assetStore = new AssetStore({ isArchived, withStacked, withPartners, isFavorite, x1, x2, y1, y2 });
|
||||
const assetInteractionStore = createAssetInteractionStore();
|
||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||
|
||||
|
@ -63,6 +83,24 @@
|
|||
onDestroy(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
const closePreviousRoute = async () => {
|
||||
const newUrl = new URL($page.url);
|
||||
if (options.previousRoute) {
|
||||
newUrl.searchParams.delete(QueryParameter.PREVIOUS_ROUTE);
|
||||
}
|
||||
if (options.coordinates) {
|
||||
newUrl.searchParams.delete(QueryParameter.COORDINATESX1);
|
||||
newUrl.searchParams.delete(QueryParameter.COORDINATESX2);
|
||||
newUrl.searchParams.delete(QueryParameter.COORDINATESY1);
|
||||
newUrl.searchParams.delete(QueryParameter.COORDINATESY2);
|
||||
}
|
||||
if (options.assetGridOptions) {
|
||||
newUrl.searchParams.delete(QueryParameter.ASSET_GRID_OPTIONS);
|
||||
}
|
||||
|
||||
options = {};
|
||||
await goto(newUrl);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $isMultiSelectState}
|
||||
|
@ -103,18 +141,30 @@
|
|||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
<UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
on:escape={handleEscape}
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
<MemoryLane />
|
||||
<UserPageLayout {showCustomSidebar} hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
|
||||
<slot slot="customSidebar">
|
||||
{#if options.previousRoute}
|
||||
<a href={options.previousRoute}>
|
||||
<SideBarLink title={$t('previous')} routeId={`/(user)${options.previousRoute}`} icon={mdiArrowLeft} />
|
||||
</a>
|
||||
{/if}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
|
||||
</AssetGrid>
|
||||
{#if options.previousRoute !== undefined || options.coordinates !== undefined}
|
||||
<SideBarButton title={$t('close')} icon={mdiClose} moreInformation={false} onClick={closePreviousRoute} />
|
||||
{/if}
|
||||
</slot>
|
||||
{#key options}
|
||||
<AssetGrid
|
||||
enableRouting={true}
|
||||
{assetStore}
|
||||
{assetInteractionStore}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
on:escape={handleEscape}
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
<MemoryLane />
|
||||
{/if}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
|
||||
</AssetGrid>
|
||||
{/key}
|
||||
</UserPageLayout>
|
||||
|
|
|
@ -1,17 +1,49 @@
|
|||
import {
|
||||
AssetGridOptionsValues,
|
||||
createAssetGridOptionsFromArray,
|
||||
QueryParameter,
|
||||
type IQueryParameter,
|
||||
} from '$lib/constants';
|
||||
import { checkEnumInArray, parseNumberOrUndefined } from '$lib/utils';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import { getAssetInfoFromParam, isExternalUrl } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
export const load = (async ({ params, url }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
const $t = await getFormatter();
|
||||
const options: IQueryParameter = {};
|
||||
|
||||
const getPreviousRoute = url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
|
||||
if (getPreviousRoute && !isExternalUrl(decodeURIComponent(getPreviousRoute))) {
|
||||
options.previousRoute = decodeURIComponent(getPreviousRoute);
|
||||
}
|
||||
|
||||
const assetGridOptionss = url.searchParams.get(QueryParameter.ASSET_GRID_OPTIONS)?.split(',');
|
||||
if (assetGridOptionss && checkEnumInArray(assetGridOptionss, AssetGridOptionsValues)) {
|
||||
options.assetGridOptions = createAssetGridOptionsFromArray(assetGridOptionss);
|
||||
}
|
||||
|
||||
const x1 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESX1));
|
||||
const x2 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESX2));
|
||||
const y1 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESY1));
|
||||
const y2 = parseNumberOrUndefined(url.searchParams.get(QueryParameter.COORDINATESY2));
|
||||
if (x1 && x2 && y1 && y2) {
|
||||
options.coordinates = {
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: $t('photos'),
|
||||
},
|
||||
options,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
|
Loading…
Reference in a new issue