v2.5.1: Admin theme refactor, Navigation plugin, user roles, guide restructure
- Reorganize admin into admin/theme/default/ (views + assets) - Rename GuideNav to Navigation plugin (essential, protected) - Plugin assets support (SCSS/CSS) loaded after theme CSS - User roles: Admin, Content Manager, BI Manager, Site Admin - Role-based access control (RBAC) for admin routes and sidebar - Guide restructure: sub-topics in separate folders with sidebar nav - Dynamic breadcrumb for homepage and subdirectories - Fix theme path traversal (../../ -> ../) in admin.php - Fix CodeMirror mode load order (xml -> css -> js -> htmlmixed -> php) - Fix editor-toolbar.js null checks for plugin edit pages - Layout select from theme.json with live frontmatter update - Footer sticky at bottom of viewport (min-height: 100vh) - Breadcrumb color fix (var(--nav-font) -> var(--header-bg)) - Remove language switcher from guide pages - Update README.md and README.en.md - Bump version to 2.5.1
This commit is contained in:
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(S){var n={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},k=S.Pos;function y(e,t){return"pairs"==t&&"string"==typeof e?e:("object"==typeof e&&null!=e[t]?e:n)[t]}S.defineOption("autoCloseBrackets",!1,function(e,t,n){n&&n!=S.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),t&&(r(y(t,"pairs")),e.state.closeBrackets=t,e.addKeyMap(i))});var i={Backspace:function(e){var t=O(e);if(!t||e.getOption("disableInput"))return S.Pass;for(var n=y(t,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return S.Pass;var a=s(e,r[i].head);if(!a||n.indexOf(a)%2!=0)return S.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",k(o.line,o.ch-1),k(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=O(r),t=e&&y(e,"explode");if(!t||r.getOption("disableInput"))return S.Pass;for(var i=r.listSelections(),n=0;n<i.length;n++){if(!i[n].empty())return S.Pass;var a=s(r,i[n].head);if(!a||t.indexOf(a)%2!=0)return S.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),m(r,-1),i=r.listSelections();for(var t=0;t<i.length;t++){var n=i[t].head.line;r.indentLine(n,null,!0),r.indentLine(n+1,null,!0)}})}};function r(e){for(var t=0;t<e.length;t++){var n=e.charAt(t),r="'"+n+"'";i[r]||(i[r]=function(P){return function(e){var i=e,t=P,e=O(i);if(!e||i.getOption("disableInput"))return S.Pass;var n=y(e,"pairs"),r=n.indexOf(t);if(-1==r)return S.Pass;for(var a,o=y(e,"closeBefore"),s=y(e,"triples"),l=n.charAt(r+1)==t,c=i.listSelections(),h=r%2==0,f=0;f<c.length;f++){var u,d=c[f],p=d.head,g=i.getRange(p,k(p.line,p.ch+1));if(h&&!d.empty())u="surround";else if(!l&&h||g!=t)if(l&&1<p.ch&&0<=s.indexOf(t)&&i.getRange(k(p.line,p.ch-2),p)==t+t){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(k(p.line,p.ch-2))))return S.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(k(p.line,p.ch-1),p);if(S.isWordChar(g)||d==t||S.isWordChar(d))return S.Pass;u="both"}else{if(!h||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return S.Pass;u="both"}else u=l&&function(e,t){var n=e.getTokenAt(k(t.line,t.ch+1));return/\bstring/.test(n.type)&&n.start==t.ch&&(0==t.ch||!/\bstring/.test(e.getTokenTypeAt(t)))}(i,p)?"both":0<=s.indexOf(t)&&i.getRange(p,k(p.line,p.ch+3))==t+t+t?"skipThree":"skip";if(a){if(a!=u)return S.Pass}else a=u}var v=r%2?n.charAt(r-1):t,b=r%2?t:n.charAt(r+1);i.operation(function(){if("skip"==a)m(i,1);else if("skipThree"==a)m(i,3);else if("surround"==a){for(var e=i.getSelections(),t=0;t<e.length;t++)e[t]=v+e[t]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),t=0;t<e.length;t++)e[t]=(n=e[t],r=void 0,r=0<S.cmpPos(n.anchor,n.head),{anchor:new k(n.anchor.line,n.anchor.ch+(r?-1:1)),head:new k(n.head.line,n.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),m(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),m(i,1));var n,r})}}(n))}}function O(e){var t=e.state.closeBrackets;return t&&!t.override&&e.getModeAt(e.getCursor()).closeBrackets||t}function m(e,t){for(var n=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a],o=(o.head==e.getCursor()&&(i=a),o.head.ch||0<t?{line:o.head.line,ch:o.head.ch+t}:{line:o.head.line-1});n.push({anchor:o,head:o})}e.setSelections(n,i)}function s(e,t){e=e.getRange(k(t.line,t.ch-1),k(t.line,t.ch+1));return 2==e.length?e:null}r(n.pairs+"`")});
|
||||
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(i){"use strict";var a="CodeMirror-activeline",s="CodeMirror-activeline-background",c="CodeMirror-activeline-gutter";function l(e){for(var t=0;t<e.state.activeLines.length;t++)e.removeLineClass(e.state.activeLines[t],"wrap",a),e.removeLineClass(e.state.activeLines[t],"background",s),e.removeLineClass(e.state.activeLines[t],"gutter",c)}function o(t,e){for(var n=[],i=0;i<e.length;i++){var o=e[i],r=t.getOption("styleActiveLine");("object"==typeof r&&r.nonEmpty?o.anchor.line==o.head.line:o.empty())&&(r=t.getLineHandleVisualStart(o.head.line),n[n.length-1]!=r&&n.push(r))}!function(e,t){if(e.length==t.length){for(var n=0;n<e.length;n++)if(e[n]!=t[n])return;return 1}}(t.state.activeLines,n)&&t.operation(function(){l(t);for(var e=0;e<n.length;e++)t.addLineClass(n[e],"wrap",a),t.addLineClass(n[e],"background",s),t.addLineClass(n[e],"gutter",c);t.state.activeLines=n})}function r(e,t){o(e,t.ranges)}i.defineOption("styleActiveLine",!1,function(e,t,n){n=n!=i.Init&&n;t!=n&&(n&&(e.off("beforeSelectionChange",r),l(e),delete e.state.activeLines),t&&(e.state.activeLines=[],o(e,e.listSelections()),e.on("beforeSelectionChange",r)))})});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2078
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
|
||||
.CodeMirror {
|
||||
height: auto;
|
||||
min-height: 500px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-bottom: none;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.editor-toolbar .vr {
|
||||
height: 22px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.editor-toolbar .btn {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-wrapper .CodeMirror {
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-media-item {
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.card-media-item:hover {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Admin theme styles */
|
||||
|
||||
/* Code block styling (for guide pages) */
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e8e8e8;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
color: #d63384;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- < -->
|
||||
<path d="M8 8 L3 16 L8 24" stroke="#ffffff" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- / -->
|
||||
<path d="M12 24 L18 8" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- .. -->
|
||||
<circle cx="22" cy="20" r="2" fill="#ffffff"/>
|
||||
<circle cx="28" cy="20" r="2" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 442 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 164 KiB |
@@ -0,0 +1,94 @@
|
||||
// Main application JavaScript
|
||||
// This file contains general application functionality
|
||||
|
||||
/**
|
||||
* Toggle sidebar visibility (open/close)
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('site-sidebar');
|
||||
const contentCol = sidebar ? sidebar.nextElementSibling || sidebar.parentElement.querySelector('.content-column') : null;
|
||||
const btn = document.querySelector('.sidebar-toggle-btn');
|
||||
const icon = btn ? btn.querySelector('i') : null;
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
sidebar.classList.toggle('sidebar-hidden');
|
||||
|
||||
// Adjust content column width, toggle icon, and update aria-expanded
|
||||
if (sidebar.classList.contains('sidebar-hidden')) {
|
||||
if (contentCol) {
|
||||
contentCol.classList.remove('col-lg-9', 'col-md-8');
|
||||
contentCol.classList.add('col-12');
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-layout-sidebar-inset');
|
||||
icon.classList.add('bi-layout-sidebar');
|
||||
}
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
sessionStorage.setItem('sidebarHidden', 'true');
|
||||
} else {
|
||||
if (contentCol) {
|
||||
contentCol.classList.remove('col-12');
|
||||
contentCol.classList.add('col-lg-9', 'col-md-8');
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-layout-sidebar');
|
||||
icon.classList.add('bi-layout-sidebar-inset');
|
||||
}
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
sessionStorage.setItem('sidebarHidden', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore sidebar state from sessionStorage on page load
|
||||
*/
|
||||
function restoreSidebarState() {
|
||||
if (sessionStorage.getItem('sidebarHidden') === 'true') {
|
||||
const sidebar = document.getElementById('site-sidebar');
|
||||
if (sidebar) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Restore sidebar state
|
||||
restoreSidebarState();
|
||||
|
||||
// Handle nested dropdowns for touch devices using event delegation
|
||||
document.addEventListener('click', function(e) {
|
||||
const toggle = e.target.closest('.dropdown-submenu .dropdown-toggle');
|
||||
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const submenu = toggle.closest('.dropdown-submenu');
|
||||
const dropdown = submenu.querySelector('.dropdown-menu');
|
||||
|
||||
// Close other submenus at the same level
|
||||
const parent = submenu.parentElement;
|
||||
parent.querySelectorAll('.dropdown-submenu').forEach(function(sibling) {
|
||||
if (sibling !== submenu) {
|
||||
var siblingMenu = sibling.querySelector('.dropdown-menu');
|
||||
if (siblingMenu) siblingMenu.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current submenu
|
||||
if (dropdown) dropdown.classList.toggle('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all open submenus when clicking outside
|
||||
document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach(function(menu) {
|
||||
menu.classList.remove('show');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,273 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var textarea = document.getElementById('editor-textarea');
|
||||
if (!textarea || typeof CodeMirror === 'undefined') return;
|
||||
|
||||
var ext = textarea.dataset.ext || 'md';
|
||||
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||
|
||||
var modeMap = { md: 'markdown', html: 'htmlmixed', php: 'php' };
|
||||
|
||||
var editor = CodeMirror.fromTextArea(textarea, {
|
||||
mode: modeMap[ext] || 'markdown',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
matchBrackets: true,
|
||||
autoCloseBrackets: true,
|
||||
styleActiveLine: true,
|
||||
indentUnit: 4,
|
||||
tabSize: 4,
|
||||
indentWithTabs: true,
|
||||
viewportMargin: Infinity,
|
||||
extraKeys: {
|
||||
'Ctrl-S': function () { if (form) form.submit(); },
|
||||
'Cmd-S': function () { if (form) form.submit(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Expose editor globally so other scripts can access it
|
||||
window.codeMirrorEditor = editor;
|
||||
|
||||
var commands = {
|
||||
md: [
|
||||
{ cmd: 'bold', icon: 'bi-type-bold', title: 'Vet' },
|
||||
{ cmd: 'italic', icon: 'bi-type-italic', title: 'Cursief' },
|
||||
{ cmd: 'heading', icon: 'bi-type-h2', title: 'Kop' },
|
||||
{ cmd: 'link', icon: 'bi-link-45deg', title: 'Link' },
|
||||
null,
|
||||
{ cmd: 'ulist', icon: 'bi-list-ul', title: 'Ongenummerde lijst' },
|
||||
{ cmd: 'olist', icon: 'bi-list-ol', title: 'Genummerde lijst' },
|
||||
{ cmd: 'code', icon: 'bi-code-slash', title: 'Code' },
|
||||
{ cmd: 'quote', icon: 'bi-chat-quote', title: 'Citaat' },
|
||||
null,
|
||||
{ cmd: 'hr', icon: 'bi-hr', title: 'Horizontale lijn' },
|
||||
null,
|
||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||
],
|
||||
html: [
|
||||
{ cmd: 'strong', icon: 'bi-type-bold', title: '<strong>' },
|
||||
{ cmd: 'em', icon: 'bi-type-italic', title: '<em>' },
|
||||
null,
|
||||
{ cmd: 'link', icon: 'bi-link-45deg', title: 'Link' },
|
||||
{ cmd: 'image', icon: 'bi-image', title: 'Afbeelding' },
|
||||
null,
|
||||
{ cmd: 'comment', icon: 'bi-chat-square-dots', title: 'Commentaar' },
|
||||
null,
|
||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||
],
|
||||
php: [
|
||||
{ cmd: 'comment_php', icon: 'bi-slash-circle', title: '// Commentaar' },
|
||||
{ cmd: 'docblock', icon: 'bi-blockquote-left', title: '/** DocBlock */' },
|
||||
null,
|
||||
{ cmd: 'media', icon: 'bi-images', title: 'Media invoegen' }
|
||||
]
|
||||
};
|
||||
|
||||
function wrap(editor, before, after) {
|
||||
var sel = editor.getSelection();
|
||||
editor.replaceSelection(before + sel + after);
|
||||
if (!sel) {
|
||||
var cur = editor.getCursor();
|
||||
editor.setCursor({ line: cur.line, ch: cur.ch - after.length });
|
||||
}
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function insert(editor, text, cursorOffset) {
|
||||
editor.replaceSelection(text);
|
||||
if (cursorOffset !== undefined) {
|
||||
var cur = editor.getCursor();
|
||||
editor.setCursor({ line: cur.line, ch: cur.ch - text.length + cursorOffset });
|
||||
}
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function prependLine(editor, prefix) {
|
||||
var cur = editor.getCursor();
|
||||
editor.replaceRange(prefix, { line: cur.line, ch: 0 }, { line: cur.line, ch: 0 });
|
||||
editor.setCursor({ line: cur.line, ch: prefix.length });
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function execute(cmd) {
|
||||
var sel = editor.getSelection();
|
||||
|
||||
switch (cmd) {
|
||||
case 'bold':
|
||||
sel ? wrap(editor, '**', '**') : insert(editor, '****', 2);
|
||||
break;
|
||||
case 'italic':
|
||||
sel ? wrap(editor, '*', '*') : insert(editor, '**', 1);
|
||||
break;
|
||||
case 'heading':
|
||||
prependLine(editor, '## ');
|
||||
break;
|
||||
case 'link':
|
||||
sel ? wrap(editor, '[', '](url)') : insert(editor, '[linktekst](url)', 1);
|
||||
break;
|
||||
case 'ulist':
|
||||
prependLine(editor, '- ');
|
||||
break;
|
||||
case 'olist':
|
||||
prependLine(editor, '1. ');
|
||||
break;
|
||||
case 'code':
|
||||
sel ? wrap(editor, '`', '`') : insert(editor, '``', 1);
|
||||
break;
|
||||
case 'quote':
|
||||
prependLine(editor, '> ');
|
||||
break;
|
||||
case 'hr':
|
||||
insert(editor, '\n---\n', 0);
|
||||
break;
|
||||
case 'strong':
|
||||
sel ? wrap(editor, '<strong>', '</strong>') : insert(editor, '<strong></strong>', 8);
|
||||
break;
|
||||
case 'em':
|
||||
sel ? wrap(editor, '<em>', '</em>') : insert(editor, '<em></em>', 4);
|
||||
break;
|
||||
case 'image':
|
||||
insert(editor, '<img src="" alt="">', 10);
|
||||
break;
|
||||
case 'comment':
|
||||
sel ? wrap(editor, '<!-- ', ' -->') : insert(editor, '<!-- -->', 4);
|
||||
break;
|
||||
case 'comment_php':
|
||||
if (sel) {
|
||||
sel.indexOf('\n') !== -1
|
||||
? insert(editor, '/* ' + sel + ' */', 0)
|
||||
: insert(editor, '// ' + sel, 0);
|
||||
} else {
|
||||
prependLine(editor, '// ');
|
||||
}
|
||||
break;
|
||||
case 'docblock':
|
||||
insert(editor, '/**\n * \n */', 7);
|
||||
break;
|
||||
case 'media':
|
||||
var modal = new bootstrap.Modal(document.getElementById('mediaModal'));
|
||||
if (modal) modal.show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolbar(ext) {
|
||||
var toolbar = document.getElementById('editor-toolbar');
|
||||
if (!toolbar) return;
|
||||
|
||||
toolbar.innerHTML = '';
|
||||
var cmds = commands[ext] || [];
|
||||
cmds.forEach(function (item) {
|
||||
if (item === null) {
|
||||
var sep = document.createElement('div');
|
||||
sep.className = 'vr';
|
||||
toolbar.appendChild(sep);
|
||||
return;
|
||||
}
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-secondary';
|
||||
btn.title = item.title;
|
||||
btn.innerHTML = '<i class="bi ' + item.icon + '"></i>';
|
||||
btn.addEventListener('click', function () { execute(item.cmd); });
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
var templates = {
|
||||
md: '# Nieuwe Pagina\n\nSchrijf hier je inhoud...\n',
|
||||
html: '<h1>Nieuwe Pagina</h1>\n<p>Dit is een HTML content pagina.</p>\n',
|
||||
php: '---\ntitle: Nieuwe Pagina\n---\n\n<?php\n$pageTitle = "Nieuwe Pagina";\n?>\n\n<h1><?= $pageTitle ?></h1>\n<p>PHP content - alles wat je hier echo't wordt weergegeven.</p>\n\n<?php\n$items = [\'Item 1\', \'Item 2\', \'Item 3\'];\n?>\n<ul>\n<?php foreach ($items as $item): ?>\n <li><?= $item ?></li>\n<?php endforeach; ?>\n</ul>\n'
|
||||
};
|
||||
|
||||
var defaultContents = {};
|
||||
|
||||
function getTemplate(ext) {
|
||||
return templates[ext] || '';
|
||||
}
|
||||
|
||||
function setDefaultContent(ext) {
|
||||
var tpl = getTemplate(ext);
|
||||
editor.setValue(tpl);
|
||||
editor.setCursor({ line: 0, ch: 0 });
|
||||
editor.focus();
|
||||
defaultContents[ext] = tpl;
|
||||
}
|
||||
|
||||
function switchMode(ext, forceContent) {
|
||||
var mode = modeMap[ext] || 'markdown';
|
||||
editor.setOption('mode', mode);
|
||||
textarea.dataset.ext = ext;
|
||||
|
||||
if (forceContent) {
|
||||
setDefaultContent(ext);
|
||||
} else {
|
||||
var cur = editor.getValue().trim();
|
||||
var expected = defaultContents[ext];
|
||||
if (cur === '' || (expected && cur === expected.trim())) {
|
||||
setDefaultContent(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editor.on('change', function () {
|
||||
if (window.__onContentChange) window.__onContentChange();
|
||||
});
|
||||
|
||||
buildToolbar(ext);
|
||||
|
||||
var form = document.getElementById('editor-form');
|
||||
var isNewPage = form && form.hasAttribute('data-new-page');
|
||||
|
||||
if (isNewPage && editor.getValue().trim() === '') {
|
||||
setDefaultContent(ext);
|
||||
}
|
||||
|
||||
var typeSelect = document.querySelector('[data-editor-mode]');
|
||||
if (typeSelect && isNewPage) {
|
||||
typeSelect.addEventListener('change', function () {
|
||||
switchMode(this.value, true);
|
||||
});
|
||||
}
|
||||
|
||||
var form = document.getElementById('editor-form') || textarea.closest('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
editor.save();
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
|
||||
function handleShortcut(e) {
|
||||
var mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod || e.altKey) return;
|
||||
|
||||
var key = (e.key || '').toLowerCase();
|
||||
|
||||
if (key === 's') {
|
||||
e.preventDefault();
|
||||
editor.save();
|
||||
if (form) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'n') {
|
||||
e.preventDefault();
|
||||
window.location.href = '/admin/content-new';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleShortcut);
|
||||
// CodeMirror swallows keystrokes inside the editor, so bind there too
|
||||
editor.setOption('extraKeys', Object.assign({}, editor.getOption('extraKeys') || {}, {
|
||||
'Ctrl-S': function () { handleShortcut({ ctrlKey: true, key: 's', preventDefault: function () {} }); },
|
||||
'Cmd-S': function () { handleShortcut({ metaKey: true, key: 's', preventDefault: function () {} }); }
|
||||
}));
|
||||
})();
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* KeyboardNavigation - WCAG 2.1 AA Compliant Keyboard Navigation
|
||||
*
|
||||
* Features:
|
||||
* - Full keyboard navigation support
|
||||
* - Focus management
|
||||
* - Skip links functionality
|
||||
* - Custom keyboard shortcuts
|
||||
* - Focus trap for modals
|
||||
* - WCAG 2.1 AA compliance
|
||||
*/
|
||||
class KeyboardNavigation {
|
||||
constructor() {
|
||||
this.focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
this.currentFocusIndex = -1;
|
||||
this.focusableElementsList = [];
|
||||
this.modalOpen = false;
|
||||
this.lastFocusedElement = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize keyboard navigation
|
||||
*/
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.setupSkipLinks();
|
||||
this.setupFocusManagement();
|
||||
this.setupKeyboardShortcuts();
|
||||
this.announceToScreenReader('Keyboard navigation initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for keyboard navigation
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
||||
document.addEventListener('focus', (e) => this.handleFocus(e), true);
|
||||
document.addEventListener('blur', (e) => this.handleBlur(e), true);
|
||||
|
||||
// Handle focus for dynamic content
|
||||
const observer = new MutationObserver(() => {
|
||||
this.updateFocusableElements();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['tabindex', 'disabled', 'aria-hidden']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleKeyDown(e) {
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
this.handleTabNavigation(e);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
this.handleActivation(e);
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleEscape(e);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
this.handleArrowNavigation(e);
|
||||
break;
|
||||
case 'Home':
|
||||
case 'End':
|
||||
this.handleHomeEndNavigation(e);
|
||||
break;
|
||||
default:
|
||||
this.handleCustomShortcuts(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Tab navigation
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleTabNavigation(e) {
|
||||
if (e.ctrlKey || e.altKey) return;
|
||||
|
||||
this.updateFocusableElements();
|
||||
|
||||
if (this.focusableElementsList.length === 0) return;
|
||||
|
||||
const currentIndex = this.focusableElementsList.indexOf(document.activeElement);
|
||||
let nextIndex;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab - Previous element
|
||||
nextIndex = currentIndex <= 0 ? this.focusableElementsList.length - 1 : currentIndex - 1;
|
||||
} else {
|
||||
// Tab - Next element
|
||||
nextIndex = currentIndex >= this.focusableElementsList.length - 1 ? 0 : currentIndex + 1;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.focusElement(this.focusableElementsList[nextIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle activation (Enter/Space)
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleActivation(e) {
|
||||
const element = document.activeElement;
|
||||
|
||||
if (e.key === ' ' && (element.tagName === 'BUTTON' || element.role === 'button')) {
|
||||
e.preventDefault();
|
||||
element.click();
|
||||
this.announceToScreenReader('Button activated');
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && element.tagName === 'A' && element.getAttribute('role') === 'menuitem') {
|
||||
e.preventDefault();
|
||||
element.click();
|
||||
this.announceToScreenReader('Link activated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Escape key
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleEscape(e) {
|
||||
if (this.modalOpen) {
|
||||
this.closeModal();
|
||||
this.announceToScreenReader('Modal closed');
|
||||
} else {
|
||||
// Return focus to main content
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
this.focusElement(mainContent);
|
||||
this.announceToScreenReader('Returned to main content');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle arrow key navigation
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleArrowNavigation(e) {
|
||||
const element = document.activeElement;
|
||||
|
||||
// Handle menu navigation
|
||||
if (element.getAttribute('role') === 'menuitem' || element.classList.contains('dropdown-item')) {
|
||||
e.preventDefault();
|
||||
this.navigateMenu(e.key);
|
||||
}
|
||||
|
||||
// Handle tab navigation in tab lists
|
||||
if (element.getAttribute('role') === 'tab') {
|
||||
e.preventDefault();
|
||||
this.navigateTabs(e.key);
|
||||
}
|
||||
|
||||
// Handle grid navigation
|
||||
if (element.getAttribute('role') === 'gridcell') {
|
||||
e.preventDefault();
|
||||
this.navigateGrid(e.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Home/End navigation
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleHomeEndNavigation(e) {
|
||||
if (e.ctrlKey || e.altKey) return;
|
||||
|
||||
this.updateFocusableElements();
|
||||
|
||||
if (this.focusableElementsList.length === 0) return;
|
||||
|
||||
const targetIndex = e.key === 'Home' ? 0 : this.focusableElementsList.length - 1;
|
||||
|
||||
e.preventDefault();
|
||||
this.focusElement(this.focusableElementsList[targetIndex]);
|
||||
this.announceToScreenReader(`Moved to ${e.key === 'Home' ? 'first' : 'last'} element`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup skip links functionality
|
||||
*/
|
||||
setupSkipLinks() {
|
||||
const skipLinks = document.querySelectorAll('.skip-link');
|
||||
|
||||
skipLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetId = link.getAttribute('href').substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
this.focusElement(targetElement);
|
||||
this.announceToScreenReader(`Skipped to ${link.textContent}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup focus management
|
||||
*/
|
||||
setupFocusManagement() {
|
||||
// Add focus indicators
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:focus {
|
||||
outline: 3px solid #0056b3 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
position: static !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
clip: auto !important;
|
||||
clip-path: none !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
[aria-hidden="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.keyboard-user *:focus {
|
||||
outline: 3px solid #0056b3 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Detect keyboard user
|
||||
document.addEventListener('keydown', () => {
|
||||
document.body.classList.add('keyboard-user');
|
||||
}, { once: true });
|
||||
|
||||
// Remove keyboard class on mouse use
|
||||
document.addEventListener('mousedown', () => {
|
||||
document.body.classList.remove('keyboard-user');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup custom keyboard shortcuts
|
||||
*/
|
||||
setupKeyboardShortcuts() {
|
||||
// Alt+S - Focus search
|
||||
this.addShortcut('Alt+s', () => {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
this.focusElement(searchInput);
|
||||
this.announceToScreenReader('Search focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Alt+N - Focus navigation
|
||||
this.addShortcut('Alt+n', () => {
|
||||
const navigation = document.getElementById('main-navigation');
|
||||
if (navigation) {
|
||||
this.focusElement(navigation.querySelector('[role="menuitem"]'));
|
||||
this.announceToScreenReader('Navigation focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Alt+M - Focus main content
|
||||
this.addShortcut('Alt+m', () => {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
this.focusElement(mainContent);
|
||||
this.announceToScreenReader('Main content focused');
|
||||
}
|
||||
});
|
||||
|
||||
// Alt+H - Go home
|
||||
this.addShortcut('Alt+h', () => {
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
// Alt+1-9 - Quick navigation
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
this.addShortcut(`Alt+${i}`, () => {
|
||||
this.quickNavigate(i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyboard shortcut
|
||||
*
|
||||
* @param {string} shortcut Shortcut combination
|
||||
* @param {Function} callback Callback function
|
||||
*/
|
||||
addShortcut(shortcut, callback) {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (this.matchesShortcut(e, shortcut)) {
|
||||
e.preventDefault();
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if event matches shortcut
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
* @param {string} shortcut Shortcut string
|
||||
* @return {boolean} True if matches
|
||||
*/
|
||||
matchesShortcut(e, shortcut) {
|
||||
const parts = shortcut.toLowerCase().split('+');
|
||||
const key = parts.pop();
|
||||
|
||||
if (e.key.toLowerCase() !== key) return false;
|
||||
|
||||
const altRequired = parts.includes('alt');
|
||||
const ctrlRequired = parts.includes('ctrl');
|
||||
const shiftRequired = parts.includes('shift');
|
||||
|
||||
return e.altKey === altRequired &&
|
||||
e.ctrlKey === ctrlRequired &&
|
||||
e.shiftKey === shiftRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update focusable elements list
|
||||
*/
|
||||
updateFocusableElements() {
|
||||
this.focusableElementsList = Array.from(document.querySelectorAll(this.focusableElements))
|
||||
.filter(element => {
|
||||
// Filter out hidden elements
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
element.getAttribute('aria-hidden') !== 'true' &&
|
||||
!element.disabled;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus element with accessibility
|
||||
*
|
||||
* @param {Element} element Element to focus
|
||||
*/
|
||||
focusElement(element) {
|
||||
if (!element) return;
|
||||
|
||||
element.focus();
|
||||
|
||||
// Scroll into view if needed
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus events
|
||||
*
|
||||
* @param {FocusEvent} e Focus event
|
||||
*/
|
||||
handleFocus(e) {
|
||||
this.currentFocusIndex = this.focusableElementsList.indexOf(e.target);
|
||||
|
||||
// Announce focus changes to screen readers
|
||||
const announcement = this.getFocusAnnouncement(e.target);
|
||||
if (announcement) {
|
||||
setTimeout(() => {
|
||||
this.announceToScreenReader(announcement);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blur events
|
||||
*
|
||||
* @param {FocusEvent} e Blur event
|
||||
*/
|
||||
handleBlur(e) {
|
||||
// Store last focused element for modal restoration
|
||||
if (!this.modalOpen) {
|
||||
this.lastFocusedElement = e.target;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get focus announcement for screen readers
|
||||
*
|
||||
* @param {Element} element Focused element
|
||||
* @return {string} Announcement text
|
||||
*/
|
||||
getFocusAnnouncement(element) {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const role = element.getAttribute('role');
|
||||
const label = element.getAttribute('aria-label') || element.textContent || '';
|
||||
|
||||
if (role === 'button') {
|
||||
return `Button, ${label}`;
|
||||
} else if (role === 'link') {
|
||||
return `Link, ${label}`;
|
||||
} else if (tagName === 'input') {
|
||||
const type = element.type || 'text';
|
||||
return `${type} input, ${label}`;
|
||||
} else if (role === 'menuitem') {
|
||||
return `Menu item, ${label}`;
|
||||
} else if (role === 'tab') {
|
||||
return `Tab, ${label}`;
|
||||
}
|
||||
|
||||
return label || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce to screen readers
|
||||
*
|
||||
* @param {string} message Message to announce
|
||||
*/
|
||||
announceToScreenReader(message) {
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('role', 'status');
|
||||
announcement.setAttribute('aria-live', 'polite');
|
||||
announcement.className = 'sr-only';
|
||||
announcement.textContent = message;
|
||||
|
||||
document.body.appendChild(announcement);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(announcement);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate menu with arrow keys
|
||||
*
|
||||
* @param {string} direction Arrow direction
|
||||
*/
|
||||
navigateMenu(direction) {
|
||||
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
const currentIndex = menuItems.indexOf(document.activeElement);
|
||||
|
||||
let nextIndex;
|
||||
if (direction === 'ArrowDown' || direction === 'ArrowRight') {
|
||||
nextIndex = currentIndex >= menuItems.length - 1 ? 0 : currentIndex + 1;
|
||||
} else {
|
||||
nextIndex = currentIndex <= 0 ? menuItems.length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
this.focusElement(menuItems[nextIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate tabs with arrow keys
|
||||
*
|
||||
* @param {string} direction Arrow direction
|
||||
*/
|
||||
navigateTabs(direction) {
|
||||
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
let nextIndex;
|
||||
if (direction === 'ArrowRight' || direction === 'ArrowDown') {
|
||||
nextIndex = currentIndex >= tabs.length - 1 ? 0 : currentIndex + 1;
|
||||
} else {
|
||||
nextIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
this.focusElement(tabs[nextIndex]);
|
||||
tabs[nextIndex].click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate grid with arrow keys
|
||||
*
|
||||
* @param {string} direction Arrow direction
|
||||
*/
|
||||
navigateGrid(direction) {
|
||||
// Implementation for grid navigation
|
||||
// This would need to be customized based on specific grid structure
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick navigation with Alt+number
|
||||
*
|
||||
* @param {number} number Number key
|
||||
*/
|
||||
quickNavigate(number) {
|
||||
const targets = [
|
||||
{ selector: '#main-navigation', name: 'navigation' },
|
||||
{ selector: '#search-input', name: 'search' },
|
||||
{ selector: '#main-content', name: 'main content' },
|
||||
{ selector: 'h1', name: 'heading' },
|
||||
{ selector: '.breadcrumb', name: 'breadcrumb' },
|
||||
{ selector: 'footer', name: 'footer' },
|
||||
{ selector: '.sidebar', name: 'sidebar' },
|
||||
{ selector: '.btn-primary', name: 'primary button' },
|
||||
{ selector: 'form', name: 'form' }
|
||||
];
|
||||
|
||||
if (number <= targets.length) {
|
||||
const target = targets[number - 1];
|
||||
const element = document.querySelector(target.selector);
|
||||
if (element) {
|
||||
this.focusElement(element);
|
||||
this.announceToScreenReader(`Quick navigation to ${target.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom shortcuts
|
||||
*
|
||||
* @param {KeyboardEvent} e Keyboard event
|
||||
*/
|
||||
handleCustomShortcuts(e) {
|
||||
// Additional custom shortcuts can be added here
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal with focus trap
|
||||
*
|
||||
* @param {string} modalId Modal ID
|
||||
*/
|
||||
openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) return;
|
||||
|
||||
this.modalOpen = true;
|
||||
this.lastFocusedElement = document.activeElement;
|
||||
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
modal.style.display = 'block';
|
||||
|
||||
// Focus first focusable element in modal
|
||||
const firstFocusable = modal.querySelector(this.focusableElements);
|
||||
if (firstFocusable) {
|
||||
this.focusElement(firstFocusable);
|
||||
}
|
||||
|
||||
this.announceToScreenReader('Modal opened');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and restore focus
|
||||
*/
|
||||
closeModal() {
|
||||
if (!this.modalOpen) return;
|
||||
|
||||
const modal = document.querySelector('[role="dialog"][aria-hidden="false"]');
|
||||
if (!modal) return;
|
||||
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.style.display = 'none';
|
||||
|
||||
this.modalOpen = false;
|
||||
|
||||
// Restore focus to last focused element
|
||||
if (this.lastFocusedElement) {
|
||||
this.focusElement(this.lastFocusedElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize keyboard navigation when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.keyboardNavigation = new KeyboardNavigation();
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* ScreenReaderOptimization - WCAG 2.1 AA Compliant Screen Reader Support
|
||||
*
|
||||
* Features:
|
||||
* - Screen reader detection and optimization
|
||||
* - Live region management
|
||||
* - ARIA announcements
|
||||
* - Content adaptation for screen readers
|
||||
* - Voice control support
|
||||
* - WCAG 2.1 AA compliance
|
||||
*/
|
||||
class ScreenReaderOptimization {
|
||||
constructor() {
|
||||
this.isScreenReaderActive = false;
|
||||
this.liveRegion = null;
|
||||
this.announcementQueue = [];
|
||||
this.isAnnouncing = false;
|
||||
this.voiceControlEnabled = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize screen reader optimization
|
||||
*/
|
||||
init() {
|
||||
this.detectScreenReader();
|
||||
this.createLiveRegion();
|
||||
this.setupVoiceControl();
|
||||
this.optimizeContent();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if screen reader is active
|
||||
*/
|
||||
detectScreenReader() {
|
||||
// Multiple detection methods
|
||||
const methods = [
|
||||
this.detectByNavigator,
|
||||
this.detectByAria,
|
||||
this.detectByTiming,
|
||||
this.detectByBehavior
|
||||
];
|
||||
|
||||
let positiveDetections = 0;
|
||||
|
||||
methods.forEach(method => {
|
||||
if (method.call(this)) {
|
||||
positiveDetections++;
|
||||
}
|
||||
});
|
||||
|
||||
// Consider screen reader active if majority of methods detect it
|
||||
this.isScreenReaderActive = positiveDetections >= 2;
|
||||
|
||||
if (this.isScreenReaderActive) {
|
||||
document.body.classList.add('screen-reader-active');
|
||||
this.announceToScreenReader('Screen reader detected, accessibility features enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by navigator properties
|
||||
*/
|
||||
detectByNavigator() {
|
||||
// Check for common screen reader indicators
|
||||
return window.speechSynthesis !== undefined ||
|
||||
window.navigator.userAgent.includes('JAWS') ||
|
||||
window.navigator.userAgent.includes('NVDA') ||
|
||||
window.navigator.userAgent.includes('VoiceOver') ||
|
||||
window.navigator.userAgent.includes('TalkBack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by ARIA support
|
||||
*/
|
||||
detectByAria() {
|
||||
// Check if ARIA attributes are supported and used
|
||||
const testElement = document.createElement('div');
|
||||
testElement.setAttribute('role', 'region');
|
||||
testElement.setAttribute('aria-live', 'polite');
|
||||
|
||||
return testElement.getAttribute('role') === 'region' &&
|
||||
testElement.getAttribute('aria-live') === 'polite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by timing analysis
|
||||
*/
|
||||
detectByTiming() {
|
||||
// Screen readers often have different timing patterns
|
||||
const startTime = performance.now();
|
||||
|
||||
// Create a test element that screen readers would process differently
|
||||
const testElement = document.createElement('div');
|
||||
testElement.setAttribute('aria-hidden', 'false');
|
||||
testElement.textContent = 'Screen reader test';
|
||||
document.body.appendChild(testElement);
|
||||
|
||||
const endTime = performance.now();
|
||||
document.body.removeChild(testElement);
|
||||
|
||||
// If processing takes unusually long, might indicate screen reader
|
||||
return (endTime - startTime) > 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect screen reader by user behavior
|
||||
*/
|
||||
detectByBehavior() {
|
||||
// Check for keyboard-only navigation patterns
|
||||
let keyboardOnly = true;
|
||||
|
||||
document.addEventListener('mousedown', () => {
|
||||
keyboardOnly = false;
|
||||
}, { once: true });
|
||||
|
||||
// If user navigates with keyboard extensively, likely screen reader user
|
||||
setTimeout(() => {
|
||||
if (keyboardOnly) {
|
||||
this.isScreenReaderActive = true;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return false; // Async detection
|
||||
}
|
||||
|
||||
/**
|
||||
* Create live region for announcements
|
||||
*/
|
||||
createLiveRegion() {
|
||||
this.liveRegion = document.createElement('div');
|
||||
this.liveRegion.setAttribute('aria-live', 'polite');
|
||||
this.liveRegion.setAttribute('aria-atomic', 'true');
|
||||
this.liveRegion.className = 'sr-only live-region';
|
||||
this.liveRegion.style.position = 'absolute';
|
||||
this.liveRegion.style.left = '-10000px';
|
||||
this.liveRegion.style.width = '1px';
|
||||
this.liveRegion.style.height = '1px';
|
||||
this.liveRegion.style.overflow = 'hidden';
|
||||
|
||||
document.body.appendChild(this.liveRegion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup voice control
|
||||
*/
|
||||
setupVoiceControl() {
|
||||
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
|
||||
this.voiceControlEnabled = true;
|
||||
this.initializeVoiceRecognition();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize voice recognition
|
||||
*/
|
||||
initializeVoiceRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
this.recognition = new SpeechRecognition();
|
||||
|
||||
this.recognition.continuous = false;
|
||||
this.recognition.interimResults = false;
|
||||
this.recognition.lang = document.documentElement.lang || 'nl-NL';
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
const command = event.results[0][0].transcript.toLowerCase();
|
||||
this.handleVoiceCommand(command);
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.log('Voice recognition error:', event.error);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice commands
|
||||
*
|
||||
* @param {string} command Voice command
|
||||
*/
|
||||
handleVoiceCommand(command) {
|
||||
const commands = {
|
||||
'zoeken': () => this.focusSearch(),
|
||||
'navigatie': () => this.focusNavigation(),
|
||||
'hoofdinhoud': () => this.focusMainContent(),
|
||||
'home': () => this.goHome(),
|
||||
'terug': () => this.goBack(),
|
||||
'volgende': () => this.goNext(),
|
||||
'vorige': () => this.goPrevious(),
|
||||
'stop': () => this.stopReading()
|
||||
};
|
||||
|
||||
for (const [keyword, action] of Object.entries(commands)) {
|
||||
if (command.includes(keyword)) {
|
||||
action();
|
||||
this.announceToScreenReader(`Voice command: ${keyword}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize content for screen readers
|
||||
*/
|
||||
optimizeContent() {
|
||||
this.addMissingLabels();
|
||||
this.enhanceHeadings();
|
||||
this.improveTableAccessibility();
|
||||
this.optimizeImages();
|
||||
this.enhanceLinks();
|
||||
this.addLandmarks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add missing labels to form elements
|
||||
*/
|
||||
addMissingLabels() {
|
||||
const inputs = document.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) {
|
||||
const id = input.id || 'input-' + Math.random().toString(36).substr(2, 9);
|
||||
input.id = id;
|
||||
|
||||
// Try to find associated label
|
||||
let label = document.querySelector(`label[for="${id}"]`);
|
||||
|
||||
if (!label) {
|
||||
// Create label from placeholder or name
|
||||
const labelText = input.placeholder || input.name || input.type || 'Input';
|
||||
label = document.createElement('label');
|
||||
label.textContent = labelText;
|
||||
label.setAttribute('for', id);
|
||||
label.className = 'sr-only';
|
||||
input.parentNode.insertBefore(label, input);
|
||||
}
|
||||
|
||||
input.setAttribute('aria-label', label.textContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance headings for better structure
|
||||
*/
|
||||
enhanceHeadings() {
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
// Add proper ARIA attributes
|
||||
heading.setAttribute('role', 'heading');
|
||||
heading.setAttribute('aria-level', heading.tagName.substring(1));
|
||||
|
||||
// Add unique ID for navigation
|
||||
if (!heading.id) {
|
||||
heading.id = 'heading-' + index;
|
||||
}
|
||||
|
||||
// Add heading anchor for navigation
|
||||
if (!heading.querySelector('.heading-anchor')) {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = '#' + heading.id;
|
||||
anchor.className = 'heading-anchor sr-only';
|
||||
anchor.textContent = 'Link to this heading';
|
||||
anchor.setAttribute('aria-label', 'Link to this heading');
|
||||
heading.appendChild(anchor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Improve table accessibility
|
||||
*/
|
||||
improveTableAccessibility() {
|
||||
const tables = document.querySelectorAll('table');
|
||||
|
||||
tables.forEach(table => {
|
||||
// Add table caption if missing
|
||||
if (!table.querySelector('caption')) {
|
||||
const caption = document.createElement('caption');
|
||||
caption.textContent = 'Tabel ' + (tables.indexOf(table) + 1);
|
||||
caption.className = 'sr-only';
|
||||
table.insertBefore(caption, table.firstChild);
|
||||
}
|
||||
|
||||
// Add scope to headers
|
||||
const headers = table.querySelectorAll('th');
|
||||
headers.forEach(header => {
|
||||
if (!header.getAttribute('scope')) {
|
||||
const scope = header.parentElement.tagName === 'THEAD' ? 'col' : 'row';
|
||||
header.setAttribute('scope', scope);
|
||||
}
|
||||
});
|
||||
|
||||
// Add table description
|
||||
if (!table.getAttribute('aria-describedby')) {
|
||||
const description = document.createElement('div');
|
||||
description.id = 'table-desc-' + Math.random().toString(36).substr(2, 9);
|
||||
description.className = 'sr-only';
|
||||
description.textContent = 'Data table with ' + headers.length + ' columns';
|
||||
table.parentNode.insertBefore(description, table);
|
||||
table.setAttribute('aria-describedby', description.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize images for screen readers
|
||||
*/
|
||||
optimizeImages() {
|
||||
const images = document.querySelectorAll('img');
|
||||
|
||||
images.forEach(img => {
|
||||
// Ensure alt text exists
|
||||
if (!img.alt && !img.getAttribute('aria-label')) {
|
||||
// Try to get alt text from nearby text
|
||||
const nearbyText = this.getNearbyText(img);
|
||||
img.alt = nearbyText || 'Afbeelding';
|
||||
img.setAttribute('role', 'img');
|
||||
}
|
||||
|
||||
// Add long description if needed
|
||||
if (img.title && !img.getAttribute('aria-describedby')) {
|
||||
const descId = 'img-desc-' + Math.random().toString(36).substr(2, 9);
|
||||
const description = document.createElement('div');
|
||||
description.id = descId;
|
||||
description.className = 'sr-only';
|
||||
description.textContent = img.title;
|
||||
img.parentNode.insertBefore(description, img.nextSibling);
|
||||
img.setAttribute('aria-describedby', descId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance links for screen readers
|
||||
*/
|
||||
enhanceLinks() {
|
||||
const links = document.querySelectorAll('a');
|
||||
|
||||
links.forEach(link => {
|
||||
// Ensure accessible name
|
||||
if (!link.textContent.trim() && !link.getAttribute('aria-label')) {
|
||||
const href = link.getAttribute('href') || '';
|
||||
link.setAttribute('aria-label', 'Link: ' + href);
|
||||
}
|
||||
|
||||
// Add external link indication
|
||||
if (link.hostname !== window.location.hostname) {
|
||||
if (!link.getAttribute('aria-label')?.includes('external')) {
|
||||
const currentLabel = link.getAttribute('aria-label') || link.textContent;
|
||||
link.setAttribute('aria-label', currentLabel + ' (externe link)');
|
||||
}
|
||||
}
|
||||
|
||||
// Add file type and size for file links
|
||||
const href = link.getAttribute('href');
|
||||
if (href && this.isFileLink(href)) {
|
||||
const fileInfo = this.getFileInfo(href);
|
||||
if (!link.getAttribute('aria-label')?.includes(fileInfo.type)) {
|
||||
const currentLabel = link.getAttribute('aria-label') || link.textContent;
|
||||
link.setAttribute('aria-label', currentLabel + ` (${fileInfo.type}, ${fileInfo.size})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add landmarks for better navigation
|
||||
*/
|
||||
addLandmarks() {
|
||||
// Add main landmark if missing
|
||||
if (!document.querySelector('[role="main"], main')) {
|
||||
const content = document.querySelector('article, .content, #content');
|
||||
if (content) {
|
||||
content.setAttribute('role', 'main');
|
||||
content.id = 'main-content';
|
||||
}
|
||||
}
|
||||
|
||||
// Add navigation landmark if missing
|
||||
if (!document.querySelector('[role="navigation"], nav')) {
|
||||
const nav = document.querySelector('.nav, .navigation, #navigation');
|
||||
if (nav) {
|
||||
nav.setAttribute('role', 'navigation');
|
||||
nav.setAttribute('aria-label', 'Hoofdmenu');
|
||||
}
|
||||
}
|
||||
|
||||
// Add search landmark if missing
|
||||
if (!document.querySelector('[role="search"]')) {
|
||||
const search = document.querySelector('.search, #search, [type="search"]');
|
||||
if (search) {
|
||||
search.setAttribute('role', 'search');
|
||||
search.setAttribute('aria-label', 'Zoeken');
|
||||
}
|
||||
}
|
||||
|
||||
// Add contentinfo landmark if missing
|
||||
if (!document.querySelector('[role="contentinfo"], footer')) {
|
||||
const footer = document.querySelector('footer');
|
||||
if (footer) {
|
||||
footer.setAttribute('role', 'contentinfo');
|
||||
footer.setAttribute('aria-label', 'Voettekst');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for dynamic content
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Monitor DOM changes for new content
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
this.optimizeNode(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Handle page changes
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(() => this.optimizeContent(), 100);
|
||||
});
|
||||
|
||||
// Handle AJAX content loading
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => this.optimizeContent(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a specific node
|
||||
*
|
||||
* @param {Node} node Node to optimize
|
||||
*/
|
||||
optimizeNode(node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
// Optimize based on tag type
|
||||
switch (node.tagName.toLowerCase()) {
|
||||
case 'img':
|
||||
this.optimizeImages();
|
||||
break;
|
||||
case 'a':
|
||||
this.enhanceLinks();
|
||||
break;
|
||||
case 'table':
|
||||
this.improveTableAccessibility();
|
||||
break;
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
this.enhanceHeadings();
|
||||
break;
|
||||
case 'input':
|
||||
case 'select':
|
||||
case 'textarea':
|
||||
this.addMissingLabels();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nearby text for an element
|
||||
*
|
||||
* @param {Element} element Element to check
|
||||
* @return {string} Nearby text
|
||||
*/
|
||||
getNearbyText(element) {
|
||||
// Check parent text content
|
||||
let parent = element.parentElement;
|
||||
if (parent) {
|
||||
const text = parent.textContent.replace(element.alt || '', '').trim();
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
// Check previous sibling
|
||||
let prev = element.previousElementSibling;
|
||||
if (prev && prev.textContent.trim()) {
|
||||
return prev.textContent.trim();
|
||||
}
|
||||
|
||||
// Check next sibling
|
||||
let next = element.nextElementSibling;
|
||||
if (next && next.textContent.trim()) {
|
||||
return next.textContent.trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if link is a file link
|
||||
*
|
||||
* @param {string} href Link href
|
||||
* @return {boolean} True if file link
|
||||
*/
|
||||
isFileLink(href) {
|
||||
const fileExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip', '.rar'];
|
||||
return fileExtensions.some(ext => href.toLowerCase().includes(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file information
|
||||
*
|
||||
* @param {string} href File link
|
||||
* @return {object} File information
|
||||
*/
|
||||
getFileInfo(href) {
|
||||
const extension = href.split('.').pop().toLowerCase();
|
||||
const types = {
|
||||
'pdf': { type: 'PDF document', size: '' },
|
||||
'doc': { type: 'Word document', size: '' },
|
||||
'docx': { type: 'Word document', size: '' },
|
||||
'xls': { type: 'Excel spreadsheet', size: '' },
|
||||
'xlsx': { type: 'Excel spreadsheet', size: '' },
|
||||
'ppt': { type: 'PowerPoint presentation', size: '' },
|
||||
'pptx': { type: 'PowerPoint presentation', size: '' },
|
||||
'zip': { type: 'ZIP archive', size: '' },
|
||||
'rar': { type: 'RAR archive', size: '' }
|
||||
};
|
||||
|
||||
return types[extension] || { type: 'File', size: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce message to screen readers
|
||||
*
|
||||
* @param {string} message Message to announce
|
||||
* @param {string} priority Priority level
|
||||
*/
|
||||
announceToScreenReader(message, priority = 'polite') {
|
||||
if (!this.isScreenReaderActive) return;
|
||||
|
||||
// Queue announcement if currently announcing
|
||||
if (this.isAnnouncing) {
|
||||
this.announcementQueue.push({ message, priority });
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAnnouncing = true;
|
||||
|
||||
// Create temporary live region if needed
|
||||
const tempRegion = document.createElement('div');
|
||||
tempRegion.setAttribute('aria-live', priority);
|
||||
tempRegion.setAttribute('aria-atomic', 'true');
|
||||
tempRegion.className = 'sr-only';
|
||||
tempRegion.textContent = message;
|
||||
|
||||
document.body.appendChild(tempRegion);
|
||||
|
||||
// Remove after announcement
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(tempRegion);
|
||||
this.isAnnouncing = false;
|
||||
|
||||
// Process next announcement in queue
|
||||
if (this.announcementQueue.length > 0) {
|
||||
const next = this.announcementQueue.shift();
|
||||
this.announceToScreenReader(next.message, next.priority);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice control methods
|
||||
*/
|
||||
startVoiceRecognition() {
|
||||
if (this.voiceControlEnabled && this.recognition) {
|
||||
this.recognition.start();
|
||||
this.announceToScreenReader('Voice control activated');
|
||||
}
|
||||
}
|
||||
|
||||
stopVoiceRecognition() {
|
||||
if (this.voiceControlEnabled && this.recognition) {
|
||||
this.recognition.stop();
|
||||
this.announceToScreenReader('Voice control deactivated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation methods for voice control
|
||||
*/
|
||||
focusSearch() {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
this.announceToScreenReader('Search focused');
|
||||
}
|
||||
}
|
||||
|
||||
focusNavigation() {
|
||||
const navigation = document.querySelector('[role="navigation"]');
|
||||
if (navigation) {
|
||||
navigation.focus();
|
||||
this.announceToScreenReader('Navigation focused');
|
||||
}
|
||||
}
|
||||
|
||||
focusMainContent() {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
mainContent.focus();
|
||||
this.announceToScreenReader('Main content focused');
|
||||
}
|
||||
}
|
||||
|
||||
goHome() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
goNext() {
|
||||
// Implementation depends on context
|
||||
const nextButton = document.querySelector('.next, [aria-label*="next"]');
|
||||
if (nextButton) {
|
||||
nextButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
goPrevious() {
|
||||
// Implementation depends on context
|
||||
const prevButton = document.querySelector('.previous, [aria-label*="previous"]');
|
||||
if (prevButton) {
|
||||
prevButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
stopReading() {
|
||||
// Stop any ongoing screen reader activity
|
||||
window.speechSynthesis.cancel();
|
||||
this.announceToScreenReader('Reading stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize screen reader optimization when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.screenReaderOptimization = new ScreenReaderOptimization();
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "CodePress Admin Default",
|
||||
"type": "admin",
|
||||
"default_layout": "admin",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}CodePress Admin{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/style.css">
|
||||
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
|
||||
<style>
|
||||
body { background-color: #f5f6fa; min-height: 100vh; }
|
||||
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; display: flex; flex-direction: column; }
|
||||
.admin-sidebar .nav.flex-column:first-of-type { flex: 1; overflow-y: auto; }
|
||||
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
|
||||
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
|
||||
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
|
||||
.admin-sidebar .nav-link i { width: 24px; text-align: center; margin-right: 0.5rem; }
|
||||
.admin-sidebar .nav-section { color: rgba(255,255,255,0.4); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; padding: 1rem 1.25rem 0.3rem 1.25rem; }
|
||||
.admin-main { margin-left: 240px; padding: 2rem; }
|
||||
.admin-brand { color: #fff; padding: 1.25rem; font-size: 1.1rem; border-bottom: 1px solid rgba(255,255,255,0.15); }
|
||||
.admin-brand i { margin-right: 0.5rem; }
|
||||
.stat-card { border: none; border-radius: 0.5rem; }
|
||||
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
|
||||
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
|
||||
.admin-main { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
{% if needs_editor %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="admin-sidebar d-flex flex-column">
|
||||
<div class="admin-brand">
|
||||
<i class="bi bi-gear-fill"></i> CodePress Admin
|
||||
</div>
|
||||
<ul class="nav flex-column mt-2">
|
||||
<li class="nav-section">Algemeen</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'dashboard' or route == '' ? 'active' : '' }}" href="/admin/dashboard">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if has_permission('content') %}
|
||||
<li class="nav-section">Content</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route starts with 'content' ? 'active' : '' }}" href="/admin/content">
|
||||
<i class="bi bi-file-earmark-text"></i> Content
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('config') or has_permission('theme') or has_permission('security') %}
|
||||
<li class="nav-section">Instellingen</li>
|
||||
{% if has_permission('config') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'config' ? 'active' : '' }}" href="/admin/config">
|
||||
<i class="bi bi-sliders"></i> Configuratie
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('theme') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'theme' ? 'active' : '' }}" href="/admin/theme">
|
||||
<i class="bi bi-palette"></i> Thema
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('security') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'security' ? 'active' : '' }}" href="/admin/security">
|
||||
<i class="bi bi-shield-check"></i> Beveiliging
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('statistics') or has_permission('logs') %}
|
||||
<li class="nav-section">Gegevens</li>
|
||||
{% if has_permission('statistics') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'statistics' ? 'active' : '' }}" href="/admin/statistics">
|
||||
<i class="bi bi-bar-chart"></i> Statistieken
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('logs') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'logs' ? 'active' : '' }}" href="/admin/logs">
|
||||
<i class="bi bi-journal-text"></i> Logs
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('plugins') or has_permission('users') or has_permission('update') %}
|
||||
<li class="nav-section">Systeem</li>
|
||||
{% if has_permission('plugins') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'plugins' ? 'active' : '' }}" href="/admin/plugins">
|
||||
<i class="bi bi-plug"></i> Plugins
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('users') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'users' ? 'active' : '' }}" href="/admin/users">
|
||||
<i class="bi bi-people"></i> Gebruikers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_permission('update') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'update' ? 'active' : '' }}" href="/admin/update">
|
||||
<i class="bi bi-cloud-arrow-down"></i> Update
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_permission('guide') %}
|
||||
<li class="nav-section">Help</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ route == 'guide' ? 'active' : '' }}" href="/admin/guide">
|
||||
<i class="bi bi-book"></i> Handleiding
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="nav flex-column mt-auto mb-5">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/" target="_blank">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Website bekijken
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-warning" href="/admin/logout">
|
||||
<i class="bi bi-box-arrow-left"></i> Uitloggen
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="admin-user">
|
||||
<i class="bi bi-person-circle"></i> {{ user.username|default('') }}
|
||||
<br><small>{{ role_label(user_role)|default('') }}</small>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="admin-main">
|
||||
{% if message is defined and message %}
|
||||
<div class="alert alert-{{ message_type|default('info') }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||
{% if needs_editor %}
|
||||
<script src="/admin/assets/codemirror/codemirror.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/markdown.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/xml.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/css.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/javascript.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/htmlmixed.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/php.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/mode/clike.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/addon/edit/closebrackets.min.js"></script>
|
||||
<script src="/admin/assets/codemirror/addon/selection/active-line.min.js"></script>
|
||||
<script src="/admin/assets/js/editor-toolbar.js"></script>
|
||||
{% endif %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CodePress Admin - Login</title>
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/style.css">
|
||||
<style>
|
||||
body { background-color: #f5f6fa; }
|
||||
.login-card { max-width: 400px; margin: 10vh auto; }
|
||||
.login-header { background-color: #0a369d; color: #fff; padding: 2rem; text-align: center; border-radius: 0.5rem 0.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h4><i class="bi bi-shield-lock"></i> CodePress Admin</h4>
|
||||
</div>
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 0 0 0.5rem 0.5rem;">
|
||||
<div class="card-body p-4">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ error }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Wachtwoord</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Inloggen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-muted mt-3 small">
|
||||
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left"></i> Terug naar website</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/admin/assets/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Configuratie - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-sliders"></i> Configuratie</h2>
|
||||
|
||||
<form method="POST" action="/admin/config">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Algemene instellingen</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="site_title" class="form-label">Site titel</label>
|
||||
<input type="text" class="form-control" id="site_title" name="site_title" value="{{ config.site_title|default('CodePress') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="language_default" class="form-label">Standaard taal</label>
|
||||
<select class="form-select" id="language_default" name="language_default">
|
||||
<option value="nl" {{ config.language.default|default('nl') == 'nl' ? 'selected' : '' }}>Nederlands</option>
|
||||
<option value="en" {{ config.language.default == 'en' ? 'selected' : '' }}>Engels</option>
|
||||
<option value="de" {{ config.language.default == 'de' ? 'selected' : '' }}>Duits</option>
|
||||
<option value="fr" {{ config.language.default == 'fr' ? 'selected' : '' }}>Frans</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Auteur</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="author_name" class="form-label">Naam</label>
|
||||
<input type="text" class="form-control" id="author_name" name="author_name" value="{{ config.author.name|default('') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="author_email" class="form-label">E-mail</label>
|
||||
<input type="email" class="form-control" id="author_email" name="author_email" value="{{ config.author.email|default('') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Analytics & Logging</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="analytics_enabled" name="analytics_enabled" {{ config.analytics.enabled ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="analytics_enabled">Analytics ingeschakeld</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="logging_enabled" name="logging_enabled" {{ config.logging.enabled ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="logging_enabled">Logging ingeschakeld</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/dashboard" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Map hernoemen - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> Map hernoemen</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="newname" class="form-label">Nieuwe naam voor {{ currentName }}</label>
|
||||
<input type="text" class="form-control" id="newname" name="newname" value="{{ currentName }}" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Hernoemen
|
||||
</button>
|
||||
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ fileName }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content-edit?file={{ file|url_encode }}" id="editor-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="filename" name="filename" value="{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" required>
|
||||
<span class="input-group-text">.{{ fileExt }}</span>
|
||||
</div>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, punten, underscores en streepjes.</small>
|
||||
</div>
|
||||
{% if isEditable %}
|
||||
<div class="col-md-4">
|
||||
<label for="layout" class="form-label">Sjabloon / Layout <small class="text-muted">({{ activeThemeName|default('default') }} theme)</small></label>
|
||||
<select class="form-select" id="layout" name="layout">
|
||||
{% for key, layoutFile in themeLayouts %}
|
||||
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if availablePlugins is not empty %}
|
||||
<div class="col-md-4">
|
||||
<label class="form-label d-block">Zichtbare plugins</label>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for plugin in availablePlugins %}
|
||||
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
|
||||
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if isEditable %}
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary" title="Opslaan (Ctrl+S)">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
{% if isEditable %}
|
||||
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="Open in nieuw tabblad">
|
||||
<i class="bi bi-eye"></i> Preview
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/admin/content?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">Terug</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var backBtn = document.getElementById('back-btn');
|
||||
var filenameInput = document.getElementById('filename');
|
||||
var layoutSelect = document.getElementById('layout');
|
||||
if (!backBtn) return;
|
||||
|
||||
var changed = false;
|
||||
|
||||
function markChanged() {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
backBtn.innerHTML = '<i class="bi bi-x-circle"></i> Annuleren';
|
||||
backBtn.classList.remove('btn-outline-secondary');
|
||||
backBtn.classList.add('btn-outline-danger');
|
||||
}
|
||||
|
||||
if (filenameInput) {
|
||||
filenameInput.addEventListener('input', markChanged);
|
||||
}
|
||||
|
||||
// Update the layout value in the editor's frontmatter when the select changes
|
||||
if (layoutSelect) {
|
||||
layoutSelect.addEventListener('change', function () {
|
||||
var newLayout = this.value;
|
||||
var editor = document.getElementById('editor-textarea');
|
||||
if (!editor) return;
|
||||
|
||||
var cm = window.codeMirrorEditor;
|
||||
var content = cm ? cm.getValue() : editor.value;
|
||||
|
||||
// Check if frontmatter exists
|
||||
var fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
// Update existing layout line in frontmatter
|
||||
var frontmatter = fmMatch[1];
|
||||
if (/^layout:\s*.+$/m.test(frontmatter)) {
|
||||
frontmatter = frontmatter.replace(/^layout:\s*.+$/m, 'layout: ' + newLayout);
|
||||
} else {
|
||||
frontmatter = 'layout: ' + newLayout + '\n' + frontmatter;
|
||||
}
|
||||
content = content.replace(/^---\n([\s\S]*?)\n---/, '---\n' + frontmatter + '\n---');
|
||||
} else {
|
||||
// No frontmatter yet — add one
|
||||
content = '---\nlayout: ' + newLayout + '\n---\n\n' + content;
|
||||
}
|
||||
|
||||
if (cm) {
|
||||
cm.setValue(content);
|
||||
} else {
|
||||
editor.value = content;
|
||||
}
|
||||
|
||||
markChanged();
|
||||
});
|
||||
}
|
||||
|
||||
window.__onContentChange = markChanged;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ isDir ? 'Map' : 'Bestand' }} verplaatsen - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-arrows-move"></i> {{ isDir ? 'Map' : 'Bestand' }} verplaatsen</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
Verplaats <strong>{{ itemName }}</strong> naar:
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="destination" class="form-label">Doelmap</label>
|
||||
<select class="form-select" id="destination" name="destination" required>
|
||||
{% for directory in directories %}
|
||||
<option value="{{ directory }}">{{ directory }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Verplaatsen
|
||||
</button>
|
||||
<a href="/admin/content?dir={{ itemDir|url_encode }}" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Nieuwe pagina - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-plus-lg"></i> Nieuwe pagina</h2>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content-new?dir={{ dir|url_encode }}" id="editor-form" data-new-page>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="filename" class="form-label">Bestandsnaam</label>
|
||||
<input type="text" class="form-control" id="filename" name="filename" required autofocus>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, punten, underscores en streepjes.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="extension" class="form-label">Bestandstype</label>
|
||||
<select class="form-select" id="extension" name="extension">
|
||||
{% for ext, label in availableExtensions %}
|
||||
<option value="{{ ext }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="layout" class="form-label">Sjabloon / Layout <small class="text-muted">({{ activeThemeName|default('default') }} theme)</small></label>
|
||||
<select class="form-select" id="layout" name="layout">
|
||||
{% for key, layoutFile in themeLayouts %}
|
||||
<option value="{{ key }}" {{ key == themeDefaultLayout ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/content?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,209 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Content - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-file-earmark-text"></i> Content</h2>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-success btn-sm me-1" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> Upload
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" data-bs-toggle="modal" data-bs-target="#createDirModal">
|
||||
<i class="bi bi-folder-plus"></i> Nieuwe map
|
||||
</button>
|
||||
<a href="/admin/content-new?dir={{ subdir|url_encode }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Nieuw bestand
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse mb-4" id="uploadForm">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/content?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,application/pdf,application/zip,video/mp4,video/webm,audio/mpeg,audio/wav">
|
||||
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG, PDF, ZIP, MP4, WebM, MP3, WAV</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden naar deze map
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if subdir %}
|
||||
{% set parentDir = subdir|split('/')|slice(0, -1)|join('/') %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/admin/content"><i class="bi bi-house"></i></a></li>
|
||||
{% set crumbPath = '' %}
|
||||
{% for crumb in subdir|split('/') %}
|
||||
{% set crumbPath = crumbPath ? crumbPath ~ '/' ~ crumb : crumb %}
|
||||
<li class="breadcrumb-item {{ crumbPath == subdir ? 'active' : '' }}">
|
||||
{% if crumbPath == subdir %}
|
||||
{{ crumb }}
|
||||
{% else %}
|
||||
<a href="/admin/content?dir={{ crumbPath|url_encode }}">{{ crumb }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="search" id="contentFilter" class="form-control border-start-0" placeholder="Filter op bestands- of mapnaam…" autocomplete="off" aria-label="Filter content">
|
||||
<span class="input-group-text bg-white text-muted" id="contentFilterCount"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Type</th>
|
||||
<th>Grootte</th>
|
||||
<th>Gewijzigd</th>
|
||||
<th style="width: 200px;">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if items is empty %}
|
||||
<tr><td colspan="5" class="text-muted text-center py-4">Geen bestanden gevonden.</td></tr>
|
||||
{% else %}
|
||||
{% for item in items %}
|
||||
<tr data-name="{{ item.name|lower }}">
|
||||
<td>
|
||||
{% if item.is_dir %}
|
||||
<a href="/admin/content?dir={{ item.path|url_encode }}">
|
||||
<i class="bi bi-folder-fill text-warning"></i> {{ item.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/content-edit?file={{ item.path|url_encode }}">
|
||||
{% set icon = item.extension == 'md' ? 'bi-file-text text-primary' : (item.extension == 'php' ? 'bi-file-code text-success' : (item.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted')) %}
|
||||
<i class="bi {{ icon }}"></i> {{ item.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.is_dir %}
|
||||
<span class="badge bg-warning text-dark">Map</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ item.extension|upper }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">{{ item.size }}</td>
|
||||
<td class="text-muted">{{ item.modified }}</td>
|
||||
<td>
|
||||
{% if item.is_dir %}
|
||||
<a href="/admin/content-dir-rename?dir={{ item.path|url_encode }}" class="btn btn-sm btn-outline-secondary" title="Hernoemen">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="/admin/content-move?item={{ item.path|url_encode }}" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/content-dir-delete?dir={{ item.path|url_encode }}" class="d-inline" onsubmit="return confirm('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="/admin/content-edit?file={{ item.path|url_encode }}" class="btn btn-sm btn-outline-primary" title="Bewerken">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="/admin/content-move?item={{ item.path|url_encode }}" class="btn btn-sm btn-outline-info" title="Verplaatsen">
|
||||
<i class="bi bi-arrows-move"></i>
|
||||
</a>
|
||||
<form method="POST" action="/admin/content-delete?file={{ item.path|url_encode }}" class="d-inline" onsubmit="return confirm('Weet je zeker dat je dit bestand wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<tr id="contentFilterEmpty" class="d-none">
|
||||
<td colspan="5" class="text-muted text-center py-4">Geen resultaten voor deze filter.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById('contentFilter');
|
||||
var counter = document.getElementById('contentFilterCount');
|
||||
var emptyRow = document.getElementById('contentFilterEmpty');
|
||||
if (!input) return;
|
||||
|
||||
var rows = Array.prototype.slice.call(document.querySelectorAll('tbody tr[data-name]'));
|
||||
|
||||
function apply() {
|
||||
var q = input.value.trim().toLowerCase();
|
||||
var shown = 0;
|
||||
|
||||
rows.forEach(function (row) {
|
||||
var match = q === '' || row.getAttribute('data-name').indexOf(q) !== -1;
|
||||
row.classList.toggle('d-none', !match);
|
||||
if (match) shown++;
|
||||
});
|
||||
|
||||
if (emptyRow) {
|
||||
emptyRow.classList.toggle('d-none', shown > 0 || rows.length === 0);
|
||||
}
|
||||
if (counter) {
|
||||
counter.textContent = q === '' ? rows.length + ' items' : shown + ' van ' + rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', apply);
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
apply();
|
||||
}
|
||||
});
|
||||
|
||||
apply();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Create Directory Modal -->
|
||||
<div class="modal fade" id="createDirModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/content-dir-create?dir={{ subdir|url_encode }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> Nieuwe map aanmaken</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="dirname" class="form-label">Mapnaam</label>
|
||||
<input type="text" class="form-control" id="dirname" name="dirname" required autofocus>
|
||||
<div class="form-text">Alleen letters, cijfers, punten, underscores en streepjes.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Aanmaken</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,219 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Dashboard - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-speedometer2"></i> Dashboard</h2>
|
||||
|
||||
<div class="alert alert-light border mb-4">
|
||||
<strong>Welkom, {{ user.username }}</strong> — Ingelogd als <span class="badge bg-{{ user_role == 'admin' ? 'danger' : (user_role == 'content-manager' ? 'primary' : (user_role == 'bi-manager' ? 'success' : 'warning')) }}">{{ role_label(user_role) }}</span>
|
||||
</div>
|
||||
|
||||
{# Analytics stats - only for roles with statistics permission #}
|
||||
{% if has_permission('statistics') %}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Weergaven (30 dagen)</h6>
|
||||
<h3 class="mb-0">{{ (analytics_summary.totals.views ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Unieke bezoekers (30 dagen)</h6>
|
||||
<h3 class="mb-0">{{ (analytics_summary.totals.uniques ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-people stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Grootste land</h6>
|
||||
<h3 class="mb-0">
|
||||
{{ get_country_flag(analytics_summary.countries|keys|first) }}
|
||||
<span class="fs-5">{{ get_country_name(analytics_summary.countries|keys|first) }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<a href="/admin/statistics" class="btn btn-sm btn-outline-secondary">Statistieken →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content stats - only for roles with content permission #}
|
||||
{% if has_permission('content') %}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Pagina's</h6>
|
||||
<h3 class="mb-0">{{ stats.pages }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-file-earmark-text stat-icon text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Mappen</h6>
|
||||
<h3 class="mb-0">{{ stats.directories }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-folder stat-icon text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Content grootte</h6>
|
||||
<h3 class="mb-0">{{ stats.content_size }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-hdd stat-icon text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# System stats - only for admin/site-admin #}
|
||||
{% if has_permission('plugins') or has_permission('config') %}
|
||||
<div class="row g-4 mb-4">
|
||||
{% if has_permission('plugins') %}
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Plugins</h6>
|
||||
<h3 class="mb-0">{{ stats.plugins }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-plug stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4">
|
||||
{# Site information - only for admin #}
|
||||
{% if has_permission('config') %}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-info-circle"></i> Site informatie</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-muted">Site titel</td><td>{{ site_config.site_title|default('CodePress') }}</td></tr>
|
||||
<tr><td class="text-muted">Standaard taal</td><td>{{ site_config.language.default|default('nl') }}</td></tr>
|
||||
<tr><td class="text-muted">Auteur</td><td>{{ site_config.author.name|default('-') }}</td></tr>
|
||||
<tr><td class="text-muted">CodePress versie</td><td>{{ stats.cms_version }}</td></tr>
|
||||
<tr><td class="text-muted">PHP versie</td><td>{{ stats.php_version }}</td></tr>
|
||||
<tr><td class="text-muted">Besturingssysteem</td><td>{{ stats.os }}</td></tr>
|
||||
<tr><td class="text-muted">Config geladen</td><td>{% if stats.config_exists %}<span class="badge bg-success">Ja</span>{% else %}<span class="badge bg-danger">Nee</span>{% endif %}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Recent activity - only for roles with logs permission #}
|
||||
{% if has_permission('logs') %}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-activity"></i> Recente activiteit</span>
|
||||
<a href="/admin/logs?tab=admin" class="btn btn-sm btn-outline-secondary">Bekijk alle →</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
{% if recent_logs is empty %}
|
||||
<p class="text-muted mb-0">Geen activiteit geregistreerd.</p>
|
||||
{% else %}
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for log in recent_logs %}
|
||||
<li class="mb-2 pb-2 border-bottom small">
|
||||
<span class="text-muted">{{ log.time }}</span>
|
||||
<span class="badge bg-{{ log.level == 'warning' ? 'warning text-dark' : (log.level == 'error' ? 'danger' : 'info') }} me-1">{{ log.level }}</span>
|
||||
<code class="text-muted">{{ log.ip }}</code>
|
||||
{{ log.message }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-globe"></i> Recente requests</span>
|
||||
<a href="/admin/logs?tab=requests" class="btn btn-sm btn-outline-secondary">Bekijk alle →</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
{% if recent_requests is empty %}
|
||||
<p class="text-muted mb-0">Geen requests geregistreerd.</p>
|
||||
{% else %}
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for log in recent_requests %}
|
||||
<li class="mb-2 pb-2 border-bottom small d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="text-muted me-1">{{ log.time }}</span>
|
||||
<code class="text-muted me-1">{{ log.ip }}</code>
|
||||
<span class="fw-bold">{{ log.page }}</span>
|
||||
</div>
|
||||
{% if log.visitor_info is not empty %}
|
||||
<span class="badge bg-{{ log.visitor_info.badge }}" title="{{ log.ua }}">
|
||||
<i class="bi {{ log.visitor_info.icon }}"></i> {{ log.visitor_info.label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quick actions - role-specific #}
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-lightning"></i> Snelle acties</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
{% if has_permission('content') %}
|
||||
<a href="/admin/content-new" class="btn btn-outline-primary"><i class="bi bi-plus-lg"></i> Nieuwe pagina</a>
|
||||
<a href="/admin/content" class="btn btn-outline-info"><i class="bi bi-folder2-open"></i> Content beheren</a>
|
||||
{% endif %}
|
||||
{% if has_permission('config') %}
|
||||
<a href="/admin/config" class="btn btn-outline-secondary"><i class="bi bi-sliders"></i> Configuratie bewerken</a>
|
||||
{% endif %}
|
||||
{% if has_permission('statistics') %}
|
||||
<a href="/admin/statistics" class="btn btn-outline-secondary"><i class="bi bi-bar-chart"></i> Statistieken bekijken</a>
|
||||
{% endif %}
|
||||
{% if has_permission('theme') %}
|
||||
<a href="/admin/theme" class="btn btn-outline-secondary"><i class="bi bi-palette"></i> Thema beheren</a>
|
||||
{% endif %}
|
||||
{% if has_permission('plugins') %}
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary"><i class="bi bi-plug"></i> Plugins beheren</a>
|
||||
{% endif %}
|
||||
<a href="/" target="_blank" class="btn btn-outline-success"><i class="bi bi-box-arrow-up-right"></i> Website bekijken</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ error_title|default('Fout') }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="text-center py-5">
|
||||
<h1 class="display-1 text-muted">{{ error_code|default('404') }}</h1>
|
||||
<h2 class="mb-3">{{ error_title|default('Pagina niet gevonden') }}</h2>
|
||||
<p class="text-muted mb-4">{{ error_message|default('De gevraagde pagina kon niet worden gevonden.') }}</p>
|
||||
<a href="/admin/dashboard" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Naar dashboard
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}{{ guide_page ? 'Handleiding - ' : '' }}Handleiding{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{% if guide_nav %}
|
||||
<aside class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-list-ul"></i> Navigatie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ guide_nav|raw }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="col-md-9">
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
{% endif %}
|
||||
<nav aria-label="breadcrumb" class="mb-4">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item {{ not guide_page ? 'active' : '' }}">
|
||||
{% if guide_page %}
|
||||
<a href="/admin/guide{% if guide_lang %}?lang={{ guide_lang }}{% endif %}">Handleiding</a>
|
||||
{% else %}
|
||||
Handleidingen
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if guide_breadcrumbs %}
|
||||
{% for crumb in guide_breadcrumbs %}
|
||||
{% if loop.last %}
|
||||
<li class="breadcrumb-item active">{{ crumb.title }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item">
|
||||
<a href="/admin/guide?lang={{ guide_lang }}&page={{ crumb.url }}">{{ crumb.title }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="guide-content card shadow-sm">
|
||||
<div class="card-body">
|
||||
{{ content|raw }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Logs - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" action="/admin/logs" class="row g-3 align-items-end">
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/admin/logs?tab=admin" class="btn btn-sm {{ tab == 'admin' ? 'btn-primary' : 'btn-outline-primary' }}">
|
||||
<i class="bi bi-shield-check"></i> Admin
|
||||
</a>
|
||||
<a href="/admin/logs?tab=requests" class="btn btn-sm {{ tab == 'requests' ? 'btn-primary' : 'btn-outline-primary' }}">
|
||||
<i class="bi bi-globe"></i> Requests
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/admin/logs?tab={{ tab }}&download=1" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0" style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="sticky-top bg-white">
|
||||
<tr>
|
||||
<th>Tijd</th>
|
||||
<th>Level</th>
|
||||
<th>IP</th>
|
||||
<th>Bericht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ log.time }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ log.level == 'warning' ? 'warning text-dark' : (log.level == 'error' ? 'danger' : 'info') }}">
|
||||
{{ log.level }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">{{ log.ip }}</td>
|
||||
<td><code class="text-muted">{{ log.message }}</code></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center py-4">Geen logs gevonden.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Media - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-images"></i> Media</h2>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#uploadForm">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse mb-4" id="uploadForm">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/media?dir={{ subdir|url_encode }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Bestanden selecteren</label>
|
||||
<input type="file" class="form-control" id="file" name="file[]" multiple accept="image/*,video/*,audio/*">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-cloud-upload"></i> Uploaden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
{% for item in items %}
|
||||
{% if not item.is_dir and item.extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] %}
|
||||
<div class="col-md-3">
|
||||
<div class="card shadow-sm">
|
||||
<img src="/content/{{ item.path|url_encode }}" class="card-img-top" alt="{{ item.name }}">
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate">{{ item.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<p class="text-muted text-center">Geen media bestanden gevonden.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Plugin Configuratie: {{ pluginName }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Plugin Configuratie: {{ pluginName }}</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<textarea name="config" id="config-textarea" class="form-control font-monospace" rows="20">{{ pluginConfig }}</textarea>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Plugin bewerken: {{ pluginName }} - CodePress Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/css/editor.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-pencil"></i> Plugin bewerken: {{ pluginName }}</h2>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Terug
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/admin/plugins-edit?plugin={{ pluginName|url_encode }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="editor-toolbar" id="editor-toolbar"></div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea name="content" id="editor-textarea" data-ext="php">{{ pluginContent }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Nieuwe plugin - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-plug"></i> Nieuwe plugin aanmaken</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Plugin naam</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, underscores en streepjes.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/plugins" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Plugins - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-plug"></i> Plugins</h2>
|
||||
<a href="/admin/plugins-new" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> Nieuwe plugin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{% for plugin in plugins %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">{{ plugin.name|default(plugin.name) }}</span>
|
||||
<span class="badge bg-{{ plugin.enabled ? 'success' : 'secondary' }}">{{ plugin.enabled ? 'Actief' : 'Inactief' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-2">{{ plugin.description|default('Geen beschrijving') }}</p>
|
||||
<p class="small text-muted mb-3">
|
||||
{% if plugin.version %}<span class="badge bg-info">v{{ plugin.version }}</span>{% endif %}
|
||||
{% if plugin.author %}<span class="badge bg-secondary">{{ plugin.author }}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% if plugin.protected %}
|
||||
<span class="btn btn-sm btn-outline-secondary disabled" title="Essentiële plugin - kan niet worden bewerkt, gedeactiveerd of verwijderd">
|
||||
<i class="bi bi-shield-check"></i> Essentieel
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="/admin/plugins-edit?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Bewerken
|
||||
</a>
|
||||
{% if plugin.hasConfig %}
|
||||
<a href="/admin/plugins-config?plugin={{ plugin.name|url_encode }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-gear"></i> Config
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/plugins-toggle" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-{{ plugin.enabled ? 'outline-warning' : 'outline-success' }}">
|
||||
<i class="bi bi-{{ plugin.enabled ? 'pause' : 'play' }}"></i> {{ plugin.enabled ? 'Deactiveren' : 'Activeren' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/plugins-delete" class="d-inline" onsubmit="return confirm('Weet je zeker dat je deze plugin wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="plugin" value="{{ plugin.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> Geen plugins gevonden. Maak een nieuwe plugin aan om te beginnen.
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Beveiliging - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-shield-check"></i> Beveiliging & Bot Bescherming</h2>
|
||||
|
||||
<form method="POST" action="/admin/security">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Bot Bescherming</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="botguard_enabled" name="botguard_enabled" {{ config.security.botguard_enabled ?? true ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="botguard_enabled">BotGuard ingeschakeld</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="block_bad_bots" name="block_bad_bots" {{ config.security.block_bad_bots ?? true ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="block_bad_bots">Blokkeer slechte bots</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Sessie Instellingen</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="session_timeout" class="form-label">Sessie timeout (seconden)</label>
|
||||
<input type="number" class="form-control" id="session_timeout" name="session_timeout" value="{{ config.security.session_timeout ?? 3600 }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="max_login_attempts" class="form-label">Maximale login pogingen</label>
|
||||
<input type="number" class="form-control" id="max_login_attempts" name="max_login_attempts" value="{{ config.security.max_login_attempts ?? 5 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Opslaan
|
||||
</button>
|
||||
<a href="/admin/dashboard" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Statistieken - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Totaal aantal views</h6>
|
||||
<h3 class="mb-0">{{ (stats.totals.views ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-eye stat-icon text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
|
||||
<h3 class="mb-0">{{ (stats.totals.uniques ?? 0)|number_format(0, ',', '.') }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-people stat-icon text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card shadow-sm">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">Pagina views</h6>
|
||||
<h3 class="mb-0">{{ (stats.pages|length ?? 0) }}</h3>
|
||||
</div>
|
||||
<i class="bi bi-file-earmark-text stat-icon text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-globe"></i> Landen</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
{% for country, count in stats.countries|slice(0, 10) %}
|
||||
<tr>
|
||||
<td>{{ get_country_flag(country) }} {{ get_country_name(country) }}</td>
|
||||
<td class="text-end">{{ count|number_format(0, ',', '.') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2" class="text-muted">Geen data</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-file-text"></i> Top pagina's</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
{% for page, count in stats.pages|slice(0, 10) %}
|
||||
<tr>
|
||||
<td><code class="text-muted">{{ page }}</code></td>
|
||||
<td class="text-end">{{ count|number_format(0, ',', '.') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2" class="text-muted">Geen data</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Nieuw thema - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-palette"></i> Nieuw thema aanmaken</h2>
|
||||
|
||||
<form method="post" class="card shadow-sm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Thema naam</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||
<small class="form-text text-muted">Alleen letters, cijfers, underscores en streepjes.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Aanmaken
|
||||
</button>
|
||||
<a href="/admin/theme" class="btn btn-outline-secondary">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Thema's - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-palette"></i> Thema's</h2>
|
||||
<div>
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" name="activate_default" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-check-lg"></i> Activeer Default
|
||||
</button>
|
||||
</form>
|
||||
<a href="/admin/theme-new" class="btn btn-outline-primary btn-sm ms-2">
|
||||
<i class="bi bi-plus-lg"></i> Nieuw thema
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{% for theme in themes %}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm h-100 {{ theme.active ? 'border-primary border-2' : '' }}">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">{{ theme.title|default(theme.name) }}</span>
|
||||
{% if theme.active %}
|
||||
<span class="badge bg-success">Actief</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Naam: {{ theme.name }}</p>
|
||||
{% if theme.default_layout %}
|
||||
<p class="text-muted small">Default layout: {{ theme.default_layout }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-white">
|
||||
{% if not theme.active %}
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="activate" value="{{ theme.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-check-lg"></i> Activeren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="/admin/theme" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="compile_scss" value="1">
|
||||
<input type="hidden" name="theme" value="{{ theme.name }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-palette"></i> SCSS compileren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> Geen thema's gevonden.
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Update - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-cloud-arrow-down"></i> Systeem Update</h2>
|
||||
|
||||
{% if isGitWritable == false %}
|
||||
<div class="alert alert-warning mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
De .git map is niet beschrijfbaar. Automatische updates zijn niet mogelijk.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">Huidige versie</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">CodePress versie: <strong>{{ version }}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">Update opties</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" {{ isGitWritable ? '' : 'disabled' }}>
|
||||
<i class="bi bi-cloud-arrow-down"></i> Controleer op updates
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" {{ isGitWritable ? '' : 'disabled' }}>
|
||||
<i class="bi bi-arrow-repeat"></i> Update uitvoeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "layouts/admin.twig" %}
|
||||
|
||||
{% block title %}Gebruikers - CodePress Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-people"></i> Gebruikers</h2>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-list"></i> Gebruikers
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gebruikersnaam</th>
|
||||
<th>Rol</th>
|
||||
<th>Aangemaakt</th>
|
||||
<th>Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for username, data in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
{{ username }}
|
||||
{% if username == user.username %}
|
||||
<span class="badge bg-info">Jij</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ data.role == 'admin' ? 'danger' : (data.role == 'content-manager' ? 'primary' : (data.role == 'bi-manager' ? 'success' : 'warning')) }}">
|
||||
{{ data.role_label|default(data.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">{{ data.created|default('Onbekend') }}</td>
|
||||
<td>
|
||||
{% if username != user.username %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#roleModal-{{ username }}">
|
||||
<i class="bi bi-person-gear"></i> Rol
|
||||
</button>
|
||||
<form method="POST" action="/admin/users" class="d-inline" onsubmit="return confirm('Weet je zeker dat je gebruiker {{ username }} wilt verwijderen?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="delete_username" value="{{ username }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Role change modal -->
|
||||
<div class="modal fade" id="roleModal-{{ username }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="change_role">
|
||||
<input type="hidden" name="role_username" value="{{ username }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-person-gear"></i> Rol wijzigen: {{ username }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Huidige rol</label>
|
||||
<p><span class="badge bg-secondary">{{ data.role_label|default(data.role) }}</span></p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_role-{{ username }}" class="form-label">Nieuwe rol</label>
|
||||
<select class="form-select" id="new_role-{{ username }}" name="new_role">
|
||||
{% for roleKey, roleLabel in roles %}
|
||||
<option value="{{ roleKey }}" {{ data.role == roleKey ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuleren</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Wijzigen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small">Eigen account</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center py-4">Geen gebruikers gevonden.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plus-circle"></i> Nieuwe gebruiker
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<div class="mb-3">
|
||||
<label for="new_username" class="form-label">Gebruikersnaam</label>
|
||||
<input type="text" class="form-control" id="new_username" name="new_username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Wachtwoord</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
||||
<small class="form-text text-muted">Minimaal 8 tekens.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_role" class="form-label">Rol</label>
|
||||
<select class="form-select" id="new_role" name="new_role">
|
||||
{% for roleKey, roleLabel in roles %}
|
||||
<option value="{{ roleKey }}" {{ roleKey == 'content-manager' ? 'selected' : '' }}>{{ roleLabel }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-lg"></i> Gebruiker toevoegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user