+Datenexport

This commit is contained in:
Simon Martens
2026-01-28 17:26:04 +01:00
parent de37145471
commit b0a57884bf
19 changed files with 3729 additions and 1931 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,59 @@
{{ $model := . }}
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
<div class="flex-col w-full">
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Datenexport</h1>
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
<i class="ri-question-line"></i>
Export von Daten u. Dateien als ZIP-Ordner. Die Exporte werden gespeichert und nach dem
Ablauf von sieben Tagen automatisch gelöscht.
</div>
</div>
</div>
<export-manager
class="block container-normal mx-auto px-8 mt-6"
data-run-url="/redaktion/exports/run/"
data-list-url="/redaktion/exports/list/"
data-delete-url="/redaktion/exports/delete/"
data-csrf="{{ $model.csrf_token }}">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<div class="flex flex-col gap-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Daten-Export erstellen</h2>
<p class="text-sm text-slate-600 mt-1">Sichert alle Daten der Tabellen als
XML-Dateien. Der Export läuft im Hintergrund.</p>
</div>
<div class="flex items-center gap-3">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="data">
<i class="ri-download-2-line"></i> Export starten
</button>
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Dateien sichern</h2>
<p class="text-sm text-slate-600 mt-1">Exportiert Bilder und Dateien als ZIP. Der
Export kann eine Weile in Anspruch nehmen und läuft ebenfalls im Hintergrund.</p>
</div>
<div class="flex items-center gap-3">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="files">
<i class="ri-folder-zip-line"></i> Dateien sichern
</button>
</div>
</div>
<div class="text-sm text-slate-600" data-role="status"></div>
<div class="">
<div class="flex items-center justify-between mb-4 border-b border-slate-200 pb-2">
<h3 class="text-base font-semibold text-slate-900">Letzte Exporte</h3>
<div class="text-xs text-slate-500">Aktualisiert automatisch</div>
</div>
<div class="flex flex-col gap-3" data-role="export-list">
{{ template "_export_list" $model }}
</div>
</div>
</div>
</export-manager>

View File

@@ -0,0 +1,97 @@
{{ $model := . }}
{{- if not $model.exports -}}
<div class="text-sm text-slate-500">Noch keine Exporte vorhanden.</div>
{{- else -}}
{{- range $_, $export := $model.exports -}}
<div class="rounded-xs border border-slate-200 bg-white p-4 shadow-sm" data-export-id="{{ $export.Id }}" data-export-status="{{ $export.Status }}">
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div class="flex items-center flex-wrap gap-2">
{{- if eq $export.Type "files" -}}
<span class="inline-flex items-center gap-1.5 rounded-xs bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-900">
<i class="ri-folder-zip-line"></i>
Dateien
</span>
{{- else -}}
<span class="inline-flex items-center gap-1.5 rounded-xs bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">
<i class="ri-database-2-line"></i>
Daten
</span>
{{- end -}}
<span class="text-xs text-slate-600">
{{ GermanDate $export.Created }} {{ GermanTime $export.Created }}
</span>
{{- if $export.Expires.IsZero | not -}}
<span class="inline-flex items-center gap-2 text-xs text-slate-600"><span class="inline-block w-[0.9ch] text-center">•</span>Läuft ab {{ GermanDate $export.Expires }} {{ GermanTime $export.Expires }}</span>
{{- end -}}
</div>
</div>
<div class="flex items-center gap-2">
{{- if eq $export.Status "complete" -}}
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-green-100 text-green-900">Fertig</span>
{{- else if eq $export.Status "failed" -}}
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-red-100 text-red-900">Fehler</span>
{{- else if eq $export.Status "running" -}}
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-amber-100 text-amber-900">Läuft</span>
{{- else -}}
<span class="status-badge px-2 py-1 text-xs font-semibold rounded-xs bg-slate-100 text-slate-700">Wartend</span>
{{- end -}}
</div>
</div>
<div>
<div class="flex items-center justify-between text-xs text-slate-700 mb-2">
{{- if eq $export.Type "files" -}}
<div>{{ $export.TablesDone }} / {{ $export.TablesTotal }} Dateien</div>
{{- else -}}
<div>{{ $export.TablesDone }} / {{ $export.TablesTotal }} Tabellen</div>
{{- end -}}
<div>{{ $export.SizeLabel }}</div>
</div>
<div class="h-2 rounded-xs bg-slate-100 overflow-hidden">
{{- if eq $export.Status "failed" -}}
<div class="h-full bg-red-400" style="width: {{ $export.Progress }}%;"></div>
{{- else if eq $export.Status "complete" -}}
<div class="h-full bg-green-500" style="width: {{ $export.Progress }}%;"></div>
{{- else -}}
<div class="h-full bg-slate-700" style="width: {{ $export.Progress }}%;"></div>
{{- end -}}
</div>
</div>
{{- if $export.CurrentTable -}}
<div class="text-xs text-slate-500">Aktuell: {{ $export.CurrentTable }}</div>
{{- end -}}
{{- if $export.Error -}}
<div class="text-xs text-red-700 bg-red-50 border border-red-200 rounded-xs px-3 py-2">{{ $export.Error }}</div>
{{- end -}}
<div class="flex items-center justify-between">
<div class="text-xs text-slate-500">
{{- if ne $export.Status "complete" -}}
Download verfügbar, sobald der Export abgeschlossen ist.
{{- end -}}
</div>
<div class="flex items-center gap-3">
{{- if or (eq $export.Status "running") (eq $export.Status "queued") -}}
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-400 shadow-sm cursor-not-allowed" disabled title="Export läuft noch.">
<i class="ri-delete-bin-line"></i> Löschen
</button>
{{- else -}}
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-100 px-3 py-2 text-sm font-semibold text-red-700 shadow-sm hover:bg-slate-200 hover:text-red-800 focus:outline-none focus:ring-2 focus:ring-red-200/70" data-action="delete" data-id="{{ $export.Id }}">
<i class="ri-delete-bin-line"></i> Löschen
</button>
{{- end -}}
{{- if eq $export.Status "complete" -}}
<a class="no-underline inline-flex items-center gap-2 rounded-md bg-slate-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" href="/redaktion/exports/download/{{ $export.Id }}">
<i class="ri-download-line"></i> Download
</a>
{{- end -}}
</div>
</div>
</div>
</div>
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1 @@
<title>{{ .site.title }} &ndash; Datenexport</title>

