Compare commits
No commits in common. "admin-v2" and "main" have entirely different histories.
87
admin_app.py
87
admin_app.py
|
|
@ -1,87 +0,0 @@
|
||||||
import yaml
|
|
||||||
from flask import Flask, jsonify, request, Response, abort
|
|
||||||
|
|
||||||
import build # reuse your existing static builder logic
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def list_pages():
|
|
||||||
"""
|
|
||||||
Return a lightweight list of pages for a sidebar etc.
|
|
||||||
Uses build.load_pages() from the existing builder.
|
|
||||||
"""
|
|
||||||
pages = build.load_pages()
|
|
||||||
result = []
|
|
||||||
for p in pages:
|
|
||||||
result.append({
|
|
||||||
"slug": p.get("slug", ""),
|
|
||||||
"title": p.get("title", ""),
|
|
||||||
"language": p.get("language", "de"),
|
|
||||||
})
|
|
||||||
result.sort(key=lambda x: x["slug"])
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/pages")
|
|
||||||
def api_list_pages():
|
|
||||||
return jsonify(list_pages())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/pages/<slug>")
|
|
||||||
def api_get_page(slug):
|
|
||||||
try:
|
|
||||||
page, path = build.load_page_by_slug(slug)
|
|
||||||
except FileNotFoundError:
|
|
||||||
abort(404, description="Page not found")
|
|
||||||
return jsonify(page)
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/pages/<slug>")
|
|
||||||
def api_save_page(slug):
|
|
||||||
"""
|
|
||||||
Save page JSON back as YAML. For now we trust the structure,
|
|
||||||
since the admin UI is under our control.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
_, path = build.load_page_by_slug(slug)
|
|
||||||
except FileNotFoundError:
|
|
||||||
abort(404, description="Page not found")
|
|
||||||
|
|
||||||
new_page = request.get_json(force=True, silent=False)
|
|
||||||
if not isinstance(new_page, dict):
|
|
||||||
abort(400, description="Expected JSON object")
|
|
||||||
|
|
||||||
# Ensure slug consistency
|
|
||||||
new_page.setdefault("slug", slug)
|
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
yaml.safe_dump(
|
|
||||||
new_page,
|
|
||||||
f,
|
|
||||||
allow_unicode=True,
|
|
||||||
sort_keys=False,
|
|
||||||
width=1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ("", 204)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/preview/<slug>/")
|
|
||||||
def preview_page(slug):
|
|
||||||
"""
|
|
||||||
Render HTML for a single page using existing Jinja templates.
|
|
||||||
No disk write: we render directly from the current YAML content.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page, _ = build.load_page_by_slug(slug)
|
|
||||||
except FileNotFoundError:
|
|
||||||
abort(404, description="Page not found")
|
|
||||||
|
|
||||||
html = build.render_page(page)
|
|
||||||
return Response(html, mimetype="text/html")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# For development; in production we'll run this with gunicorn and Caddy as reverse proxy.
|
|
||||||
app.run(host="127.0.0.1", port=8075, debug=True)
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Club Builder Admin</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
background: #111827;
|
|
||||||
color: #e5e7eb;
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
width: 220px;
|
|
||||||
border-right: 1px solid #1f2937;
|
|
||||||
background: #020617;
|
|
||||||
padding: 0.75rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.sidebar h1 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
.page-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.page-list li {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.page-btn {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e5e7eb;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.page-btn.active {
|
|
||||||
background: #111827;
|
|
||||||
border-color: #4b5563;
|
|
||||||
}
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.editor {
|
|
||||||
flex: 1 1 50%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.75rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-right: 1px solid #1f2937;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.editor-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.editor-header span {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
.editor textarea {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
resize: none;
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
border: 1px solid #374151;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
padding: 0.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.editor button {
|
|
||||||
padding: 0.25rem 0.7rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.btn-primary {
|
|
||||||
background: #10b981;
|
|
||||||
color: #022c22;
|
|
||||||
}
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.status {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
flex: 1 1 50%;
|
|
||||||
min-width: 320px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.preview-header {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid #1f2937;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
background: #020617;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.preview-title {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.preview-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.device-btn {
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.device-btn.active {
|
|
||||||
background: #4b5563;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
.preview-open {
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.preview-expand {
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-inner {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: stretch;
|
|
||||||
padding: 0.5rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #000;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.preview iframe {
|
|
||||||
border: none;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device width presets for iframe */
|
|
||||||
.device-mobile #preview-frame {
|
|
||||||
width: 390px; /* typical phone */
|
|
||||||
}
|
|
||||||
.device-tablet #preview-frame {
|
|
||||||
width: 820px; /* typical tablet */
|
|
||||||
}
|
|
||||||
.device-desktop #preview-frame {
|
|
||||||
width: 1200px; /* typical desktop layout */
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust column ratios per mode */
|
|
||||||
.device-mobile .editor {
|
|
||||||
flex-basis: 50%;
|
|
||||||
}
|
|
||||||
.device-mobile .preview {
|
|
||||||
flex-basis: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-tablet .editor {
|
|
||||||
flex-basis: 40%;
|
|
||||||
}
|
|
||||||
.device-tablet .preview {
|
|
||||||
flex-basis: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-desktop .editor {
|
|
||||||
flex-basis: 35%;
|
|
||||||
}
|
|
||||||
.device-desktop .preview {
|
|
||||||
flex-basis: 65%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Full-width preview mode: hide editor, preview takes all */
|
|
||||||
.preview-full .editor {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.preview-full .preview {
|
|
||||||
flex-basis: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="device-desktop">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<h1>Pages</h1>
|
|
||||||
<ul class="page-list" id="page-list"></ul>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<section class="editor">
|
|
||||||
<div class="editor-header">
|
|
||||||
<span id="editor-title">No page selected</span>
|
|
||||||
<button class="btn-primary" id="save-btn" disabled>Save</button>
|
|
||||||
<span class="status" id="status"></span>
|
|
||||||
</div>
|
|
||||||
<textarea id="json-editor" placeholder="Select a page from the left…"></textarea>
|
|
||||||
</section>
|
|
||||||
<section class="preview">
|
|
||||||
<div class="preview-header">
|
|
||||||
<span class="preview-title">
|
|
||||||
Preview: <span id="preview-label">–</span>
|
|
||||||
</span>
|
|
||||||
<div class="preview-controls">
|
|
||||||
<button class="device-btn" data-mode="mobile">📱</button>
|
|
||||||
<button class="device-btn" data-mode="tablet">📱📱</button>
|
|
||||||
<button class="device-btn active" data-mode="desktop">🖥</button>
|
|
||||||
<a id="open-preview" class="preview-open" href="#" target="_blank" rel="noopener noreferrer">↗</a>
|
|
||||||
<button id="expand-preview" class="preview-expand" title="Toggle full-width preview">⤢</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="preview-inner">
|
|
||||||
<iframe id="preview-frame" src="about:blank"></iframe>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Same origin: /api/... and /preview/... on builder-preview.clubdaguerre.de
|
|
||||||
const API_BASE = '';
|
|
||||||
|
|
||||||
const pageListEl = document.getElementById('page-list');
|
|
||||||
const editorTitleEl = document.getElementById('editor-title');
|
|
||||||
const previewLabelEl = document.getElementById('preview-label');
|
|
||||||
const previewFrameEl = document.getElementById('preview-frame');
|
|
||||||
const jsonEditorEl = document.getElementById('json-editor');
|
|
||||||
const saveBtnEl = document.getElementById('save-btn');
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
const deviceButtons = document.querySelectorAll('.device-btn');
|
|
||||||
const openPreviewEl = document.getElementById('open-preview');
|
|
||||||
const expandPreviewEl = document.getElementById('expand-preview');
|
|
||||||
|
|
||||||
let currentSlug = null;
|
|
||||||
|
|
||||||
function setDeviceMode(mode) {
|
|
||||||
document.body.classList.remove('device-mobile', 'device-tablet', 'device-desktop');
|
|
||||||
document.body.classList.add('device-' + mode);
|
|
||||||
deviceButtons.forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const mode = btn.dataset.mode;
|
|
||||||
setDeviceMode(mode);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expandPreviewEl.addEventListener('click', () => {
|
|
||||||
document.body.classList.toggle('preview-full');
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadPages() {
|
|
||||||
const res = await fetch(API_BASE + '/api/pages');
|
|
||||||
const pages = await res.json();
|
|
||||||
|
|
||||||
pageListEl.innerHTML = '';
|
|
||||||
pages.forEach(page => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'page-btn';
|
|
||||||
btn.textContent = page.title || page.slug || '(untitled)';
|
|
||||||
btn.dataset.slug = page.slug;
|
|
||||||
btn.onclick = () => selectPage(page.slug, btn);
|
|
||||||
li.appendChild(btn);
|
|
||||||
pageListEl.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectPage(slug, buttonEl) {
|
|
||||||
currentSlug = slug;
|
|
||||||
statusEl.textContent = 'Loading…';
|
|
||||||
saveBtnEl.disabled = true;
|
|
||||||
|
|
||||||
document.querySelectorAll('.page-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
if (buttonEl) buttonEl.classList.add('active');
|
|
||||||
|
|
||||||
const res = await fetch(API_BASE + '/api/pages/' + encodeURIComponent(slug));
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
editorTitleEl.textContent = slug;
|
|
||||||
previewLabelEl.textContent = slug;
|
|
||||||
jsonEditorEl.value = JSON.stringify(data, null, 2);
|
|
||||||
saveBtnEl.disabled = false;
|
|
||||||
statusEl.textContent = 'Loaded';
|
|
||||||
|
|
||||||
const url = API_BASE + '/preview/' + encodeURIComponent(slug) + '/';
|
|
||||||
previewFrameEl.src = url;
|
|
||||||
openPreviewEl.href = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCurrent() {
|
|
||||||
if (!currentSlug) return;
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(jsonEditorEl.value);
|
|
||||||
} catch (e) {
|
|
||||||
alert('JSON is invalid: ' + e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveBtnEl.disabled = true;
|
|
||||||
statusEl.textContent = 'Saving…';
|
|
||||||
const res = await fetch(API_BASE + '/api/pages/' + encodeURIComponent(currentSlug), {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(parsed),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
statusEl.textContent = 'Error: ' + res.status;
|
|
||||||
saveBtnEl.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusEl.textContent = 'Saved';
|
|
||||||
saveBtnEl.disabled = false;
|
|
||||||
|
|
||||||
// refresh preview
|
|
||||||
const url = API_BASE + '/preview/' + encodeURIComponent(currentSlug) + '/';
|
|
||||||
previewFrameEl.src = url;
|
|
||||||
openPreviewEl.href = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonEditorEl.addEventListener('input', () => {
|
|
||||||
if (!currentSlug) return;
|
|
||||||
statusEl.textContent = 'Changed (not saved)';
|
|
||||||
});
|
|
||||||
|
|
||||||
saveBtnEl.addEventListener('click', saveCurrent);
|
|
||||||
|
|
||||||
// Default device mode = desktop
|
|
||||||
setDeviceMode('desktop');
|
|
||||||
|
|
||||||
loadPages().catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
statusEl.textContent = 'Error loading pages';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
18
build.py
18
build.py
|
|
@ -27,24 +27,6 @@ def load_pages():
|
||||||
pages.append(data)
|
pages.append(data)
|
||||||
return pages
|
return pages
|
||||||
|
|
||||||
|
|
||||||
def load_page_by_slug(slug):
|
|
||||||
"""
|
|
||||||
Load a single page dict and its file path by slug.
|
|
||||||
Assumes each YAML file in CONTENT_PAGES_DIR has a 'slug' field.
|
|
||||||
"""
|
|
||||||
for filename in os.listdir(CONTENT_PAGES_DIR):
|
|
||||||
if not filename.endswith(".yaml"):
|
|
||||||
continue
|
|
||||||
path = os.path.join(CONTENT_PAGES_DIR, filename)
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
data = yaml.safe_load(f)
|
|
||||||
if data and data.get("slug") == slug:
|
|
||||||
return data, path
|
|
||||||
raise FileNotFoundError(f"No page with slug={slug!r} in {CONTENT_PAGES_DIR}")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def render_page(page):
|
def render_page(page):
|
||||||
template_name = "page.html" # all use same template for now
|
template_name = "page.html" # all use same template for now
|
||||||
template = env.get_template(template_name)
|
template = env.get_template(template_name)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue