mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 10:35:30 +00:00
+Datenexport
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
59
views/routes/redaktion/exports/body.gohtml
Normal file
59
views/routes/redaktion/exports/body.gohtml
Normal 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>
|
||||
@@ -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 -}}
|
||||
1
views/routes/redaktion/exports/head.gohtml
Normal file
1
views/routes/redaktion/exports/head.gohtml
Normal file
@@ -0,0 +1 @@
|
||||
<title>{{ .site.title }} – Datenexport</title>
|
||||
1
views/routes/redaktion/exports/list/body.gohtml
Normal file
1
views/routes/redaktion/exports/list/body.gohtml
Normal file
@@ -0,0 +1 @@
|
||||
{{ template "_export_list" . }}
|
||||
198
views/transform/export-manager.js
Normal file
198
views/transform/export-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user