View File

@@ -0,0 +1 @@
{{ template "_export_list" . }}

View File

@@ -0,0 +1,198 @@
export class ExportManager extends HTMLElement {
constructor() {
super();
this.listUrl = "";
this.runUrl = "";
this.deleteUrl = "";
this.csrf = "";
this.list = null;
this.status = null;
this.pollTimer = null;
this.pollIntervalMs = 2500;
}
connectedCallback() {
this.listUrl = this.dataset.listUrl || "";
this.runUrl = this.dataset.runUrl || "";
this.deleteUrl = this.dataset.deleteUrl || "";
this.csrf = this.dataset.csrf || "";
this.list = this.querySelector("[data-role='export-list']");
this.status = this.querySelector("[data-role='status']");
this.addEventListener("click", (event) => this.handleAction(event));
this.refreshList();
}
disconnectedCallback() {
this.stopPolling();
}
setStatus(message, isError) {
if (!this.status) return;
this.status.textContent = message || "";
this.status.classList.remove("text-red-600", "text-green-600");
if (isError) {
this.status.classList.add("text-red-600");
} else if (message) {
this.status.classList.add("text-green-600");
}
}
async handleRun(event, exportType) {
event.preventDefault();
if (!this.runUrl) return;
this.setStatus("Export wird gestartet...");
const button = event.target.closest("[data-role='run-export']");
if (button) button.disabled = true;
const payload = new URLSearchParams();
payload.set("csrf_token", this.getCsrfToken());
payload.set("export_type", exportType || "data");
try {
const response = await fetch(this.runUrl, {
method: "POST",
body: payload,
credentials: "same-origin",
});
if (!response.ok) {
const message = await this.extractError(response);
this.setStatus(message || "Export konnte nicht gestartet werden.", true);
return;
}
const json = await this.safeJson(response);
if (json && json.error) {
this.setStatus(json.error, true);
return;
}
this.setStatus("Export läuft im Hintergrund.");
await this.refreshList();
this.startPolling();
} catch (err) {
this.setStatus("Export konnte nicht gestartet werden.", true);
} finally {
if (button) button.disabled = false;
}
}
async handleAction(event) {
const runTarget = event.target.closest("[data-role='run-export']");
if (runTarget) {
const exportType = runTarget.getAttribute("data-export-type") || "data";
await this.handleRun(event, exportType);
return;
}
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.getAttribute("data-action");
if (action === "delete") {
const id = target.getAttribute("data-id");
if (!id || !this.deleteUrl) return;
const confirmed = confirm("Soll der Export wirklich gelöscht werden?");
if (!confirmed) return;
await this.deleteExport(id);
}
}
async deleteExport(id) {
const payload = new URLSearchParams();
payload.set("csrf_token", this.getCsrfToken());
try {
const response = await fetch(`${this.deleteUrl}${id}`, {
method: "POST",
body: payload,
credentials: "same-origin",
});
if (!response.ok) {
const message = await this.extractError(response);
this.setStatus(message || "Export konnte nicht gelöscht werden.", true);
return;
}
const json = await this.safeJson(response);
if (json && json.error) {
this.setStatus(json.error, true);
return;
}
this.setStatus("Export gelöscht.");
await this.refreshList();
} catch {
this.setStatus("Export konnte nicht gelöscht werden.", true);
}
}
async refreshList() {
if (!this.list || !this.listUrl) return;
try {
const response = await fetch(this.listUrl, { credentials: "same-origin" });
if (!response.ok) {
return;
}
const html = await response.text();
this.list.innerHTML = html;
this.syncPollingState();
} catch {
// ignore refresh errors
}
}
syncPollingState() {
if (!this.list) return;
const active = this.list.querySelector("[data-export-status='running'], [data-export-status='queued']");
if (active) {
this.startPolling();
} else {
this.stopPolling();
}
}
startPolling() {
if (this.pollTimer) return;
this.pollTimer = window.setInterval(() => {
this.refreshList();
}, this.pollIntervalMs);
}
stopPolling() {
if (!this.pollTimer) return;
window.clearInterval(this.pollTimer);
this.pollTimer = null;
}
async safeJson(response) {
try {
return await response.json();
} catch {
return null;
}
}
async extractError(response) {
try {
const json = await response.json();
if (json && json.error) {
return json.error;
}
} catch {
// ignore
}
try {
return await response.text();
} catch {
return "";
}
}
getCsrfToken() {
if (this.csrf) return this.csrf;
const fallback = document.querySelector("input[name='csrf_token']");
if (fallback && fallback.value) {
this.csrf = fallback.value;
}
return this.csrf;
}
}

