Admin-Panel: Schema-Designer, Dropzone mit Auto-Erkennung, Linkliste, Versionen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "qr-tabellen",
|
||||
"runtimeExecutable": "cmd",
|
||||
"runtimeArgs": ["/c", "cd /d D:\\Claude-Projekte\\qr-tabellen && venv\\Scripts\\python.exe -m flask --app app.main run --port 5005"],
|
||||
"port": 5005
|
||||
}
|
||||
]
|
||||
}
|
||||
173
app/main.py
Normal file
173
app/main.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Admin-Panel (Flask): Schema-Designer, Dropzone, Linkliste, Versionen.
|
||||
|
||||
In Produktion liegt das Panel hinter Nginx Basic Auth; die
|
||||
oeffentlichen Seiten liefert Nginx direkt aus public/ aus.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from . import engine, storage
|
||||
|
||||
app = Flask(__name__)
|
||||
# Secret nur fuer Flash-Messages; bei Neustart verfallende Session ist ok.
|
||||
app.secret_key = os.environ.get("QT_SECRET_KEY") or os.urandom(16)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return redirect(url_for("admin_index"))
|
||||
|
||||
|
||||
@app.get("/admin/")
|
||||
def admin_index():
|
||||
return render_template(
|
||||
"admin/index.html",
|
||||
tables=storage.list_tables(),
|
||||
qr_svg=engine.qr_svg,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/neu", methods=["GET", "POST"])
|
||||
def admin_new():
|
||||
if request.method == "GET":
|
||||
return render_template("admin/new.html", types=engine.TYPE_LABELS)
|
||||
|
||||
title = request.form.get("title", "").strip()
|
||||
labels = [l.strip() for l in request.form.getlist("label")]
|
||||
types = request.form.getlist("type")
|
||||
|
||||
columns = []
|
||||
seen_keys = set()
|
||||
for label, ctype in zip(labels, types):
|
||||
if not label:
|
||||
continue
|
||||
if ctype not in engine.COLUMN_TYPES:
|
||||
ctype = "text"
|
||||
key = engine.column_key(label)
|
||||
while key in seen_keys:
|
||||
key += "_2"
|
||||
seen_keys.add(key)
|
||||
columns.append({"key": key, "label": label, "type": ctype})
|
||||
|
||||
error = None
|
||||
if not title:
|
||||
error = "Bitte einen Titel angeben."
|
||||
elif not columns:
|
||||
error = "Mindestens eine Spalte anlegen."
|
||||
else:
|
||||
slug = engine.slugify(title)
|
||||
if storage.load_schema(slug) is not None:
|
||||
error = f'Es gibt schon eine Tabelle mit dem Slug "{slug}".'
|
||||
|
||||
if error:
|
||||
flash(error, "error")
|
||||
return render_template(
|
||||
"admin/new.html",
|
||||
types=engine.TYPE_LABELS,
|
||||
title=title,
|
||||
columns=columns,
|
||||
)
|
||||
|
||||
storage.save_schema({"slug": slug, "title": title, "columns": columns})
|
||||
flash(f'Tabelle "{title}" angelegt. Jetzt die CSV-Vorlage herunterladen.', "ok")
|
||||
return redirect(url_for("admin_index"))
|
||||
|
||||
|
||||
@app.get("/admin/vorlage/<slug>")
|
||||
def admin_template(slug):
|
||||
schema = storage.load_schema(slug)
|
||||
if schema is None:
|
||||
abort(404)
|
||||
return send_file(
|
||||
io.BytesIO(engine.template_csv(schema)),
|
||||
mimetype="text/csv",
|
||||
as_attachment=True,
|
||||
download_name=f"{slug}.csv",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/upload")
|
||||
def admin_upload():
|
||||
file = request.files.get("csv")
|
||||
if file is None or not file.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "error")
|
||||
return redirect(url_for("admin_index"))
|
||||
|
||||
raw = file.read()
|
||||
header, _ = engine.parse_csv(raw)
|
||||
|
||||
slug = request.form.get("slug", "").strip()
|
||||
if not slug: # automatisch erkennen
|
||||
slug = storage.find_table(file.filename, header)
|
||||
if not slug:
|
||||
flash(
|
||||
"Tabelle nicht erkannt — bitte im Auswahlfeld die Zieltabelle "
|
||||
"waehlen und erneut hochladen.",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("admin_index"))
|
||||
|
||||
ok, errors, warnings, version = storage.publish_upload(slug, raw)
|
||||
for w in warnings:
|
||||
flash(w, "warn")
|
||||
if not ok:
|
||||
for e in errors[:10]:
|
||||
flash(e, "error")
|
||||
if len(errors) > 10:
|
||||
flash(f"... und {len(errors) - 10} weitere Fehler.", "error")
|
||||
flash("Nichts veroeffentlicht — bitte CSV korrigieren und erneut hochladen.", "error")
|
||||
else:
|
||||
flash(
|
||||
f'"{slug}" aktualisiert (Version {version}) und veroeffentlicht: '
|
||||
f"{storage.public_url(slug)}",
|
||||
"ok",
|
||||
)
|
||||
return redirect(url_for("admin_index"))
|
||||
|
||||
|
||||
@app.get("/admin/csv/<slug>/<filename>")
|
||||
def admin_download(slug, filename):
|
||||
raw = storage.read_version(slug, filename)
|
||||
if raw is None:
|
||||
abort(404)
|
||||
return send_file(
|
||||
io.BytesIO(raw),
|
||||
mimetype="text/csv",
|
||||
as_attachment=True,
|
||||
download_name=f"{slug}_{filename}",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/loeschen/<slug>")
|
||||
def admin_delete(slug):
|
||||
if storage.load_schema(slug) is None:
|
||||
abort(404)
|
||||
storage.delete_table(slug)
|
||||
flash(f'Tabelle "{slug}" samt Versionen geloescht.', "ok")
|
||||
return redirect(url_for("admin_index"))
|
||||
|
||||
|
||||
@app.get("/<slug>/")
|
||||
def public_page(slug):
|
||||
"""Nur fuer die lokale Entwicklung — in Produktion macht das Nginx."""
|
||||
path = storage.PUBLIC_DIR / slug / "index.html"
|
||||
if not path.exists():
|
||||
abort(404)
|
||||
return send_file(path.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
155
app/static/admin.css
Normal file
155
app/static/admin.css
Normal file
@@ -0,0 +1,155 @@
|
||||
/* Admin-Panel — nutzt dieselben Design-Tokens wie die Tabellen-Seiten. */
|
||||
:root {
|
||||
--font-heading: "Source Serif 4", Georgia, "Times New Roman", serif;
|
||||
--font-body: "Fira Sans", "Segoe UI", system-ui, sans-serif;
|
||||
--color-paper: #fbfaf7;
|
||||
--color-ink: #26221c;
|
||||
--color-muted: #6f6a60;
|
||||
--color-accent: #1f5e79;
|
||||
--color-border: #ddd8cc;
|
||||
--color-danger: #a33a2a;
|
||||
--color-ok: #2c6e49;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1.5rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
background: var(--color-ink);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
|
||||
.brand { font-family: var(--font-heading); font-size: 1.15rem; }
|
||||
|
||||
.topbar nav a {
|
||||
color: var(--color-paper);
|
||||
opacity: 0.85;
|
||||
text-decoration: none;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.topbar nav a:hover { opacity: 1; text-decoration: underline; }
|
||||
|
||||
main { max-width: 900px; margin: 0 auto; padding: 1rem 1.2rem 3rem; }
|
||||
|
||||
h2, h3 { font-family: var(--font-heading); }
|
||||
|
||||
.flash { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
.flash li {
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-left: 4px solid var(--color-muted);
|
||||
background: #fff;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.flash-ok { border-color: var(--color-ok); }
|
||||
.flash-warn { border-color: #c9931f; }
|
||||
.flash-error { border-color: var(--color-danger); }
|
||||
|
||||
/* Dropzone */
|
||||
.dropzone {
|
||||
display: block;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone.drag { border-color: var(--color-accent); color: var(--color-accent); }
|
||||
.dropzone input { display: none; }
|
||||
|
||||
.upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tabellen-Karten (Linkliste) */
|
||||
.table-card {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.card-qr svg { width: 84px; height: 84px; }
|
||||
.card-body { flex: 1; min-width: 0; }
|
||||
.card-body h3 { margin: 0 0 0.2rem; }
|
||||
.card-url a { color: var(--color-accent); word-break: break-all; }
|
||||
.card-url, .card-meta { margin: 0.15rem 0; font-size: 0.88rem; }
|
||||
.card-meta, .muted { color: var(--color-muted); }
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.card-actions form { margin: 0; }
|
||||
|
||||
.versions ul { margin: 0.4rem 0 0; padding-left: 1.2rem; font-size: 0.85rem; }
|
||||
.versions summary { list-style: none; }
|
||||
|
||||
/* Buttons & Formulare */
|
||||
.btn, button {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.88rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn:hover, button:hover { border-color: var(--color-accent); color: var(--color-accent); }
|
||||
.btn-primary, button[type="submit"] {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover, button[type="submit"]:hover { color: #fff; opacity: 0.92; }
|
||||
.btn-danger, button.btn-danger[type="submit"] {
|
||||
background: #fff;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.btn-danger:hover, button.btn-danger[type="submit"]:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.schema-form .field { display: block; margin-bottom: 1rem; }
|
||||
.schema-form .field span { display: block; font-size: 0.85rem; color: var(--color-muted); }
|
||||
.schema-form input[type="text"], .schema-form select, .upload-row select {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
}
|
||||
.schema-form .field input { width: 100%; max-width: 34rem; }
|
||||
|
||||
.column-row { display: flex; gap: 0.5rem; margin-bottom: 0.4rem; }
|
||||
.column-row input { flex: 1; max-width: 20rem; }
|
||||
|
||||
.hint, .empty { color: var(--color-muted); font-size: 0.88rem; }
|
||||
131
app/storage.py
Normal file
131
app/storage.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Ablage im Dateisystem: Schemas, CSV-Versionen, veroeffentlichte Seiten.
|
||||
|
||||
Layout:
|
||||
data/tables/<slug>/schema.json
|
||||
data/tables/<slug>/versions/<zeitstempel>.csv
|
||||
public/<slug>/index.html (statisch, liefert Nginx aus)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from . import engine
|
||||
|
||||
DATA_DIR = Path(os.environ.get("QT_DATA_DIR", "data"))
|
||||
PUBLIC_DIR = Path(os.environ.get("QT_PUBLIC_DIR", "public"))
|
||||
BASE_URL = os.environ.get("QT_BASE_URL", "http://localhost:5000").rstrip("/")
|
||||
|
||||
|
||||
def _table_dir(slug: str) -> Path:
|
||||
return DATA_DIR / "tables" / slug
|
||||
|
||||
|
||||
def public_url(slug: str) -> str:
|
||||
return f"{BASE_URL}/{slug}/"
|
||||
|
||||
|
||||
def load_schema(slug: str) -> dict | None:
|
||||
path = _table_dir(slug) / "schema.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_schema(schema: dict) -> None:
|
||||
table_dir = _table_dir(schema["slug"])
|
||||
(table_dir / "versions").mkdir(parents=True, exist_ok=True)
|
||||
(table_dir / "schema.json").write_text(
|
||||
json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def list_tables() -> list[dict]:
|
||||
"""Alle Tabellen mit Versions-Info, neueste zuerst."""
|
||||
tables_dir = DATA_DIR / "tables"
|
||||
if not tables_dir.exists():
|
||||
return []
|
||||
tables = []
|
||||
for table_dir in sorted(tables_dir.iterdir()):
|
||||
schema = load_schema(table_dir.name)
|
||||
if schema is None:
|
||||
continue
|
||||
versions = list_versions(schema["slug"])
|
||||
tables.append(
|
||||
{
|
||||
"schema": schema,
|
||||
"slug": schema["slug"],
|
||||
"title": schema["title"],
|
||||
"url": public_url(schema["slug"]),
|
||||
"versions": versions,
|
||||
"last_upload": versions[0] if versions else None,
|
||||
"published": (PUBLIC_DIR / schema["slug"] / "index.html").exists(),
|
||||
}
|
||||
)
|
||||
tables.sort(key=lambda t: t["last_upload"] or "", reverse=True)
|
||||
return tables
|
||||
|
||||
|
||||
def list_versions(slug: str) -> list[str]:
|
||||
versions_dir = _table_dir(slug) / "versions"
|
||||
if not versions_dir.exists():
|
||||
return []
|
||||
return sorted((p.name for p in versions_dir.glob("*.csv")), reverse=True)
|
||||
|
||||
|
||||
def read_version(slug: str, filename: str) -> bytes | None:
|
||||
path = _table_dir(slug) / "versions" / Path(filename).name
|
||||
return path.read_bytes() if path.exists() else None
|
||||
|
||||
|
||||
def find_table(filename: str, header: list[str]) -> str | None:
|
||||
"""Upload einer Tabelle zuordnen: erst Dateiname, dann Header-Signatur."""
|
||||
tables = list_tables()
|
||||
stem = engine.slugify(Path(filename).stem)
|
||||
for t in tables:
|
||||
if stem == t["slug"] or stem.startswith(t["slug"]):
|
||||
return t["slug"]
|
||||
norm_header = {" ".join(h.strip().lower().split()) for h in header if h.strip()}
|
||||
matches = [
|
||||
t["slug"]
|
||||
for t in tables
|
||||
if {" ".join(c["label"].strip().lower().split()) for c in t["schema"]["columns"]}
|
||||
<= norm_header
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def publish_upload(slug: str, raw: bytes) -> tuple[bool, list[str], list[str], str]:
|
||||
"""CSV validieren, als Version ablegen und die Seite neu veroeffentlichen.
|
||||
|
||||
Rueckgabe: (ok, errors, warnings, version_name). Bei Fehlern wird
|
||||
nichts gespeichert und nichts veroeffentlicht.
|
||||
"""
|
||||
schema = load_schema(slug)
|
||||
if schema is None:
|
||||
return False, [f'Tabelle "{slug}" existiert nicht.'], [], ""
|
||||
|
||||
header, rows = engine.parse_csv(raw)
|
||||
records, errors, warnings = engine.validate_and_map(schema, header, rows)
|
||||
if errors:
|
||||
return False, errors, warnings, ""
|
||||
|
||||
stamp = datetime.now()
|
||||
version_name = stamp.strftime("%Y-%m-%d_%H-%M-%S") + ".csv"
|
||||
(_table_dir(slug) / "versions" / version_name).write_bytes(raw)
|
||||
|
||||
html = engine.render_page(
|
||||
schema, records, version_label=stamp.strftime("%d.%m.%Y, %H:%M Uhr")
|
||||
)
|
||||
page_dir = PUBLIC_DIR / slug
|
||||
page_dir.mkdir(parents=True, exist_ok=True)
|
||||
(page_dir / "index.html").write_text(html, encoding="utf-8")
|
||||
return True, [], warnings, version_name
|
||||
|
||||
|
||||
def delete_table(slug: str) -> None:
|
||||
shutil.rmtree(_table_dir(slug), ignore_errors=True)
|
||||
shutil.rmtree(PUBLIC_DIR / slug, ignore_errors=True)
|
||||
30
app/templates/admin/base.html
Normal file
30
app/templates/admin/base.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}QR-Tabellen{% endblock %} · Admin</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">QR-Tabellen</span>
|
||||
<nav>
|
||||
<a href="{{ url_for('admin_index') }}">Übersicht</a>
|
||||
<a href="{{ url_for('admin_new') }}">Neue Tabelle</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<ul class="flash">
|
||||
{% for category, message in messages %}
|
||||
<li class="flash-{{ category }}">{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
87
app/templates/admin/index.html
Normal file
87
app/templates/admin/index.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Übersicht{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="dropzone-section">
|
||||
<h2>CSV hochladen</h2>
|
||||
<form method="post" action="{{ url_for('admin_upload') }}" enctype="multipart/form-data" id="upload-form">
|
||||
<label class="dropzone" id="dropzone">
|
||||
<input type="file" name="csv" accept=".csv,text/csv" id="csv-input">
|
||||
<span>Befüllte CSV hier ablegen oder klicken —<br>
|
||||
die Tabelle wird am Dateinamen bzw. an den Spalten erkannt.</span>
|
||||
</label>
|
||||
<div class="upload-row">
|
||||
<label for="slug-select">Zieltabelle:</label>
|
||||
<select name="slug" id="slug-select">
|
||||
<option value="">automatisch erkennen</option>
|
||||
{% for t in tables %}
|
||||
<option value="{{ t.slug }}">{{ t.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Hochladen & veröffentlichen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Gehostete Tabellen</h2>
|
||||
{% if not tables %}
|
||||
<p class="empty">Noch keine Tabellen — <a href="{{ url_for('admin_new') }}">lege die erste an</a>.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for t in tables %}
|
||||
<article class="table-card">
|
||||
<div class="card-qr">{{ qr_svg(t.url) | safe }}</div>
|
||||
<div class="card-body">
|
||||
<h3>{{ t.title }}</h3>
|
||||
<p class="card-url">
|
||||
{% if t.published %}
|
||||
<a href="{{ t.url }}">{{ t.url }}</a>
|
||||
{% else %}
|
||||
<span class="muted">{{ t.url }} — noch keine CSV hochgeladen</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="card-meta">
|
||||
{{ t.versions | length }} Version(en)
|
||||
{% if t.last_upload %}· letzte: {{ t.last_upload[:-4] }}{% endif %}
|
||||
</p>
|
||||
<p class="card-actions">
|
||||
<a class="btn" href="{{ url_for('admin_template', slug=t.slug) }}">CSV-Vorlage</a>
|
||||
{% if t.versions %}
|
||||
<details class="versions">
|
||||
<summary class="btn">Versionen (CSV-Download)</summary>
|
||||
<ul>
|
||||
{% for v in t.versions %}
|
||||
<li><a href="{{ url_for('admin_download', slug=t.slug, filename=v) }}">{{ v }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('admin_delete', slug=t.slug) }}"
|
||||
onsubmit="return confirm('Tabelle {{ t.slug }} samt aller Versionen löschen?')">
|
||||
<button type="submit" class="btn btn-danger">Löschen</button>
|
||||
</form>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const zone = document.getElementById("dropzone");
|
||||
const input = document.getElementById("csv-input");
|
||||
const form = document.getElementById("upload-form");
|
||||
["dragover", "dragenter"].forEach(ev =>
|
||||
zone.addEventListener(ev, e => { e.preventDefault(); zone.classList.add("drag"); }));
|
||||
["dragleave", "drop"].forEach(ev =>
|
||||
zone.addEventListener(ev, () => zone.classList.remove("drag")));
|
||||
zone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length) {
|
||||
input.files = e.dataTransfer.files;
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
input.addEventListener("change", () => { if (input.files.length) form.submit(); });
|
||||
</script>
|
||||
{% endblock %}
|
||||
58
app/templates/admin/new.html
Normal file
58
app/templates/admin/new.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Neue Tabelle{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section>
|
||||
<h2>Neue Tabelle anlegen</h2>
|
||||
<form method="post" class="schema-form">
|
||||
<label class="field">
|
||||
<span>Titel</span>
|
||||
<input type="text" name="title" required placeholder="z. B. Projekttage Streetfotografie – Stationen"
|
||||
value="{{ title or '' }}">
|
||||
</label>
|
||||
|
||||
<h3>Spalten</h3>
|
||||
<div id="columns">
|
||||
{% set prefill = columns or [
|
||||
{"label": "Nr.", "type": "number"},
|
||||
{"label": "Titel", "type": "text"},
|
||||
{"label": "Beschreibung", "type": "text_long"},
|
||||
{"label": "Link", "type": "url"},
|
||||
{"label": "Bewertung", "type": "rating_5"},
|
||||
] %}
|
||||
{% for col in prefill %}
|
||||
<div class="column-row">
|
||||
<input type="text" name="label" placeholder="Spaltenname" value="{{ col.label }}">
|
||||
<select name="type">
|
||||
{% for value, label in types.items() %}
|
||||
<option value="{{ value }}" {% if col.type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-danger remove-row">–</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p><button type="button" class="btn" id="add-row">+ Spalte</button></p>
|
||||
|
||||
<p class="hint">Leere Spaltennamen werden übersprungen. Link-Spalten bekommen
|
||||
automatisch QR-Codes; mehrere Link-Spalten sind möglich.</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Tabelle anlegen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const columns = document.getElementById("columns");
|
||||
document.getElementById("add-row").addEventListener("click", () => {
|
||||
const row = columns.querySelector(".column-row").cloneNode(true);
|
||||
row.querySelector("input").value = "";
|
||||
row.querySelector("select").selectedIndex = 0;
|
||||
columns.appendChild(row);
|
||||
});
|
||||
columns.addEventListener("click", e => {
|
||||
if (e.target.classList.contains("remove-row") && columns.children.length > 1) {
|
||||
e.target.closest(".column-row").remove();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user