All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 40s
commit 579f0878e5240dc17db69be1e0b0c0f5af7ef9fe
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date: Tue Jun 9 09:25:44 2026 +0200
feat: Refactor error handling in Dataset Edit form and improve validation messages
- Updated error handling in the Dataset Edit form to use a centralized formatError function for displaying validation messages.
- Enhanced user feedback by ensuring that error messages are displayed consistently across various fields.
- Modified the validation rule for arrayContainsTypes to provide clearer error messages for missing main and translated titles/abstracts.
- Introduced a new ValidationService to manage manual construction of validation errors.
- Updated Vite configuration to streamline asset loading and improve performance.
- Adjusted Inertia setup to utilize dynamic imports for page-specific assets.
- Cleaned up unnecessary comments and code in various files for better readability.
commit 5efddc2a58c0e164fef585cc7344c06155dbc2c1
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date: Mon Jan 12 17:02:47 2026 +0100
feat: add dataset change detection and form submission composables
- Implemented `useDatasetChangeDetection` for tracking unsaved changes in dataset forms, including comparisons for licenses, basic properties, files, coverage, and more.
- Added `useDatasetFormSubmission` for handling dataset form submissions with validation, success/error handling, and auto-save functionality.
644 lines
16 KiB
Vue
644 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, ref, Ref, computed } from 'vue';
|
|
import SectionMain from '@/Components/SectionMain.vue';
|
|
import L, {
|
|
Map as LeafletMap,
|
|
Rectangle,
|
|
LayerGroup,
|
|
Control,
|
|
type Layer,
|
|
type LatLng,
|
|
type LatLngBounds,
|
|
type LatLngBoundsExpression,
|
|
type MapOptions,
|
|
type Renderer,
|
|
type RendererOptions,
|
|
type LeafletEvent,
|
|
} from 'leaflet';
|
|
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
|
|
import axios from 'axios';
|
|
import DrawControlComponent from '@/Components/Map/draw.component.vue';
|
|
import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
|
|
import { MapService } from '@/Stores/map.service';
|
|
import { OpensearchDocument } from '@/Dataset';
|
|
|
|
/**
|
|
* Leaflet's internal renderer machinery is not part of the public @types/leaflet
|
|
* surface, so we describe the bits we touch here. This keeps the mixin body typed
|
|
* instead of falling back to `any`.
|
|
*/
|
|
interface RendererCapableMap extends LeafletMap {
|
|
options: MapOptions & { renderer?: Renderer; preferCanvas?: boolean };
|
|
_renderer?: Renderer;
|
|
_paneRenderers: Record<string, Renderer | undefined>;
|
|
_getPaneRenderer(name?: string): Renderer | false;
|
|
_createRenderer(options?: RendererOptions): Renderer;
|
|
}
|
|
|
|
LeafletMap.include({
|
|
getRenderer(this: RendererCapableMap, layer: Layer): Renderer {
|
|
const layerOptions = layer.options as { renderer?: Renderer; pane?: string };
|
|
let renderer: Renderer | false | undefined =
|
|
layerOptions.renderer || this._getPaneRenderer(layerOptions.pane) || this.options.renderer || this._renderer;
|
|
|
|
if (!renderer) {
|
|
renderer = this._renderer = this._createRenderer();
|
|
}
|
|
|
|
if (!this.hasLayer(renderer)) {
|
|
this.addLayer(renderer);
|
|
}
|
|
return renderer;
|
|
},
|
|
|
|
_getPaneRenderer(this: RendererCapableMap, name?: string): Renderer | false {
|
|
if (name === 'overlayPane' || name === undefined) {
|
|
return false;
|
|
}
|
|
|
|
let renderer = this._paneRenderers[name];
|
|
if (renderer === undefined) {
|
|
renderer = this._createRenderer({ pane: name });
|
|
this._paneRenderers[name] = renderer;
|
|
}
|
|
return renderer;
|
|
},
|
|
|
|
_createRenderer(this: RendererCapableMap, options?: RendererOptions): Renderer {
|
|
return (this.options.preferCanvas && L.canvas(options)) || L.svg(options);
|
|
},
|
|
});
|
|
|
|
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
|
|
const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
|
|
|
let map: LeafletMap;
|
|
|
|
const props = defineProps({
|
|
checkable: Boolean,
|
|
datasets: {
|
|
type: Array<OpensearchDocument>,
|
|
default: () => [],
|
|
},
|
|
mapId: {
|
|
type: String,
|
|
default: 'map',
|
|
},
|
|
// OpenSearch host is provided by the server (prop / shared data), never imported
|
|
// from server-only modules into client code.
|
|
opensearchHost: {
|
|
type: String,
|
|
default: 'localhost',
|
|
},
|
|
mapOptions: {
|
|
type: Object,
|
|
default: () => ({
|
|
center: [48.208174, 16.373819],
|
|
zoom: 3,
|
|
zoomControl: false,
|
|
attributionControl: false,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const OPENSEARCH_HOST = computed(() => `${window.location.protocol}//${props.opensearchHost}:9200`);
|
|
|
|
const items = computed<OpensearchDocument[]>({
|
|
get() {
|
|
return props.datasets;
|
|
},
|
|
set(value) {
|
|
props.datasets.length = 0;
|
|
props.datasets.push(...value);
|
|
},
|
|
});
|
|
|
|
const resultCount = computed(() => items.value.length);
|
|
|
|
const fitBounds: LatLngBoundsExpression = [
|
|
[46.4318173285, 9.47996951665],
|
|
[49.0390742051, 16.9796667823],
|
|
];
|
|
|
|
const drawControl = ref<InstanceType<typeof DrawControlComponent> | null>(null);
|
|
const southWest = ref<LatLng | null>(null);
|
|
const northEast = ref<LatLng | null>(null);
|
|
const mapService = MapService();
|
|
const isLoading = ref(false);
|
|
const hasSearched = ref(false);
|
|
|
|
const filterLayerGroup = new LayerGroup();
|
|
|
|
/** Minimal shape of the leaflet-draw "created" event (no @types/leaflet-draw needed). */
|
|
interface DrawCreatedEvent extends LeafletEvent {
|
|
layer: Layer & { getBounds(): LatLngBounds };
|
|
layerType: string;
|
|
}
|
|
|
|
/** Shape of the OpenSearch _search response we rely on. */
|
|
interface OpenSearchHit {
|
|
_source: OpensearchDocument & {
|
|
bbox_xmin: number;
|
|
bbox_xmax: number;
|
|
bbox_ymin: number;
|
|
bbox_ymax: number;
|
|
};
|
|
}
|
|
interface OpenSearchSearchResponse {
|
|
hits: {
|
|
hits: OpenSearchHit[];
|
|
};
|
|
}
|
|
|
|
onMounted(() => {
|
|
initMap();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
map.off('zoomend zoomlevelschange');
|
|
});
|
|
|
|
const initMap = async (): Promise<void> => {
|
|
map = new LeafletMap('map', props.mapOptions as MapOptions);
|
|
mapService.setMap(props.mapId, map);
|
|
map.scrollWheelZoom.disable();
|
|
map.fitBounds(fitBounds);
|
|
drawControl.value?.toggleDraw();
|
|
|
|
map.addLayer(filterLayerGroup);
|
|
|
|
const attributionControl = new Control.Attribution().addTo(map);
|
|
attributionControl.setPrefix(false);
|
|
|
|
const osmGray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
|
|
format: 'image/png',
|
|
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
|
|
layers: 'OSM-WMS',
|
|
});
|
|
|
|
const layerOptions = {
|
|
label: DEFAULT_BASE_LAYER_NAME,
|
|
visible: true,
|
|
layer: osmGray,
|
|
};
|
|
layerOptions.layer.addTo(map);
|
|
|
|
map.on('Draw.Event.CREATED', handleDrawEventCreated as L.LeafletEventHandlerFn);
|
|
};
|
|
|
|
const handleDrawEventCreated = async (event: DrawCreatedEvent): Promise<void> => {
|
|
isLoading.value = true;
|
|
hasSearched.value = true;
|
|
filterLayerGroup.clearLayers();
|
|
items.value = [];
|
|
|
|
const bounds: LatLngBounds = event.layer.getBounds();
|
|
|
|
try {
|
|
// NOTE: OpenSearch _search with a query body must be POST — browsers/XHR
|
|
// drop the request body on GET, which would send an empty query.
|
|
const response = await axios<OpenSearchSearchResponse>({
|
|
method: 'POST',
|
|
url: OPENSEARCH_HOST.value + '/tethys-records/_search',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
data: {
|
|
size: 1000,
|
|
query: {
|
|
bool: {
|
|
must: {
|
|
match_all: {},
|
|
},
|
|
filter: {
|
|
geo_shape: {
|
|
geo_location: {
|
|
shape: {
|
|
type: 'envelope',
|
|
coordinates: [
|
|
[bounds.getSouthWest().lng, bounds.getNorthEast().lat],
|
|
[bounds.getNorthEast().lng, bounds.getSouthWest().lat],
|
|
],
|
|
},
|
|
relation: 'within',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
response.data.hits.hits.forEach((hit: OpenSearchHit) => {
|
|
const xMin = hit._source.bbox_xmin;
|
|
const xMax = hit._source.bbox_xmax;
|
|
const yMin = hit._source.bbox_ymin;
|
|
const yMax = hit._source.bbox_ymax;
|
|
const bbox: LatLngBoundsExpression = [
|
|
[yMin, xMin],
|
|
[yMax, xMax],
|
|
];
|
|
|
|
const rect = new Rectangle(bbox, {
|
|
color: '#65DC21',
|
|
weight: 2,
|
|
fillColor: '#65DC21',
|
|
fillOpacity: 0.2,
|
|
className: 'animated-rectangle',
|
|
});
|
|
filterLayerGroup.addLayer(rect);
|
|
items.value.push(hit._source);
|
|
});
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
|
// Mapping in diesem Index unterstützt keine geo_shape-Suche
|
|
console.warn('Geo search unavailable for this index mapping');
|
|
// optional: ein dezentes Hinweis-Flag setzen, z. B. searchUnavailable.value = true
|
|
} else {
|
|
console.error(error);
|
|
}
|
|
items.value = [];
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<SectionMain>
|
|
<div class="map-shell">
|
|
<!-- Header -->
|
|
<div class="map-header">
|
|
<div class="map-header-title">
|
|
<span class="map-dot"></span>
|
|
<h2>Geospatial Discovery</h2>
|
|
</div>
|
|
<transition name="badge">
|
|
<div v-if="hasSearched && !isLoading" class="result-badge" :class="{ 'is-empty': resultCount === 0 }">
|
|
{{ resultCount }} {{ resultCount === 1 ? 'dataset' : 'datasets' }}
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
|
|
<div class="map-container-wrapper">
|
|
<!-- Loading Overlay -->
|
|
<transition name="fade">
|
|
<div v-if="isLoading" class="loading-overlay">
|
|
<div class="loading-spinner"></div>
|
|
<p class="loading-text">Searching datasets…</p>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Floating instruction chip -->
|
|
<div class="map-instructions">
|
|
<svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 16v-4M12 8h.01" />
|
|
</svg>
|
|
<p class="instruction-text"><strong>Tip:</strong> draw an area to discover datasets within it</p>
|
|
</div>
|
|
|
|
<!-- Floating "no results" hint -->
|
|
<transition name="fade">
|
|
<div v-if="hasSearched && !isLoading && resultCount === 0" class="empty-hint">
|
|
No datasets in the selected area — try a larger region.
|
|
</div>
|
|
</transition>
|
|
|
|
<div id="map" class="map-container">
|
|
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
|
|
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
|
|
</DrawControlComponent>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionMain>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.map-shell {
|
|
border-radius: 1.25rem;
|
|
overflow: hidden;
|
|
background: white;
|
|
box-shadow:
|
|
0 10px 30px -12px rgba(0, 0, 0, 0.25),
|
|
0 4px 8px -4px rgba(0, 0, 0, 0.1);
|
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.dark .map-shell {
|
|
background: #1f2937;
|
|
border-color: rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
/* Header */
|
|
.map-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 1.5rem;
|
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
|
color: white;
|
|
}
|
|
|
|
.map-header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
}
|
|
|
|
.map-header-title h2 {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.01em;
|
|
margin: 0;
|
|
}
|
|
|
|
.map-dot {
|
|
width: 0.6rem;
|
|
height: 0.6rem;
|
|
border-radius: 9999px;
|
|
background: #65dc21;
|
|
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0.6);
|
|
animation: pulseDot 2s infinite;
|
|
}
|
|
|
|
@keyframes pulseDot {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0.6);
|
|
}
|
|
70% {
|
|
box-shadow: 0 0 0 0.5rem rgba(101, 220, 33, 0);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0);
|
|
}
|
|
}
|
|
|
|
.result-badge {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
padding: 0.35rem 0.85rem;
|
|
border-radius: 9999px;
|
|
background: rgba(101, 220, 33, 0.15);
|
|
color: #a3f57c;
|
|
border: 1px solid rgba(101, 220, 33, 0.4);
|
|
}
|
|
|
|
.result-badge.is-empty {
|
|
background: rgba(148, 163, 184, 0.15);
|
|
color: #cbd5e1;
|
|
border-color: rgba(148, 163, 184, 0.35);
|
|
}
|
|
|
|
/* Map Container */
|
|
.map-container-wrapper {
|
|
position: relative;
|
|
background: #f9fafb;
|
|
}
|
|
|
|
.dark .map-container-wrapper {
|
|
background: #111827;
|
|
}
|
|
|
|
/* Floating instruction chip */
|
|
.map-instructions {
|
|
position: absolute;
|
|
top: 1rem;
|
|
left: 1rem;
|
|
z-index: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.65rem 1rem;
|
|
border-radius: 9999px;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.25);
|
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
|
max-width: calc(100% - 2rem);
|
|
}
|
|
|
|
.dark .map-instructions {
|
|
background: rgba(31, 41, 55, 0.8);
|
|
border-color: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.instruction-icon {
|
|
width: 1.15rem;
|
|
height: 1.15rem;
|
|
color: #65dc21;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.instruction-text {
|
|
font-size: 0.8125rem;
|
|
color: #4b5563;
|
|
margin: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dark .instruction-text {
|
|
color: #d1d5db;
|
|
}
|
|
|
|
.instruction-text strong {
|
|
color: #4d9e1a;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.dark .instruction-text strong {
|
|
color: #65dc21;
|
|
}
|
|
|
|
/* Floating empty hint */
|
|
.empty-hint {
|
|
position: absolute;
|
|
bottom: 1rem;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 500;
|
|
padding: 0.55rem 1.1rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.8125rem;
|
|
font-weight: 500;
|
|
color: #475569;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.25);
|
|
}
|
|
|
|
.dark .empty-hint {
|
|
background: rgba(31, 41, 55, 0.85);
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
/* Loading Overlay */
|
|
.loading-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
backdrop-filter: blur(6px);
|
|
-webkit-backdrop-filter: blur(6px);
|
|
}
|
|
|
|
.dark .loading-overlay {
|
|
background: rgba(17, 24, 39, 0.9);
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
border: 4px solid #e5e7eb;
|
|
border-top-color: #65dc21;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.dark .loading-spinner {
|
|
border-color: #374151;
|
|
border-top-color: #65dc21;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.loading-text {
|
|
margin-top: 1rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #4d9e1a;
|
|
}
|
|
|
|
.dark .loading-text {
|
|
color: #65dc21;
|
|
}
|
|
|
|
/* Map Container */
|
|
.map-container {
|
|
position: relative;
|
|
height: 600px;
|
|
width: 100%;
|
|
background: #f9fafb;
|
|
}
|
|
|
|
.dark .map-container {
|
|
background: #111827;
|
|
}
|
|
|
|
/* Leaflet Overrides */
|
|
:deep(.leaflet-container) {
|
|
height: 600px;
|
|
width: 100%;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
}
|
|
|
|
:deep(.leaflet-container .leaflet-pane) {
|
|
z-index: 30 !important;
|
|
}
|
|
|
|
/* Enhanced Rectangle Styling */
|
|
:deep(.animated-rectangle) {
|
|
animation: pulseRectangle 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulseRectangle {
|
|
0%,
|
|
100% {
|
|
opacity: 0.6;
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* Control Enhancements */
|
|
:deep(.leaflet-control) {
|
|
border-radius: 0.65rem;
|
|
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.2);
|
|
border: none;
|
|
overflow: hidden;
|
|
}
|
|
|
|
:deep(.leaflet-bar a) {
|
|
border-radius: 0;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
:deep(.leaflet-bar a:hover) {
|
|
background: #65dc21;
|
|
color: white;
|
|
}
|
|
|
|
:deep(.leaflet-draw-toolbar a) {
|
|
background: white;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.dark :deep(.leaflet-draw-toolbar a) {
|
|
background: #374151;
|
|
color: #d1d5db;
|
|
}
|
|
|
|
:deep(.leaflet-draw-toolbar a:hover) {
|
|
background: #65dc21;
|
|
}
|
|
|
|
/* Popup Enhancements */
|
|
:deep(.leaflet-popup-content-wrapper) {
|
|
border-radius: 0.85rem;
|
|
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
:deep(.leaflet-popup-tip) {
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* Transitions */
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.25s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.badge-enter-active,
|
|
.badge-leave-active {
|
|
transition: all 0.25s ease;
|
|
}
|
|
.badge-enter-from,
|
|
.badge-leave-to {
|
|
opacity: 0;
|
|
transform: scale(0.85);
|
|
}
|
|
|
|
/* Responsive Design */
|
|
@media (max-width: 768px) {
|
|
.map-container,
|
|
:deep(.leaflet-container) {
|
|
height: 420px;
|
|
}
|
|
|
|
.instruction-text {
|
|
white-space: normal;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.map-container,
|
|
:deep(.leaflet-container) {
|
|
height: 360px;
|
|
}
|
|
}
|
|
</style>
|