- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations. - Added preview functionality for markdown content. - Created list view for recent manuals with edit and view options. - Developed detail view for individual manuals displaying content, steps, and related guides. - Established database schema for manual articles, steps, and relations with appropriate indexing. - Seeded initial manual articles and steps for core functionalities. - Normalized newline characters in existing manual content. - Added additional manuals and steps for enhanced user guidance.
249 lines
9.8 KiB
HTML
249 lines
9.8 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Manual Admin - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.admin-shell {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
.editor-card {
|
|
background: var(--bg-card);
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|
}
|
|
.mono {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
.preview-box {
|
|
min-height: 120px;
|
|
border-radius: 8px;
|
|
border: 1px dashed rgba(0,0,0,0.2);
|
|
padding: 0.75rem;
|
|
background: var(--bg-body);
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4 admin-shell">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h2 class="mb-1"><i class="bi bi-sliders me-2"></i>Manual Admin</h2>
|
|
<div class="text-muted">Opret og redigér manualartikler (MVP editor)</div>
|
|
</div>
|
|
<a href="/manual" class="btn btn-outline-primary"><i class="bi bi-journal-richtext me-1"></i>Se manualer</a>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-12 col-lg-8">
|
|
<div class="editor-card p-3">
|
|
<div class="row g-2">
|
|
<div class="col-12 col-md-8">
|
|
<label class="form-label">Titel</label>
|
|
<input id="title" class="form-control" placeholder="Hvordan opretter jeg en sag?">
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Sværhedsgrad</label>
|
|
<select id="difficulty" class="form-select">
|
|
<option value="beginner">beginner</option>
|
|
<option value="advanced">advanced</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Modul</label>
|
|
<input id="module" class="form-control" placeholder="sag">
|
|
</div>
|
|
<div class="col-12 col-md-8">
|
|
<label class="form-label">Tags (kommasepareret)</label>
|
|
<input id="tags" class="form-control" placeholder="sag, ticket, opgave">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Kort intro</label>
|
|
<textarea id="summary" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Markdown indhold</label>
|
|
<textarea id="content" class="form-control mono" rows="10" oninput="renderPreview()"></textarea>
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Steps JSON</label>
|
|
<textarea id="steps" class="form-control mono" rows="8">[]</textarea>
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label class="form-label">Relationer JSON</label>
|
|
<textarea id="relations" class="form-control mono" rows="8">[]</textarea>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2 mt-3">
|
|
<button id="saveBtn" class="btn btn-primary" onclick="createManual()">
|
|
<i class="bi bi-save me-1"></i>Gem manual
|
|
</button>
|
|
<button class="btn btn-outline-secondary" onclick="resetForm()">Nulstil</button>
|
|
</div>
|
|
<div id="status" class="small mt-2 text-muted"></div>
|
|
</div>
|
|
|
|
<div class="editor-card p-3 mt-3">
|
|
<h5><i class="bi bi-eye me-2"></i>Preview</h5>
|
|
<div id="preview" class="preview-box"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 col-lg-4">
|
|
<div class="editor-card p-3">
|
|
<h5><i class="bi bi-list-check me-2"></i>Seneste manualer</h5>
|
|
<div class="list-group list-group-flush">
|
|
{% for article in articles %}
|
|
<div class="list-group-item">
|
|
<div class="fw-semibold">{{ article.title }}</div>
|
|
<div class="small text-muted mb-2">{{ article.module }} • {{ article.difficulty }}</div>
|
|
<div class="d-flex gap-2">
|
|
<a class="btn btn-sm btn-outline-primary" href="/manual/{{ article.slug }}">Åbn</a>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadManual('{{ article.slug }}')">Rediger</button>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-muted">Ingen manualer endnu.</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let editingId = null;
|
|
|
|
function normalizeEditorText(value) {
|
|
return String(value || '')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/\\n/g, '\n');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.innerText = text || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function renderPreview() {
|
|
const value = document.getElementById('content').value || '';
|
|
const html = escapeHtml(value).replace(/\n/g, '<br>');
|
|
document.getElementById('preview').innerHTML = html;
|
|
}
|
|
|
|
function parseJsonField(id) {
|
|
const raw = document.getElementById(id).value || '[]';
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (e) {
|
|
throw new Error(`Ugyldig JSON i feltet ${id}`);
|
|
}
|
|
}
|
|
|
|
async function createManual() {
|
|
const status = document.getElementById('status');
|
|
const saveBtn = document.getElementById('saveBtn');
|
|
status.textContent = 'Gemmer...';
|
|
|
|
try {
|
|
const payload = {
|
|
title: document.getElementById('title').value.trim(),
|
|
module: document.getElementById('module').value.trim(),
|
|
difficulty: document.getElementById('difficulty').value,
|
|
summary: document.getElementById('summary').value.trim(),
|
|
content: document.getElementById('content').value,
|
|
tags: (document.getElementById('tags').value || '').split(',').map(t => t.trim()).filter(Boolean),
|
|
steps: parseJsonField('steps'),
|
|
relations: parseJsonField('relations')
|
|
};
|
|
|
|
if (!payload.title || !payload.module || !payload.content) {
|
|
throw new Error('Titel, modul og indhold er påkrævet.');
|
|
}
|
|
|
|
const endpoint = editingId ? `/api/v1/manual/${editingId}` : '/api/v1/manual';
|
|
const method = editingId ? 'PUT' : 'POST';
|
|
|
|
const response = await fetch(endpoint, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.detail || 'Kunne ikke gemme manual.');
|
|
}
|
|
|
|
status.textContent = (editingId ? 'Manual opdateret: ' : 'Manual gemt: ') + (data.slug || data.title);
|
|
saveBtn.innerHTML = '<i class="bi bi-save me-1"></i>Gem manual';
|
|
window.setTimeout(() => {
|
|
window.location.href = '/manual/' + data.slug;
|
|
}, 500);
|
|
} catch (error) {
|
|
status.textContent = 'Fejl: ' + (error.message || error);
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
editingId = null;
|
|
document.getElementById('title').value = '';
|
|
document.getElementById('module').value = '';
|
|
document.getElementById('summary').value = '';
|
|
document.getElementById('content').value = '';
|
|
document.getElementById('tags').value = '';
|
|
document.getElementById('steps').value = '[]';
|
|
document.getElementById('relations').value = '[]';
|
|
document.getElementById('status').textContent = '';
|
|
document.getElementById('saveBtn').innerHTML = '<i class="bi bi-save me-1"></i>Gem manual';
|
|
renderPreview();
|
|
}
|
|
|
|
async function loadManual(slug) {
|
|
const status = document.getElementById('status');
|
|
status.textContent = 'Henter manual...';
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/manual/${encodeURIComponent(slug)}`, {
|
|
method: 'GET',
|
|
credentials: 'include'
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.detail || 'Kunne ikke hente manual.');
|
|
}
|
|
|
|
editingId = data.id;
|
|
document.getElementById('title').value = data.title || '';
|
|
document.getElementById('module').value = data.module || '';
|
|
document.getElementById('summary').value = normalizeEditorText(data.summary);
|
|
document.getElementById('content').value = normalizeEditorText(data.content);
|
|
document.getElementById('difficulty').value = data.difficulty || 'beginner';
|
|
document.getElementById('tags').value = (data.tags || []).join(', ');
|
|
const normalizedSteps = (data.steps || []).map((step) => ({
|
|
...step,
|
|
content: normalizeEditorText(step.content)
|
|
}));
|
|
document.getElementById('steps').value = JSON.stringify(normalizedSteps, null, 2);
|
|
document.getElementById('relations').value = JSON.stringify(data.relations || [], null, 2);
|
|
document.getElementById('saveBtn').innerHTML = '<i class="bi bi-pencil-square me-1"></i>Opdater manual';
|
|
renderPreview();
|
|
status.textContent = 'Redigerer: ' + (data.title || slug);
|
|
} catch (error) {
|
|
status.textContent = 'Fejl: ' + (error.message || error);
|
|
}
|
|
}
|
|
|
|
renderPreview();
|
|
</script>
|
|
{% endblock %}
|