mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
chore(web): rename components to PascalCase (#28013)
chore: rename components to PascalCase
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
<script lang="ts" module>
|
||||
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url';
|
||||
import { addProtocol, setRTLTextPlugin } from 'maplibre-gl';
|
||||
import { Protocol } from 'pmtiles';
|
||||
|
||||
let protocol = new Protocol();
|
||||
void addProtocol('pmtiles', protocol.tile);
|
||||
void setRTLTextPlugin(mapboxRtlUrl, true);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
|
||||
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { Icon, modalManager, Theme, themeManager } from '@immich/ui';
|
||||
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import {
|
||||
GlobeControl,
|
||||
LngLat,
|
||||
LngLatBounds,
|
||||
Marker,
|
||||
type GeoJSONSource,
|
||||
type LngLatLike,
|
||||
type Map,
|
||||
type MapMouseEvent,
|
||||
} from 'maplibre-gl';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import {
|
||||
AttributionControl,
|
||||
Control,
|
||||
ControlButton,
|
||||
ControlGroup,
|
||||
FullscreenControl,
|
||||
GeoJSON,
|
||||
GeolocateControl,
|
||||
MapLibre,
|
||||
MarkerLayer,
|
||||
NavigationControl,
|
||||
Popup,
|
||||
ScaleControl,
|
||||
} from 'svelte-maplibre';
|
||||
import type { SelectionBBox } from './types';
|
||||
|
||||
interface Props {
|
||||
mapMarkers?: MapMarkerResponseDto[];
|
||||
showSettings?: boolean;
|
||||
zoom?: number | undefined;
|
||||
center?: LngLatLike | undefined;
|
||||
hash?: boolean;
|
||||
simplified?: boolean;
|
||||
clickable?: boolean;
|
||||
useLocationPin?: boolean;
|
||||
onOpenInMapView?: (() => Promise<void> | void) | undefined;
|
||||
onSelect?: (assetIds: string[]) => void;
|
||||
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
|
||||
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
|
||||
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
|
||||
rounded?: boolean;
|
||||
showSimpleControls?: boolean;
|
||||
autoFitBounds?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
mapMarkers = $bindable(),
|
||||
showSettings = true,
|
||||
zoom = undefined,
|
||||
center = $bindable(undefined),
|
||||
hash = false,
|
||||
simplified = false,
|
||||
clickable = false,
|
||||
useLocationPin = false,
|
||||
onOpenInMapView = undefined,
|
||||
onSelect = () => {},
|
||||
onClusterSelect,
|
||||
onClickPoint = () => {},
|
||||
popup,
|
||||
rounded = false,
|
||||
showSimpleControls = true,
|
||||
autoFitBounds = true,
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate initial bounds from markers once during initialization
|
||||
const initialBounds = (() => {
|
||||
if (!autoFitBounds || center || zoom !== undefined || !mapMarkers || mapMarkers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bounds = new LngLatBounds();
|
||||
for (const marker of mapMarkers) {
|
||||
bounds.extend([marker.lon, marker.lat]);
|
||||
}
|
||||
return bounds;
|
||||
})();
|
||||
|
||||
let map: Map | undefined = $state();
|
||||
let marker: Marker | null = null;
|
||||
let abortController: AbortController;
|
||||
|
||||
const mapTheme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.Light);
|
||||
const styleUrl = $derived(
|
||||
mapTheme === Theme.Dark ? serverConfigManager.value.mapDarkStyleUrl : serverConfigManager.value.mapLightStyleUrl,
|
||||
);
|
||||
|
||||
export function addClipMapMarker(lng: number, lat: number) {
|
||||
if (map) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
center = { lng, lat };
|
||||
marker = new Marker().setLngLat([lng, lat]).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAssetClick(assetId: string, map: Map | null) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
onSelect([assetId]);
|
||||
}
|
||||
|
||||
async function handleClusterClick(clusterId: number, map: Map | null) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapSource = map.getSource('geojson') as GeoJSONSource;
|
||||
const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0);
|
||||
const ids = leaves.map((leaf) => leaf.properties?.id as string);
|
||||
|
||||
if (onClusterSelect && ids.length > 1) {
|
||||
const [firstLongitude, firstLatitude] = (leaves[0].geometry as Point).coordinates;
|
||||
let west = firstLongitude;
|
||||
let south = firstLatitude;
|
||||
let east = firstLongitude;
|
||||
let north = firstLatitude;
|
||||
|
||||
for (const leaf of leaves.slice(1)) {
|
||||
const [longitude, latitude] = (leaf.geometry as Point).coordinates;
|
||||
west = Math.min(west, longitude);
|
||||
south = Math.min(south, latitude);
|
||||
east = Math.max(east, longitude);
|
||||
north = Math.max(north, latitude);
|
||||
}
|
||||
|
||||
const bbox = { west, south, east, north };
|
||||
onClusterSelect(ids, bbox);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(ids);
|
||||
}
|
||||
|
||||
function handleMapClick(event: MapMouseEvent) {
|
||||
if (clickable) {
|
||||
const { lng, lat } = event.lngLat;
|
||||
onClickPoint({ lng, lat });
|
||||
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
if (map) {
|
||||
marker = new Marker().setLngLat([lng, lat]).addTo(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FeaturePoint = Feature<Point, { id: string; city: string | null; state: string | null; country: string | null }>;
|
||||
|
||||
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
|
||||
properties: {
|
||||
id: marker.id,
|
||||
city: marker.city,
|
||||
state: marker.state,
|
||||
country: marker.country,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const asMarker = (feature: Feature<Geometry, GeoJsonProperties>): MapMarkerResponseDto => {
|
||||
const featurePoint = feature as FeaturePoint;
|
||||
const coords = LngLat.convert(featurePoint.geometry.coordinates as [number, number]);
|
||||
return {
|
||||
lat: coords.lat,
|
||||
lon: coords.lng,
|
||||
id: featurePoint.properties.id,
|
||||
city: featurePoint.properties.city,
|
||||
state: featurePoint.properties.state,
|
||||
country: featurePoint.properties.country,
|
||||
};
|
||||
};
|
||||
|
||||
function getFileCreatedDates() {
|
||||
const { relativeDate, dateAfter, dateBefore } = $mapSettings;
|
||||
|
||||
if (relativeDate) {
|
||||
const duration = Duration.fromISO(relativeDate);
|
||||
return {
|
||||
fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
|
||||
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
|
||||
};
|
||||
} catch {
|
||||
$mapSettings.dateAfter = '';
|
||||
$mapSettings.dateBefore = '';
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMapMarkers() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
abortController = new AbortController();
|
||||
|
||||
const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
|
||||
const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
|
||||
|
||||
return await getMapMarkers(
|
||||
{
|
||||
isArchived: includeArchived || undefined,
|
||||
isFavorite: onlyFavorites || undefined,
|
||||
fileCreatedAfter: fileCreatedAfter || undefined,
|
||||
fileCreatedBefore,
|
||||
withPartners: withPartners || undefined,
|
||||
withSharedAlbums: withSharedAlbums || undefined,
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSettingsClick = async () => {
|
||||
const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
|
||||
if (settings) {
|
||||
const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
|
||||
$mapSettings = settings;
|
||||
|
||||
if (shouldUpdate) {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
afterNavigate(() => {
|
||||
if (map) {
|
||||
map.resize();
|
||||
|
||||
if (globalThis.location.hash) {
|
||||
const hashChangeEvent = new HashChangeEvent('hashchange');
|
||||
globalThis.dispatchEvent(hashChangeEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!mapMarkers) {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
map?.setStyle(styleUrl, {
|
||||
transformStyle: (previousStyle, nextStyle) => {
|
||||
if (previousStyle) {
|
||||
// Preserves the custom map markers from the previous style when the theme is switched
|
||||
// Required until https://github.com/dimfeld/svelte-maplibre/issues/146 is fixed
|
||||
const customLayers = previousStyle.layers.filter((l) => l.type == 'fill' && l.source == 'geojson');
|
||||
const layers = nextStyle.layers.concat(customLayers);
|
||||
const sources = nextStyle.sources;
|
||||
|
||||
for (const [key, value] of Object.entries(previousStyle.sources || {})) {
|
||||
if (key.startsWith('geojson')) {
|
||||
sources[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...nextStyle,
|
||||
sources,
|
||||
layers,
|
||||
};
|
||||
}
|
||||
return nextStyle;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!center || !zoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => map?.jumpTo({ center, zoom }));
|
||||
});
|
||||
|
||||
const onAssetsDelete = async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{zoom}
|
||||
{center}
|
||||
bounds={initialBounds}
|
||||
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
onload={(event: Map) => {
|
||||
event.setMaxZoom(18);
|
||||
event.on('click', handleMapClick);
|
||||
if (!simplified) {
|
||||
event.addControl(new GlobeControl(), 'top-left');
|
||||
}
|
||||
}}
|
||||
bind:map
|
||||
>
|
||||
{#snippet children({ map }: { map: Map })}
|
||||
{#if showSimpleControls}
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={handleSettingsClick}>
|
||||
<Icon icon={mdiCog} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
{#if onOpenInMapView && showSimpleControls}
|
||||
<Control position="top-right">
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={() => onOpenInMapView()}>
|
||||
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
<GeoJSON
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
{/snippet}
|
||||
</MapLibre>
|
||||
Reference in New Issue
Block a user