mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(web+server): map date filters + small changes (#2565)
This commit is contained in:
@@ -2,16 +2,24 @@
|
||||
export interface MapSettings {
|
||||
allowDarkMode: boolean;
|
||||
onlyFavorites: boolean;
|
||||
relativeDate: string;
|
||||
dateAfter: string;
|
||||
dateBefore: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import { Duration } from 'luxon';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import SettingSelect from '../admin-page/settings/setting-select.svelte';
|
||||
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import LinkButton from '../elements/buttons/link-button.svelte';
|
||||
|
||||
export let settings: MapSettings;
|
||||
let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
@@ -27,9 +35,90 @@
|
||||
Map Settings
|
||||
</h1>
|
||||
|
||||
<form on:submit|preventDefault={() => dispatch('save', settings)} class="flex flex-col gap-4">
|
||||
<form
|
||||
on:submit|preventDefault={() => dispatch('save', settings)}
|
||||
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
|
||||
<SettingSwitch title="Show only favorites" bind:checked={settings.onlyFavorites} />
|
||||
<SettingSwitch title="Only favorites" bind:checked={settings.onlyFavorites} />
|
||||
{#if customDateRange}
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
||||
<div class="flex justify-between items-center gap-8">
|
||||
<label class="immich-form-label text-sm shrink-0" for="date-after">Date after</label>
|
||||
<input
|
||||
class="immich-form-input w-40"
|
||||
type="date"
|
||||
id="date-after"
|
||||
max={settings.dateBefore}
|
||||
bind:value={settings.dateAfter}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center gap-8">
|
||||
<label class="immich-form-label text-sm shrink-0" for="date-before">Date before</label>
|
||||
<input
|
||||
class="immich-form-input w-40"
|
||||
type="date"
|
||||
id="date-before"
|
||||
bind:value={settings.dateBefore}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center text-xs">
|
||||
<LinkButton
|
||||
on:click={() => {
|
||||
customDateRange = false;
|
||||
settings.dateAfter = '';
|
||||
settings.dateBefore = '';
|
||||
}}
|
||||
>
|
||||
Remove custom date range
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
|
||||
<SettingSelect
|
||||
label="Date range"
|
||||
name="date-range"
|
||||
bind:value={settings.relativeDate}
|
||||
options={[
|
||||
{
|
||||
value: '',
|
||||
text: 'All'
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ hours: 24 }).toISO(),
|
||||
text: 'Past 24 hours'
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ days: 7 }).toISO(),
|
||||
text: 'Past 7 days'
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ days: 30 }).toISO(),
|
||||
text: 'Past 30 days'
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ years: 1 }).toISO(),
|
||||
text: 'Past year'
|
||||
},
|
||||
{
|
||||
value: Duration.fromObject({ years: 3 }).toISO(),
|
||||
text: 'Past 3 years'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<div class="text-xs">
|
||||
<LinkButton
|
||||
on:click={() => {
|
||||
customDateRange = true;
|
||||
settings.relativeDate = '';
|
||||
}}
|
||||
>
|
||||
Use custom date range instead
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full gap-4 mt-4">
|
||||
<Button color="gray" size="sm" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as AssetMarkerCluster } from './asset-marker-cluster.svelte';
|
||||
export { default as AssetMarkerCluster } from './marker-cluster/asset-marker-cluster.svelte';
|
||||
export { default as Control } from './control.svelte';
|
||||
export { default as Map } from './map.svelte';
|
||||
export { default as Marker } from './marker.svelte';
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user