View File

@@ -337,6 +337,15 @@ export class FabMenu extends HTMLElement {
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Administration
</div>
<div class="grid grid-cols-[1fr_auto] group">
<a href="/redaktion/exports/" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
<i class="ri-download-2-line text-base text-gray-700 mr-2.5"></i>
<span class="text-gray-900">Datenexport</span>
</a>
<a href="/redaktion/exports/" target="_blank" class="flex items-center justify-center px-3 py-2 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
<i class="ri-external-link-line text-base"></i>
</a>
</div>
<div class="grid grid-cols-[1fr_auto] group">
<a href="/user/management/access/User?redirectTo=${redirectPath}" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
<i class="ri-group-3-line text-base text-gray-700 mr-2.5"></i>

View File

@@ -30,6 +30,7 @@ import { FabMenu } from "./fab-menu.js";
import { DuplicateWarningChecker } from "./duplicate-warning.js";
import { ContentImages } from "./content-images.js";
import { LookupField } from "./lookup-field.js";
import { ExportManager } from "./export-manager.js";
const FILTER_LIST_ELEMENT = "filter-list";
const FAB_MENU_ELEMENT = "fab-menu";
@@ -53,6 +54,7 @@ const EDIT_PAGE_ELEMENT = "edit-page";
const DUPLICATE_WARNING_ELEMENT = "duplicate-warning-checker";
const CONTENT_IMAGES_ELEMENT = "content-images";
const LOOKUP_FIELD_ELEMENT = "lookup-field";
const EXPORT_MANAGER_ELEMENT = "export-manager";
window.lookupSeriesValue = ({ item }) => item?.id || "";
window.lookupSeriesLink = ({ item }) => (item?.musenalm_id ? `/reihe/${item.musenalm_id}` : "");
@@ -81,6 +83,7 @@ customElements.define(FAB_MENU_ELEMENT, FabMenu);
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
customElements.define(CONTENT_IMAGES_ELEMENT, ContentImages);
customElements.define(LOOKUP_FIELD_ELEMENT, LookupField);
customElements.define(EXPORT_MANAGER_ELEMENT, ExportManager);
function PathPlusQuery() {
const path = window.location.pathname;