mirror of https://github.com/immich-app/immich.git
feat(web+server): map date filters + small changes (#2565)
parent
bcc2c34eef
commit
062e2eca6f
@ -1,10 +1,22 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { toBoolean } from 'apps/immich/src/utils/transform.util';
|
import { toBoolean } from 'apps/immich/src/utils/transform.util';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsBoolean, IsOptional } from 'class-validator';
|
import { IsBoolean, IsISO8601, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class MapMarkerDto {
|
export class MapMarkerDto {
|
||||||
|
@ApiProperty()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true, strictSeparator: true })
|
||||||
|
fileCreatedAfter?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true, strictSeparator: true })
|
||||||
|
fileCreatedBefore?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows optional values unlike the regular Between and uses MoreThanOrEqual
|
||||||
|
* or LessThanOrEqual when only one parameter is specified.
|
||||||
|
*/
|
||||||
|
export default function OptionalBetween<T>(from?: T, to?: T) {
|
||||||
|
if (from && to) {
|
||||||
|
return Between(from, to);
|
||||||
|
} else if (from) {
|
||||||
|
return MoreThanOrEqual(from);
|
||||||
|
} else if (to) {
|
||||||
|
return LessThanOrEqual(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,39 +0,0 @@
|
|||||||
.marker-cluster {
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.asset-marker-icon {
|
|
||||||
@apply rounded-full;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 1px solid rgb(69, 80, 169);
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
|
|
||||||
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
|
|
||||||
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-cluster div {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin-left: 5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
|
|
||||||
text-align: center;
|
|
||||||
@apply rounded-full;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
background-color: rgb(236, 237, 246);
|
|
||||||
border: 1px solid rgb(69, 80, 169);
|
|
||||||
|
|
||||||
color: rgb(69, 80, 169);
|
|
||||||
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .marker-cluster div {
|
|
||||||
background-color: #adcbfa;
|
|
||||||
border: 1px solid black;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-cluster span {
|
|
||||||
line-height: 40px;
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
<script lang="ts" context="module">
|
|
||||||
import { createContext } from '$lib/utils/context';
|
|
||||||
import { Icon, LeafletEvent, Marker, MarkerClusterGroup } from 'leaflet';
|
|
||||||
|
|
||||||
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
|
|
||||||
|
|
||||||
export const getClusterContext = () => {
|
|
||||||
return getContext()();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { MapMarkerResponseDto, api } from '@api';
|
|
||||||
import 'leaflet.markercluster';
|
|
||||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
|
||||||
import './asset-marker-cluster.css';
|
|
||||||
import { getMapContext } from './map.svelte';
|
|
||||||
|
|
||||||
class AssetMarker extends Marker {
|
|
||||||
constructor(private marker: MapMarkerResponseDto) {
|
|
||||||
super([marker.lat, marker.lon], {
|
|
||||||
icon: new Icon({
|
|
||||||
iconUrl: api.getAssetThumbnailUrl(marker.id),
|
|
||||||
iconRetinaUrl: api.getAssetThumbnailUrl(marker.id),
|
|
||||||
iconSize: [60, 60],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
popupAnchor: [1, -34],
|
|
||||||
tooltipAnchor: [16, -28],
|
|
||||||
shadowSize: [41, 41],
|
|
||||||
className: 'asset-marker-icon'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
this.on('click', this.onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick() {
|
|
||||||
dispatch('view', { assets: [this.marker.id] });
|
|
||||||
}
|
|
||||||
|
|
||||||
getAssetId(): string {
|
|
||||||
return this.marker.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ view: { assets: string[] } }>();
|
|
||||||
|
|
||||||
export let markers: MapMarkerResponseDto[];
|
|
||||||
|
|
||||||
const map = getMapContext();
|
|
||||||
|
|
||||||
let cluster: MarkerClusterGroup;
|
|
||||||
|
|
||||||
setClusterContext(() => cluster);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
cluster = new MarkerClusterGroup({
|
|
||||||
showCoverageOnHover: false,
|
|
||||||
zoomToBoundsOnClick: false,
|
|
||||||
spiderfyOnMaxZoom: false,
|
|
||||||
maxClusterRadius: 30,
|
|
||||||
spiderLegPolylineOptions: { opacity: 0 },
|
|
||||||
spiderfyDistanceMultiplier: 3
|
|
||||||
});
|
|
||||||
|
|
||||||
cluster.on('clusterclick', (event: LeafletEvent) => {
|
|
||||||
const ids = event.sourceTarget
|
|
||||||
.getAllChildMarkers()
|
|
||||||
.map((marker: AssetMarker) => marker.getAssetId());
|
|
||||||
dispatch('view', { assets: ids });
|
|
||||||
});
|
|
||||||
|
|
||||||
cluster.on('clustermouseover', (event: LeafletEvent) => {
|
|
||||||
if (event.sourceTarget.getChildCount() <= 10) {
|
|
||||||
event.sourceTarget.spiderfy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cluster.on('clustermouseout', (event: LeafletEvent) => {
|
|
||||||
event.sourceTarget.unspiderfy();
|
|
||||||
});
|
|
||||||
map.addLayer(cluster);
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (cluster) {
|
|
||||||
const leafletMarkers = markers.map((marker) => new AssetMarker(marker));
|
|
||||||
|
|
||||||
cluster.clearLayers();
|
|
||||||
cluster.addLayers(leafletMarkers);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (cluster) cluster.remove();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
.asset-marker-icon {
|
||||||
|
@apply rounded-full;
|
||||||
|
@apply object-cover;
|
||||||
|
@apply border;
|
||||||
|
@apply border-immich-primary;
|
||||||
|
@apply transition-all;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, rgba(0, 0, 0, 0.07) 0px 2px 4px,
|
||||||
|
rgba(0, 0, 0, 0.07) 0px 4px 8px, rgba(0, 0, 0, 0.07) 0px 8px 16px,
|
||||||
|
rgba(0, 0, 0, 0.07) 0px 16px 32px, rgba(0, 0, 0, 0.07) 0px 32px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-cluster-icon {
|
||||||
|
@apply h-full;
|
||||||
|
@apply w-full;
|
||||||
|
@apply flex;
|
||||||
|
@apply justify-center;
|
||||||
|
@apply items-center;
|
||||||
|
@apply rounded-full;
|
||||||
|
@apply font-bold;
|
||||||
|
@apply bg-violet-50;
|
||||||
|
@apply border;
|
||||||
|
@apply border-immich-primary;
|
||||||
|
@apply text-immich-primary;
|
||||||
|
box-shadow: rgba(5, 5, 122, 0.12) 0px 2px 4px 0px, rgba(4, 4, 230, 0.32) 0px 2px 16px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .map-dark .marker-cluster-icon {
|
||||||
|
@apply bg-blue-200;
|
||||||
|
@apply text-black;
|
||||||
|
@apply border-blue-200;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
import { createContext } from '$lib/utils/context';
|
||||||
|
import { MarkerClusterGroup } from 'leaflet';
|
||||||
|
|
||||||
|
const { get: getContext, set: setClusterContext } = createContext<() => MarkerClusterGroup>();
|
||||||
|
|
||||||
|
export const getClusterContext = () => {
|
||||||
|
return getContext()();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { MapMarkerResponseDto } from '@api';
|
||||||
|
import { DivIcon, LeafletEvent, LeafletMouseEvent, MarkerCluster, Point } from 'leaflet';
|
||||||
|
import 'leaflet.markercluster';
|
||||||
|
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||||
|
import { getMapContext } from '../map.svelte';
|
||||||
|
import AssetMarker from './asset-marker';
|
||||||
|
import './asset-marker-cluster.css';
|
||||||
|
|
||||||
|
export let markers: MapMarkerResponseDto[];
|
||||||
|
export let spiderfyLimit = 10;
|
||||||
|
let cluster: MarkerClusterGroup;
|
||||||
|
|
||||||
|
const map = getMapContext();
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
view: { assetIds: string[]; activeAssetIndex: number };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
setClusterContext(() => cluster);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
cluster = new MarkerClusterGroup({
|
||||||
|
showCoverageOnHover: false,
|
||||||
|
zoomToBoundsOnClick: false,
|
||||||
|
spiderfyOnMaxZoom: false,
|
||||||
|
maxClusterRadius: (zoom) => 80 - zoom * 2,
|
||||||
|
spiderLegPolylineOptions: { opacity: 0 },
|
||||||
|
spiderfyDistanceMultiplier: 3,
|
||||||
|
iconCreateFunction: (options) => {
|
||||||
|
const childCount = options.getChildCount();
|
||||||
|
const iconSize = childCount > spiderfyLimit ? 45 : 40;
|
||||||
|
|
||||||
|
return new DivIcon({
|
||||||
|
html: `<div class="marker-cluster-icon">${childCount}</div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: new Point(iconSize, iconSize)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cluster.on('clusterclick', (event: LeafletEvent) => {
|
||||||
|
const markerCluster: MarkerCluster = event.sourceTarget;
|
||||||
|
const childCount = markerCluster.getChildCount();
|
||||||
|
|
||||||
|
if (childCount > spiderfyLimit) {
|
||||||
|
const markers = markerCluster.getAllChildMarkers() as AssetMarker[];
|
||||||
|
onView(markers, markers[0].id);
|
||||||
|
} else {
|
||||||
|
markerCluster.spiderfy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cluster.on('click', (event: LeafletMouseEvent) => {
|
||||||
|
const marker: AssetMarker = event.sourceTarget;
|
||||||
|
const markerCluster = getClusterByMarker(marker);
|
||||||
|
const markers = markerCluster
|
||||||
|
? (markerCluster.getAllChildMarkers() as AssetMarker[])
|
||||||
|
: [marker];
|
||||||
|
|
||||||
|
onView(markers, marker.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer(cluster);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
const getClusterByMarker = (marker: any): MarkerCluster | undefined => {
|
||||||
|
const mapZoom = map.getZoom();
|
||||||
|
|
||||||
|
while (marker && marker._zoom !== mapZoom) {
|
||||||
|
marker = marker.__parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onView = (markers: AssetMarker[], activeAssetId: string) => {
|
||||||
|
const assetIds = markers.map((marker) => marker.id);
|
||||||
|
const activeAssetIndex = assetIds.indexOf(activeAssetId) || 0;
|
||||||
|
dispatch('view', { assetIds, activeAssetIndex });
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (cluster) {
|
||||||
|
const leafletMarkers = markers.map((marker) => new AssetMarker(marker));
|
||||||
|
|
||||||
|
cluster.clearLayers();
|
||||||
|
cluster.addLayers(leafletMarkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (cluster) cluster.remove();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { MapMarkerResponseDto, api } from '@api';
|
||||||
|
import { Marker, Map, Icon } from 'leaflet';
|
||||||
|
|
||||||
|
export default class AssetMarker extends Marker {
|
||||||
|
id: string;
|
||||||
|
private iconCreated = false;
|
||||||
|
|
||||||
|
constructor(marker: MapMarkerResponseDto) {
|
||||||
|
super([marker.lat, marker.lon]);
|
||||||
|
this.id = marker.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(map: Map) {
|
||||||
|
// Set icon when the marker gets actually added to the map. This only
|
||||||
|
// gets called for individual assets and when selecting a cluster, so
|
||||||
|
// creating an icon for every marker in advance is pretty wasteful.
|
||||||
|
if (!this.iconCreated) {
|
||||||
|
this.iconCreated = true;
|
||||||
|
this.setIcon(this.getIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onAdd(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon() {
|
||||||
|
return new Icon({
|
||||||
|
iconUrl: api.getAssetThumbnailUrl(this.id),
|
||||||
|
iconRetinaUrl: api.getAssetThumbnailUrl(this.id),
|
||||||
|
iconSize: [60, 60],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
tooltipAnchor: [16, -28],
|
||||||
|
shadowSize: [41, 41],
|
||||||
|
className: 'asset-marker-icon'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue