Squashed commit of the following:
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.
This commit is contained in:
Kaimbacher 2026-06-09 09:35:15 +02:00
commit 9368a0dd8d
38 changed files with 5588 additions and 6181 deletions

View file

@ -1,23 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, Ref, defineProps, computed } from 'vue';
import { onMounted, onUnmounted, ref, Ref, computed } from 'vue';
import SectionMain from '@/Components/SectionMain.vue';
import { Map } from 'leaflet/src/map/index';
import { Rectangle } from 'leaflet';
import { canvas } from 'leaflet/src/layer/vector/Canvas';
import { svg } from 'leaflet/src/layer/vector/SVG';
import axios from 'axios';
import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
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 { Attribution } from 'leaflet/src/control/Control.Attribution';
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 { LayerGroup } from 'leaflet/src/layer/LayerGroup';
import { OpensearchDocument } from '@/Dataset';
Map.include({
getRenderer: function (layer) {
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
/**
* 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();
@ -29,12 +51,12 @@ Map.include({
return renderer;
},
_getPaneRenderer: function (name) {
_getPaneRenderer(this: RendererCapableMap, name?: string): Renderer | false {
if (name === 'overlayPane' || name === undefined) {
return false;
}
var renderer = this._paneRenderers[name];
let renderer = this._paneRenderers[name];
if (renderer === undefined) {
renderer = this._createRenderer({ pane: name });
this._paneRenderers[name] = renderer;
@ -42,16 +64,15 @@ Map.include({
return renderer;
},
_createRenderer: function (options) {
return (this.options.preferCanvas && canvas(options)) || svg(options);
_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 = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
const OPENSEARCH_HOST = 'https://catalog.geosphere.at';
let map: Map;
let map: LeafletMap;
const props = defineProps({
checkable: Boolean,
@ -63,6 +84,12 @@ const props = defineProps({
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: () => ({
@ -74,7 +101,9 @@ const props = defineProps({
},
});
const items = computed({
const OPENSEARCH_HOST = computed(() => `${window.location.protocol}//${props.opensearchHost}:9200`);
const items = computed<OpensearchDocument[]>({
get() {
return props.datasets;
},
@ -84,19 +113,43 @@ const items = computed({
},
});
const resultCount = computed(() => items.value.length);
const fitBounds: LatLngBoundsExpression = [
[46.4318173285, 9.47996951665],
[49.0390742051, 16.9796667823],
];
const drawControl: Ref<DrawControlComponent | null> = ref(null);
const southWest = ref(null);
const northEast = ref(null);
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();
});
@ -105,8 +158,8 @@ onUnmounted(() => {
map.off('zoomend zoomlevelschange');
});
const initMap = async () => {
map = new Map('map', props.mapOptions);
const initMap = async (): Promise<void> => {
map = new LeafletMap('map', props.mapOptions as MapOptions);
mapService.setMap(props.mapId, map);
map.scrollWheelZoom.disable();
map.fitBounds(fitBounds);
@ -114,37 +167,39 @@ const initMap = async () => {
map.addLayer(filterLayerGroup);
const attributionControl = new Attribution().addTo(map);
const attributionControl = new Control.Attribution().addTo(map);
attributionControl.setPrefix(false);
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
const osmGray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
format: 'image/png',
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
layers: 'OSM-WMS',
});
let layerOptions = {
const layerOptions = {
label: DEFAULT_BASE_LAYER_NAME,
visible: true,
layer: osmGgray,
layer: osmGray,
};
layerOptions.layer.addTo(map);
map.on('Draw.Event.CREATED', handleDrawEventCreated);
map.on('Draw.Event.CREATED', handleDrawEventCreated as L.LeafletEventHandlerFn);
};
const handleDrawEventCreated = async (event) => {
const handleDrawEventCreated = async (event: DrawCreatedEvent): Promise<void> => {
isLoading.value = true;
hasSearched.value = true;
filterLayerGroup.clearLayers();
items.value = [];
let layer = event.layer;
let bounds = layer.getBounds();
const bounds: LatLngBounds = event.layer.getBounds();
try {
let response = await axios({
// 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 + '/tethys-records/_search',
url: OPENSEARCH_HOST.value + '/tethys-records/_search',
headers: { 'Content-Type': 'application/json' },
data: {
size: 1000,
@ -172,17 +227,17 @@ const handleDrawEventCreated = async (event) => {
},
});
response.data.hits.hits.forEach((hit) => {
let xMin = hit._source.bbox_xmin;
let xMax = hit._source.bbox_xmax;
let yMin = hit._source.bbox_ymin;
let yMax = hit._source.bbox_ymax;
var bbox: LatLngBoundsExpression = [
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],
];
let rect = new Rectangle(bbox, {
const rect = new Rectangle(bbox, {
color: '#65DC21',
weight: 2,
fillColor: '#65DC21',
@ -193,7 +248,14 @@ const handleDrawEventCreated = async (event) => {
items.value.push(hit._source);
});
} catch (error) {
console.error(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;
}
@ -202,74 +264,177 @@ const handleDrawEventCreated = async (event) => {
<template>
<SectionMain>
<div class="map-container-wrapper">
<!-- Loading Overlay -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p class="loading-text">Searching datasets...</p>
<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>
<!-- Map Instructions Banner -->
<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> Use the drawing tool to select an area on the map and discover datasets
</p>
</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>
<div id="map" class="map-container">
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
</DrawControlComponent>
<!-- 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-container-wrapper {
position: relative;
border-radius: 1rem;
.map-shell {
border-radius: 1.25rem;
overflow: hidden;
background: white;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
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: #1f2937;
background: #111827;
}
/* Map Instructions Banner */
/* Floating instruction chip */
.map-instructions {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 500;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
border-bottom: 2px solid #e5e7eb;
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: linear-gradient(135deg, rgba(101, 220, 33, 0.2) 0%, rgba(53, 124, 6, 0.2) 100%);
border-bottom-color: #374151;
background: rgba(31, 41, 55, 0.8);
border-color: rgba(255, 255, 255, 0.08);
}
.instruction-icon {
width: 1.5rem;
height: 1.5rem;
width: 1.15rem;
height: 1.15rem;
color: #65dc21;
flex-shrink: 0;
}
.instruction-text {
font-size: 0.875rem;
font-size: 0.8125rem;
color: #4b5563;
margin: 0;
white-space: nowrap;
}
.dark .instruction-text {
@ -277,28 +442,53 @@ const handleDrawEventCreated = async (event) => {
}
.instruction-text strong {
color: #4d9e1a;
font-weight: 700;
}
.dark .instruction-text strong {
color: #65dc21;
font-weight: 600;
}
/* 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;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
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(4px);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dark .loading-overlay {
background: rgba(31, 41, 55, 0.95);
background: rgba(17, 24, 39, 0.9);
}
.loading-spinner {
@ -325,6 +515,10 @@ const handleDrawEventCreated = async (event) => {
margin-top: 1rem;
font-size: 0.875rem;
font-weight: 600;
color: #4d9e1a;
}
.dark .loading-text {
color: #65dc21;
}
@ -369,15 +563,14 @@ const handleDrawEventCreated = async (event) => {
/* Control Enhancements */
:deep(.leaflet-control) {
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
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.5rem;
border-radius: 0;
transition: all 0.2s ease;
}
@ -402,34 +595,50 @@ const handleDrawEventCreated = async (event) => {
/* Popup Enhancements */
:deep(.leaflet-popup-content-wrapper) {
border-radius: 0.75rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
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 {
height: 400px;
}
.map-instructions {
padding: 0.75rem 1rem;
.map-container,
:deep(.leaflet-container) {
height: 420px;
}
.instruction-text {
font-size: 0.8125rem;
white-space: normal;
}
}
@media (max-width: 640px) {
.map-container {
height: 350px;
.map-container,
:deep(.leaflet-container) {
height: 360px;
}
}
</style>