2026-08-28 20:49:55 +02:00
{% extends "shared/frontend/base.html" %}
{% block title %}Website-indhold · BMC Hub{% endblock %}
{% block extra_css %}
< style >
.wc-shell { max-width: 1500px; margin: 0 auto; }
.wc-hero { background: linear-gradient(135deg, #06364b, #087894); color: #fff; border-radius: 22px; padding: 1.5rem; box-shadow: 0 14px 35px rgba(5,54,75,.2); }
.wc-kicker { text-transform: uppercase; letter-spacing: .13em; font-size: .72rem; opacity: .74; font-weight: 700; }
.wc-panel { background: var(--card-bg, #fff); color: var(--text-color, #17202a); border: 1px solid var(--border-color, #dde4e8); border-radius: 18px; overflow: hidden; }
.wc-panel-head { padding: 1rem 1.15rem; border-bottom: 1px solid var(--border-color, #dde4e8); display:flex; align-items:center; justify-content:space-between; gap:1rem; }
.wc-list { display:grid; gap:.75rem; padding:1rem; }
.wc-row { border:1px solid var(--border-color, #dde4e8); border-radius:14px; padding:1rem; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:1rem; align-items:center; background:var(--body-bg, #fff); }
2026-10-08 08:58:02 +02:00
.wc-logo { width:92px; height:54px; object-fit:contain; border-radius:8px; padding:5px; background:var(--theme-surface, #fff); border:1px solid #e5e7eb; }
2026-08-28 20:49:55 +02:00
.wc-meta { color:var(--text-muted, #68737d); font-size:.84rem; }
.wc-severity { display:inline-flex; align-items:center; gap:.35rem; border-radius:999px; padding:.25rem .6rem; font-size:.75rem; font-weight:700; }
.wc-severity.ok { color:#087b4d; background:#d9f8ea; } .wc-severity.info { color:#1261a0; background:#ddebfa; }
2026-10-08 08:58:02 +02:00
.wc-severity.warning { color:#926300; background:#fff0c2; } .wc-severity.critical { color:var(--theme-danger-text, #b42318); background:var(--theme-surface-soft, #fee4e2); }
2026-08-28 20:49:55 +02:00
.wc-hidden { opacity:.55; }
.wc-empty { text-align:center; color:var(--text-muted, #68737d); padding:2.5rem 1rem; }
.wc-tabs { display:flex; gap:.5rem; overflow:auto; padding-bottom:.25rem; }
.wc-tabs button { white-space:nowrap; border-radius:999px; }
.form-control, .form-select { background-color:var(--card-bg, #fff); color:var(--text-color, #17202a); border-color:var(--border-color, #ced4da); }
@media(max-width:700px) { .wc-row { grid-template-columns:1fr; } .wc-actions { width:100%; display:flex; } .wc-actions .btn { flex:1; } }
< / style >
{% endblock %}
{% block content %}
{% set can_edit = current_user.is_superadmin or 'website_content.edit' in (current_user.permissions or []) %}
< main class = "container-fluid py-4 px-lg-4 wc-shell" data-can-edit = "{{ 'true' if can_edit else 'false' }}" >
< section class = "wc-hero mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3" >
< div > < div class = "wc-kicker mb-2" > Nordic Top · website administration< / div > < h1 class = "h3 mb-2" > Website-indhold< / h1 > < p class = "mb-0 opacity-75" > Administrér kundelogoer, aktuel driftsstatus og offentlig historik.< / p > < / div >
< div id = "connectionState" class = "small opacity-75" > < i class = "bi bi-circle-fill me-1" > < / i > Forbinder…< / div >
< / section >
< div class = "wc-tabs mb-3" role = "tablist" >
< button class = "btn btn-primary" data-tab = "customers" > < i class = "bi bi-buildings me-1" > < / i > Kundereferencer< / button >
< button class = "btn btn-outline-secondary" data-tab = "operations" > < i class = "bi bi-broadcast me-1" > < / i > Aktuel drift< / button >
< button class = "btn btn-outline-secondary" data-tab = "incidents" > < i class = "bi bi-clock-history me-1" > < / i > Historik< / button >
< / div >
< section class = "wc-panel" id = "contentPanel" >
< div class = "wc-panel-head" >
< div > < h2 class = "h5 mb-1" id = "panelTitle" > Kundereferencer< / h2 > < div class = "wc-meta" id = "panelSubtitle" > Rækkefølge og logoer på forsiden< / div > < / div >
{% if can_edit %}< button class = "btn btn-primary" id = "createBtn" > < i class = "bi bi-plus-lg me-1" > < / i > Opret< / button > {% endif %}
< / div >
< div class = "wc-list" id = "contentList" > < div class = "wc-empty" > < div class = "spinner-border spinner-border-sm me-2" > < / div > Indlæser…< / div > < / div >
< / section >
< / main >
< div class = "modal fade" id = "editorModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-lg modal-dialog-scrollable" > < form class = "modal-content" id = "editorForm" >
< div class = "modal-header" > < h2 class = "modal-title fs-5" id = "editorTitle" > Redigér< / h2 > < button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button > < / div >
< div class = "modal-body" id = "editorFields" > < / div >
< div class = "modal-footer" > < button type = "button" class = "btn btn-outline-secondary" data-bs-dismiss = "modal" > Annuller< / button > < button class = "btn btn-primary" type = "submit" > Gem< / button > < / div >
< / form > < / div >
< / div >
< div class = "toast-container position-fixed bottom-0 end-0 p-3" > < div id = "wcToast" class = "toast" > < div class = "toast-body" id = "wcToastText" > < / div > < / div > < / div >
{% endblock %}
{% block extra_js %}
< script >
(() => {
const api = '/api/v1/website-content';
const canEdit = document.querySelector('.wc-shell').dataset.canEdit === 'true';
const labels = {
customers: ['Kundereferencer', 'Rækkefølge og logoer på forsiden'],
operations: ['Aktuel driftsstatus', 'Aktive, kommende og skjulte meddelelser'],
incidents: ['Driftshistorik', 'Offentlige og skjulte afsluttede hændelser']
};
let tab = 'customers', items = [], editing = null;
const modal = new bootstrap.Modal(document.getElementById('editorModal'));
const esc = value => String(value ?? '').replace(/[& < >'"]/g, c => ({'&':'& ','< ':'< ','>':'> ',"'":'' ','"':'" '}[c]));
const localInput = value => value ? String(value).replace(' ', 'T').slice(0,16) : '';
const formatDate = value => value ? new Intl.DateTimeFormat('da-DK', {dateStyle:'medium', timeStyle:'short'}).format(new Date(String(value).replace(' ', 'T'))) : 'Ikke angivet';
const toast = (message, error=false) => { const el=document.getElementById('wcToast'); el.classList.toggle('text-bg-danger', error); el.classList.toggle('text-bg-success', !error); document.getElementById('wcToastText').textContent=message; bootstrap.Toast.getOrCreateInstance(el).show(); };
async function request(path, options={}) {
const response = await fetch(api + path, {credentials:'include', ...options});
if (!response.ok) { let detail='Handlingen mislykkedes'; try { detail=(await response.json()).detail || detail; } catch {} throw new Error(detail); }
return response.status === 204 ? null : response.json();
}
async function load() {
const list=document.getElementById('contentList'); list.innerHTML='< div class = "wc-empty" > < div class = "spinner-border spinner-border-sm me-2" > < / div > Indlæser…< / div > ';
try { items=await request(`/${tab}`); render(); document.getElementById('connectionState').innerHTML='< i class = "bi bi-check-circle-fill me-1" > < / i > Website-database forbundet'; }
catch(error) { list.innerHTML=`< div class = "wc-empty text-danger" > < i class = "bi bi-exclamation-triangle fs-3 d-block mb-2" > < / i > ${esc(error.message)}< / div > `; document.getElementById('connectionState').innerHTML='< i class = "bi bi-x-circle-fill me-1" > < / i > Ikke forbundet'; }
}
function render() {
const list=document.getElementById('contentList');
if (!items.length) { list.innerHTML='< div class = "wc-empty" > Der er endnu ikke oprettet indhold her.< / div > '; return; }
list.innerHTML=items.map(item => tab==='customers' ? customerRow(item) : operationRow(item)).join('');
list.querySelectorAll('[data-edit]').forEach(button => button.onclick=()=>openEditor(Number(button.dataset.edit)));
list.querySelectorAll('[data-toggle]').forEach(button => button.onclick=()=>toggleVisibility(Number(button.dataset.toggle)));
list.querySelectorAll('[data-complete]').forEach(button => button.onclick=()=>complete(Number(button.dataset.complete)));
}
function customerRow(item) {
const active=!!item.is_active, preview=`${api}/customers/${item.id}/logo?v=${encodeURIComponent(item.updated_at || '')}`;
return `< article class = "wc-row ${active?'':'wc-hidden'}" > < div class = "d-flex align-items-center gap-3" > < img class = "wc-logo" src = "${preview}" onerror = "this.onerror=null;this.src='${esc(item.logo_url || '')}'" alt = "" > < div > < div class = "fw-semibold" > ${esc(item.customer_name)}< / div > < div class = "wc-meta" > Placering ${item.sort_order} · ${active?'Synlig':'Skjult'}< / div > ${item.website_url?`< a class = "small" href = "${esc(item.website_url)}" target = "_blank" rel = "noopener" > ${esc(item.website_url)}< / a > `:''}< / div > < / div > ${actions(item, active)}`;
}
function operationRow(item) {
const visibility=tab==='operations' ? !!item.is_active : !!item.is_public;
return `< article class = "wc-row ${visibility?'':'wc-hidden'}" > < div > < div class = "d-flex flex-wrap align-items-center gap-2 mb-1" > < span class = "wc-severity ${item.severity}" > ${esc(item.severity)}< / span > < strong > ${esc(item.title)}< / strong > < / div > < div class = "mb-2" > ${esc(item.message)}< / div > < div class = "wc-meta" > ${formatDate(item.starts_at)} → ${formatDate(item.ends_at)} · ${visibility?(tab==='operations'?'Aktiv':'Offentlig'):'Skjult'}< / div > < / div > ${actions(item, visibility)}`;
}
function actions(item, visible) {
if (!canEdit) return '';
return `< div class = "wc-actions d-flex gap-2" > < button class = "btn btn-sm btn-outline-secondary" data-edit = "${item.id}" > < i class = "bi bi-pencil" > < / i > < span class = "visually-hidden" > Redigér< / span > < / button > ${tab==='operations'& & item.is_active?`< button class = "btn btn-sm btn-success" data-complete = "${item.id}" > < i class = "bi bi-check2-circle me-1" > < / i > Afslut< / button > `:''}< button class = "btn btn-sm btn-outline-secondary" data-toggle = "${item.id}" > ${visible?'Skjul':'Vis'}< / button > < / div > `;
}
function openEditor(id=null) {
editing=id ? items.find(item=>item.id===id) : null;
document.getElementById('editorTitle').textContent=(editing?'Redigér ':'Opret ') + labels[tab][0].toLowerCase();
document.getElementById('editorFields').innerHTML=tab==='customers' ? customerFields(editing||{}) : operationFields(editing||{});
modal.show();
}
function customerFields(v) { return `< div class = "row g-3" > < div class = "col-md-8" > < label class = "form-label" > Kundenavn< / label > < input class = "form-control" name = "customer_name" maxlength = "180" required value = "${esc(v.customer_name)}" > < / div > < div class = "col-md-4" > < label class = "form-label" > Rækkefølge< / label > < input class = "form-control" type = "number" min = "0" name = "sort_order" value = "${v.sort_order ?? 100}" > < / div > < div class = "col-12" > < label class = "form-label" > Website-link< / label > < input class = "form-control" type = "url" name = "website_url" value = "${esc(v.website_url)}" placeholder = "https://…" > < / div > < div class = "col-12" > < label class = "form-label" > Logo< / label > < input class = "form-control" type = "file" name = "logo" accept = "image/png,image/jpeg,image/webp,image/gif" > < div class = "form-text" > PNG, JPEG, WebP eller GIF · maks. 5 MB. Tomt felt beholder eksisterende logo.< / div > < img id = "logoPreview" class = "wc-logo mt-2 d-none" alt = "Logo-preview" > < / div > < div class = "col-12 form-check ms-2" > < input class = "form-check-input" type = "checkbox" name = "is_active" id = "activeCheck" $ { v . is_active = ==0?'':'checked'} > < label class = "form-check-label" for = "activeCheck" > Synlig på websitet< / label > < / div > < / div > `; }
function operationFields(v) { const visibility=tab==='operations'?'is_active':'is_public'; return `< div class = "row g-3" > < div class = "col-md-8" > < label class = "form-label" > Titel< / label > < input class = "form-control" name = "title" maxlength = "180" required value = "${esc(v.title)}" > < / div > < div class = "col-md-4" > < label class = "form-label" > Severity< / label > < select class = "form-select" name = "severity" > ${['ok','info','warning','critical'].map(x=>`< option $ { v . severity = ==x?'selected':''} > ${x}< / option > `).join('')}< / select > < / div > < div class = "col-12" > < label class = "form-label" > Meddelelse< / label > < textarea class = "form-control" name = "message" rows = "5" required > ${esc(v.message)}< / textarea > < / div > < div class = "col-md-6" > < label class = "form-label" > Planlagt start< / label > < input class = "form-control" type = "datetime-local" name = "starts_at" value = "${localInput(v.starts_at)}" > < / div > < div class = "col-md-6" > < label class = "form-label" > Planlagt slut< / label > < input class = "form-control" type = "datetime-local" name = "ends_at" value = "${localInput(v.ends_at)}" > < / div > < div class = "col-12 form-check ms-2" > < input class = "form-check-input" type = "checkbox" name = "${visibility}" id = "visibleCheck" $ { v [ visibility ] = = = 0 ? ' ' : ' checked ' } > < label class = "form-check-label" for = "visibleCheck" > ${tab==='operations'?'Aktiv':'Offentlig'}< / label > < / div > < / div > `; }
document.getElementById('editorFields').addEventListener('change', event => { if(event.target.name==='logo'& & event.target.files[0]) { const p=document.getElementById('logoPreview'); p.src=URL.createObjectURL(event.target.files[0]); p.classList.remove('d-none'); } });
document.getElementById('editorForm').onsubmit=async event => {
event.preventDefault(); const form=new FormData(event.target), path=`/${tab}${editing?'/'+editing.id:''}`;
const payload=tab==='customers' ? {customer_name:form.get('customer_name'), website_url:form.get('website_url')||null, sort_order:Number(form.get('sort_order')), is_active:form.has('is_active')} : {title:form.get('title'), severity:form.get('severity'), message:form.get('message'), starts_at:form.get('starts_at')||null, ends_at:form.get('ends_at')||null, [tab==='operations'?'is_active':'is_public']:form.has(tab==='operations'?'is_active':'is_public')};
try { const saved=await request(path,{method:editing?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const logo=form.get('logo'); if(tab==='customers'& & logo& & logo.size){const data=new FormData();data.append('logo',logo);await request(`/customers/${saved.id}/logo`,{method:'POST',body:data});} modal.hide(); toast('Indholdet er gemt'); await load(); } catch(error){toast(error.message,true);}
};
async function toggleVisibility(id) { const item=items.find(x=>x.id===id), field=tab==='incidents'?'is_public':'is_active'; try{await request(`/${tab}/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({[field]:!item[field]})});await load();}catch(error){toast(error.message,true);} }
async function complete(id) { if(!confirm('Afslut hændelsen og flyt den til historikken?'))return; try{await request(`/operations/${id}/complete`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_public:true})});toast('Hændelsen er flyttet til historikken');await load();}catch(error){toast(error.message,true);} }
document.querySelectorAll('[data-tab]').forEach(button=>button.onclick=()=>{tab=button.dataset.tab;document.querySelectorAll('[data-tab]').forEach(x=>x.className='btn btn-outline-secondary');button.className='btn btn-primary';document.getElementById('panelTitle').textContent=labels[tab][0];document.getElementById('panelSubtitle').textContent=labels[tab][1];load();});
document.getElementById('createBtn')?.addEventListener('click',()=>openEditor()); load();
})();
< / script >
{% endblock %}