Molodetz blogrol volgens DPP-template

This commit is contained in:
2026-10-05 09:36:20 +02:00
commit f290a4c766
277 changed files with 15758 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
/* retoor <retoor@molodetz.nl> */
.admin-layout { display: grid; grid-template-columns: 12rem minmax(0, 1fr); gap: var(--space-xl); }
.admin-nav { display: grid; gap: var(--space-xs); align-content: start; font-family: var(--font-sans); font-size: var(--text-sm); }
.admin-nav a { text-decoration: none; color: var(--muted); padding: var(--space-xs) 0; border-bottom: var(--border); }
.admin-nav a.active, .admin-nav a:hover { color: var(--ink); }
.admin-main { min-width: 0; }
.admin-main h1 { font-weight: 500; font-size: var(--text-xl); margin: 0 0 var(--space-lg); }
.stat-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: var(--space-md); margin-bottom: var(--space-xl); }
.stat-card { border: var(--border); padding: var(--space-md); display: grid; gap: var(--space-xs); }
.stat-card .value { font-size: var(--text-xl); }
.stat-card .name { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; }
.admin-table { width: 100%; border-collapse: collapse; font-family: var(--font-sans); font-size: var(--text-sm); }
.admin-table th { text-align: left; font-weight: 500; color: var(--muted); font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; border-bottom: var(--border-strong); padding: var(--space-sm); }
.admin-table td { border-bottom: var(--border); padding: var(--space-sm); vertical-align: top; overflow-wrap: anywhere; }
.table-wrap { overflow-x: auto; }
.status { font-family: var(--font-sans); font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; }
.status.running, .status.done, .status.published, .status.accepted { color: var(--success); }
.status.stalled, .status.failed, .status.declined { color: var(--danger); }
.status.stopped, .status.draft, .status.pending, .status.open { color: var(--warning); }
.log-tail { background: var(--bg-sunken); border: var(--border); padding: var(--space-md); font-family: var(--font-mono); font-size: var(--text-xs); max-height: 24rem; overflow: auto; white-space: pre-wrap; }
.tabs { display: flex; gap: var(--space-lg); border-bottom: var(--border); margin-bottom: var(--space-lg); font-family: var(--font-sans); font-size: var(--text-sm); }
.tabs button { background: none; border: 0; color: var(--muted); font: inherit; padding: var(--space-sm) 0; cursor: pointer; border-bottom: 1px solid transparent; }
.tabs button.active { color: var(--ink); border-bottom-color: var(--accent); }
.editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-lg); }
.editor textarea { min-height: 28rem; }
.editor .preview { border: var(--border); padding: var(--space-md) var(--space-lg); min-height: 28rem; overflow: auto; }
@media (max-width: 1024px) {
.editor { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 768px) {
.admin-layout { grid-template-columns: minmax(0, 1fr); }
.admin-nav { grid-auto-flow: column; overflow-x: auto; }
}
+66
View File
@@ -0,0 +1,66 @@
/* retoor <retoor@molodetz.nl> */
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--bg); color: var(--ink); -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font-serif);
font-size: var(--text-base);
line-height: var(--leading-body);
background: var(--bg);
color: var(--ink);
min-height: 100vh;
display: flex;
flex-direction: column;
}
a { color: var(--ink); text-decoration-color: rgba(var(--accent-rgb), 0.6); text-underline-offset: 0.2em; }
a:hover { color: var(--accent); }
img, video { max-width: 100%; height: auto; display: block; }
::selection { background: rgba(var(--accent-rgb), 0.35); color: var(--ink); }
.hidden, [hidden] { display: none !important; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.label {
font-family: var(--font-sans);
font-size: var(--text-xs);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
color: var(--accent);
}
.muted { color: var(--muted); }
time { font-family: var(--font-sans); font-size: var(--text-xs); letter-spacing: 0.06em; color: var(--muted); }
.response-time {
position: fixed;
right: var(--space-sm);
bottom: var(--space-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--faint);
z-index: var(--z-floating-button);
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+94
View File
@@ -0,0 +1,94 @@
/* retoor <retoor@molodetz.nl> */
dp-toast {
position: fixed;
left: 50%;
bottom: var(--space-xl);
transform: translateX(-50%);
z-index: var(--z-toast);
display: grid;
gap: var(--space-sm);
will-change: transform;
}
.toast {
background: var(--bg-raised);
border: var(--border-strong);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-sm);
padding: var(--space-sm) var(--space-lg);
box-shadow: var(--shadow-soft);
}
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(var(--shade-rgb), 0.7);
z-index: var(--z-modal);
align-items: center;
justify-content: center;
padding: var(--space-lg);
will-change: transform;
}
.modal-overlay.visible { display: flex; }
.modal {
background: var(--bg);
border: var(--border-strong);
max-width: 32rem;
width: 100%;
padding: var(--space-lg);
box-shadow: var(--shadow-soft);
}
.modal.wide { max-width: 56rem; }
.modal header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--space-md); }
.modal h2 { margin: 0; font-weight: 500; font-size: var(--text-lg); }
.modal-close { background: none; border: 0; color: var(--muted); font-size: var(--text-lg); cursor: pointer; }
dp-dialog.modal-overlay { z-index: var(--z-dialog); }
dp-code { display: block; position: relative; }
.lightbox-close { position: absolute; top: var(--space-md); right: var(--space-md); color: var(--ink); }
dp-lightbox {
display: none;
position: fixed;
inset: 0;
z-index: var(--z-lightbox);
background: rgba(var(--shade-rgb), 0.92);
align-items: center;
justify-content: center;
padding: var(--space-xl);
cursor: zoom-out;
will-change: transform;
}
dp-lightbox.visible { display: flex; }
dp-lightbox img { max-height: 90vh; max-width: 100%; border: var(--border-strong); }
dp-lightbox .caption { position: absolute; bottom: var(--space-lg); left: 0; right: 0; text-align: center; color: var(--ink-soft); font-size: var(--text-base); }
dp-context-menu {
position: fixed;
z-index: var(--z-menu);
background: var(--bg-raised);
border: var(--border-strong);
box-shadow: var(--shadow-soft);
font-family: var(--font-sans);
font-size: var(--text-sm);
min-width: 10rem;
will-change: transform;
}
dp-context-menu button { display: block; width: 100%; text-align: left; background: none; border: 0; color: var(--ink); padding: var(--space-sm) var(--space-md); font: inherit; cursor: pointer; }
dp-context-menu button:hover { background: rgba(var(--accent-rgb), 0.12); }
dp-upload { display: block; border: 1px dashed var(--rule-strong); padding: var(--space-lg); text-align: center; color: var(--muted); font-family: var(--font-sans); font-size: var(--text-sm); }
dp-upload.dragging { border-color: var(--accent); color: var(--ink); }
dp-chat { display: grid; gap: var(--space-sm); border: var(--border); padding: var(--space-md); }
dp-chat .chat-log { display: grid; gap: var(--space-sm); max-height: 20rem; overflow-y: auto; }
dp-chat .chat-line { border-left: 2px solid var(--rule-strong); padding-left: var(--space-sm); }
+128
View File
@@ -0,0 +1,128 @@
/* retoor <retoor@molodetz.nl> */
.hero { padding: var(--space-xl) 0 var(--space-lg); border-bottom: var(--border); margin-bottom: var(--space-xl); }
.hero h1 {
font-size: var(--text-hero);
font-weight: 500;
letter-spacing: var(--tracking-hero);
line-height: var(--leading-tight);
margin: var(--space-sm) 0 var(--space-md);
}
.hero .lead { font-size: var(--text-lg); color: var(--ink-soft); max-width: 30em; margin: 0; }
.hero .lead em { color: var(--ink); font-style: italic; }
.section-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-md);
margin: var(--space-xl) 0 var(--space-sm);
}
.section-head a { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); text-decoration: none; letter-spacing: 0.08em; }
.post-card { border-top: var(--border); padding: var(--space-lg) 0; display: grid; gap: var(--space-xs); min-width: 0; }
.post-card:last-child { border-bottom: var(--border); }
.post-card h3, .post-card h2 { font-size: var(--text-xl); font-weight: 500; line-height: 1.2; margin: 0; letter-spacing: -0.01em; }
.post-card h3 a, .post-card h2 a { text-decoration: none; }
.post-card p { color: var(--ink-soft); margin: 0; }
.post-meta { display: flex; gap: var(--space-md); align-items: baseline; flex-wrap: wrap; }
.tag-placeholder {
font-family: var(--font-sans);
font-size: var(--text-xs);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
border: var(--border-strong);
padding: 0 var(--space-sm);
}
.tag-draft { color: var(--warning); border-color: rgba(var(--accent-rgb), 0.5); }
.article-header { margin-bottom: var(--space-xl); }
.article-header h1 { font-size: var(--text-2xl); font-weight: 500; letter-spacing: -0.02em; line-height: 1.15; margin: var(--space-sm) 0; }
.byline { display: flex; align-items: center; gap: var(--space-sm); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--muted); }
.byline a { color: var(--ink-soft); text-decoration: none; }
.prose { font-size: var(--text-lg); line-height: 1.65; overflow-wrap: anywhere; min-width: 0; }
.prose p { margin: 0 0 var(--space-md); }
.prose blockquote { margin: var(--space-lg) 0; padding: var(--space-sm) var(--space-lg); border-left: 2px solid var(--accent); color: var(--ink-soft); font-style: italic; }
.prose blockquote p:last-child { margin-bottom: 0; }
.prose h2, .prose h3 { font-weight: 500; margin: var(--space-xl) 0 var(--space-sm); }
.prose img { margin: var(--space-lg) 0; border: var(--border); cursor: zoom-in; }
.prose table { border-collapse: collapse; width: 100%; font-size: var(--text-base); }
.prose th, .prose td { border-bottom: var(--border); padding: var(--space-sm); text-align: left; }
.prose code { font-family: var(--font-mono); font-size: 0.88em; background: var(--code-bg); padding: 0 var(--space-xs); }
.prose pre { background: var(--code-bg); border: var(--border); padding: var(--space-md); overflow-x: auto; position: relative; font-size: var(--text-sm); }
.prose pre code { background: none; padding: 0; }
.prose hr { border: 0; border-top: var(--border); margin: var(--space-xl) 0; }
.prose a.mention { color: var(--accent); text-decoration: none; }
.copy-button {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
background: var(--bg-raised);
color: var(--muted);
border: var(--border);
font-family: var(--font-sans);
font-size: var(--text-xs);
padding: 0 var(--space-sm);
cursor: pointer;
}
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--code-keyword); }
.hljs-string, .hljs-attr, .hljs-template-variable { color: var(--code-string); }
.hljs-comment, .hljs-quote { color: var(--code-comment); font-style: italic; }
.hljs-number, .hljs-literal { color: var(--code-number); }
.hljs-title, .hljs-section, .hljs-name { color: var(--code-title); }
.featured { margin: var(--space-xl) 0; border-top: var(--border); padding-top: var(--space-lg); }
.featured figure { margin: 0; }
.featured figcaption { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); margin-top: var(--space-sm); letter-spacing: 0.06em; }
.closing { margin-top: var(--space-2xl); border-top: var(--border); padding-top: var(--space-lg); display: grid; gap: var(--space-md); }
.closing p { font-size: var(--text-lg); color: var(--ink-soft); margin: 0; max-width: 30em; }
.person-row { display: flex; align-items: center; gap: var(--space-md); border-top: var(--border); padding: var(--space-md) 0; min-width: 0; }
.person-row:last-child { border-bottom: var(--border); }
.person-row .who { display: grid; min-width: 0; flex: 1; }
.person-row .who b { font-weight: 500; font-size: var(--text-lg); }
.person-row .who span { color: var(--muted); font-size: var(--text-sm); }
.person-row .count { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); }
.avatar { border: var(--border-strong); background: var(--bg-raised); flex-shrink: 0; }
.presence-host { position: relative; display: inline-block; }
.presence-dot {
position: absolute;
right: calc(var(--space-xs) * -1);
bottom: calc(var(--space-xs) * -1);
width: 0.6rem;
height: 0.6rem;
border-radius: var(--radius-pill);
background: var(--faint);
border: 2px solid var(--bg);
}
.presence-dot.online { background: var(--success); }
.empty { color: var(--muted); font-style: italic; padding: var(--space-lg) 0; border-top: var(--border); }
.load-more { display: flex; justify-content: center; margin-top: var(--space-lg); }
.notice { border: var(--border-strong); padding: var(--space-md) var(--space-lg); margin: var(--space-lg) 0; background: var(--bg-raised); }
.notice.success { border-color: rgba(var(--accent-rgb), 0.6); }
.notice.error { border-color: var(--danger); }
.notice ul { margin: 0; padding-left: var(--space-lg); }
.error-page { padding: var(--space-2xl) 0; }
.error-page .code { font-size: var(--text-hero); letter-spacing: var(--tracking-hero); line-height: 1; color: var(--faint); }
@media (max-width: 480px) {
.article-header h1 { font-size: var(--text-xl); }
.prose { font-size: var(--text-base); }
}
+27
View File
@@ -0,0 +1,27 @@
/* retoor <retoor@molodetz.nl> */
.docs-layout { display: grid; grid-template-columns: 14rem minmax(0, 1fr); gap: var(--space-xl); }
.docs-sidebar { font-family: var(--font-sans); font-size: var(--text-sm); display: grid; gap: var(--space-lg); align-content: start; }
.docs-sidebar h4 { margin: 0 0 var(--space-xs); font-size: var(--text-xs); letter-spacing: var(--tracking-label); text-transform: uppercase; color: var(--accent); font-weight: 500; }
.docs-sidebar ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); }
.docs-sidebar a { color: var(--muted); text-decoration: none; }
.docs-sidebar a.active, .docs-sidebar a:hover { color: var(--ink); }
.docs-content { min-width: 0; }
.docs-content .permalink { text-decoration: none; color: inherit; }
.docs-content .permalink:hover::after { content: " #"; color: var(--faint); }
.endpoint { border-top: var(--border); padding: var(--space-lg) 0; display: grid; gap: var(--space-sm); min-width: 0; }
.endpoint .signature { font-family: var(--font-mono); font-size: var(--text-sm); display: flex; gap: var(--space-sm); flex-wrap: wrap; }
.endpoint .method { color: var(--accent); }
.endpoint .auth { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; }
.endpoint pre { background: var(--code-bg); border: var(--border); padding: var(--space-sm) var(--space-md); overflow-x: auto; font-size: var(--text-xs); margin: 0; }
.endpoint .tester { display: grid; gap: var(--space-sm); }
.endpoint .allowed { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--faint); }
.search-hit { border-top: var(--border); padding: var(--space-md) 0; }
.search-hit mark { background: rgba(var(--accent-rgb), 0.3); color: var(--ink); }
@media (max-width: 768px) {
.docs-layout { grid-template-columns: minmax(0, 1fr); }
}
+64
View File
@@ -0,0 +1,64 @@
/* retoor <retoor@molodetz.nl> */
.form { display: grid; gap: var(--space-lg); max-width: 34rem; }
.form.wide { max-width: none; }
.field { display: grid; gap: var(--space-xs); min-width: 0; }
.field label { font-family: var(--font-sans); font-size: var(--text-xs); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
.field .help { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--faint); }
input[type="text"], input[type="email"], input[type="password"], input[type="url"], input[type="number"], input[type="search"], select, textarea {
width: 100%;
background: var(--bg-sunken);
color: var(--ink);
border: var(--border-strong);
border-radius: var(--radius);
padding: var(--space-sm) var(--space-md);
font: inherit;
font-size: var(--text-base);
}
textarea { min-height: 10rem; line-height: var(--leading-body); resize: vertical; font-family: var(--font-mono); font-size: var(--text-sm); }
input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
.check { display: flex; gap: var(--space-sm); align-items: center; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--ink-soft); }
.honeypot { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.button {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
background: var(--accent);
color: var(--accent-ink);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: var(--space-sm) var(--space-lg);
font-family: var(--font-sans);
font-size: var(--text-sm);
font-weight: 700;
letter-spacing: 0.06em;
text-decoration: none;
cursor: pointer;
}
.button:hover { color: var(--accent-ink); filter: brightness(1.08); }
.button.ghost { background: transparent; color: var(--ink); border-color: var(--rule-strong); font-weight: 500; }
.button.ghost:hover { border-color: var(--ink-soft); }
.button.danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.button.small { padding: var(--space-xs) var(--space-sm); font-size: var(--text-xs); }
.button.is-loading { opacity: 0.6; pointer-events: none; }
.button .spinner {
width: 0.8em;
height: 0.8em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: var(--radius-pill);
animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.actions { display: flex; gap: var(--space-sm); flex-wrap: wrap; align-items: center; }
.actions form { margin: 0; }
+31
View File
@@ -0,0 +1,31 @@
/* retoor <retoor@molodetz.nl> */
.gallery-intro { color: var(--ink-soft); max-width: 34em; margin: 0 0 var(--space-xl); }
.gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-xl) var(--space-lg);
}
.gallery figure { margin: 0; min-width: 0; }
.gallery a.frame { display: block; border: var(--border); background: var(--bg-sunken); cursor: zoom-in; }
.gallery a.frame:hover { border-color: rgba(var(--accent-rgb), 0.7); }
.gallery img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
.gallery figcaption {
margin-top: var(--space-sm);
font-size: var(--text-base);
color: var(--ink-soft);
display: flex;
justify-content: space-between;
gap: var(--space-md);
}
.gallery figcaption .index { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--faint); letter-spacing: 0.08em; }
.gallery.flyers { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 768px) {
.gallery { grid-template-columns: minmax(0, 1fr); }
}
+118
View File
@@ -0,0 +1,118 @@
/* retoor <retoor@molodetz.nl> */
.site-header {
border-bottom: var(--border);
position: sticky;
top: 0;
background: var(--bg);
z-index: var(--z-nav);
will-change: transform;
}
.site-nav {
max-width: var(--wide);
margin: 0 auto;
display: flex;
align-items: baseline;
gap: var(--space-lg);
padding: var(--space-md) var(--space-lg);
font-family: var(--font-sans);
font-size: var(--text-sm);
letter-spacing: 0.08em;
}
.site-nav a { text-decoration: none; color: var(--ink-soft); padding-bottom: var(--space-xs); border-bottom: 1px solid transparent; }
.site-nav a:hover { color: var(--ink); }
.site-nav a.active { color: var(--ink); border-bottom-color: var(--accent); }
.brand { margin-right: auto; font-weight: 700; letter-spacing: var(--tracking-label); color: var(--accent); }
.site-nav .brand { color: var(--accent); border-bottom: 0; }
.nav-links { display: flex; gap: var(--space-lg); align-items: baseline; min-width: 0; }
.nav-toggle {
display: none;
background: transparent;
color: var(--ink);
border: var(--border-strong);
font: inherit;
padding: var(--space-xs) var(--space-sm);
cursor: pointer;
}
.nav-account { display: flex; gap: var(--space-md); align-items: baseline; }
.nav-account form { margin: 0; }
.nav-account button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; letter-spacing: inherit; }
.nav-account button:hover { color: var(--ink); }
.badge-count {
display: inline-block;
min-width: 1.4em;
padding: 0 var(--space-xs);
margin-left: var(--space-xs);
background: var(--accent);
color: var(--accent-ink);
font-size: var(--text-xs);
text-align: center;
}
main.page {
flex: 1;
width: 100%;
max-width: var(--measure);
margin: 0 auto;
padding: var(--space-xl) var(--space-lg) var(--space-2xl);
min-width: 0;
}
main.page.wide { max-width: var(--wide); }
.site-footer {
border-top: var(--border);
padding: var(--space-lg);
font-family: var(--font-sans);
font-size: var(--text-xs);
color: var(--faint);
}
.site-footer .inner {
max-width: var(--wide);
margin: 0 auto;
display: flex;
gap: var(--space-lg);
flex-wrap: wrap;
align-items: baseline;
}
.site-footer a { color: var(--muted); text-decoration: none; }
.site-footer .tagline { margin-right: auto; }
.breadcrumbs { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--muted); margin-bottom: var(--space-lg); display: flex; gap: var(--space-sm); flex-wrap: wrap; }
.breadcrumbs a { color: var(--muted); text-decoration: none; }
@media (max-width: 768px) {
.nav-toggle { display: inline-block; }
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
background: var(--bg);
border-bottom: var(--border);
z-index: var(--z-nav-panel);
}
.nav-links.open { display: flex; }
.site-nav { position: relative; }
}
@media (max-width: 480px) {
main.page { padding: var(--space-lg) var(--space-md) var(--space-xl); }
}
@media (max-width: 360px) {
main.page { padding: var(--space-md) var(--space-sm) var(--space-lg); }
.site-nav { padding-left: var(--space-sm); padding-right: var(--space-sm); }
}
+74
View File
@@ -0,0 +1,74 @@
/* retoor <retoor@molodetz.nl> */
:root {
--bg: #0d0c0a;
--bg-raised: #15130f;
--bg-sunken: #090807;
--ink: #ece6da;
--ink-soft: #cfc8ba;
--muted: #8c857a;
--faint: #5b564e;
--rule: #2a2722;
--rule-strong: #3d3932;
--accent: #d4a020;
--accent-ink: #120f08;
--accent-rgb: 212, 160, 32;
--ink-rgb: 236, 230, 218;
--shade-rgb: 0, 0, 0;
--success: #7fa36b;
--warning: #d4a020;
--danger: #c8644f;
--info: #7f9eb0;
--code-bg: #12110e;
--code-keyword: #d9a441;
--code-string: #a9b97c;
--code-comment: #6c675e;
--code-number: #c98f6a;
--code-title: #e8d9b5;
--font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1.0625rem;
--text-lg: 1.25rem;
--text-xl: 1.75rem;
--text-2xl: 2.5rem;
--text-hero: clamp(3.25rem, 9vw, 6.5rem);
--leading-tight: 1.1;
--leading-body: 1.6;
--tracking-label: 0.18em;
--tracking-hero: -0.04em;
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2.5rem;
--space-2xl: 4.5rem;
--radius: 0;
--radius-pill: 999px;
--border: 1px solid var(--rule);
--border-strong: 1px solid var(--rule-strong);
--shadow-soft: 0 12px 40px rgba(var(--shade-rgb), 0.45);
--measure: 42rem;
--wide: 68rem;
--nav-height: 3.5rem;
--duration: 160ms;
--z-floating-button: 900;
--z-nav-overlay: 998;
--z-nav-panel: 999;
--z-nav: 1000;
--z-nav-dropdown: 1001;
--z-modal: 2000;
--z-popover: 2100;
--z-lightbox: 10000;
--z-floating-windows: 2147480000;
--z-toast: 2147483400;
--z-menu: 2147483500;
--z-dialog: 2147483647;
}
+55
View File
@@ -0,0 +1,55 @@
// retoor <retoor@molodetz.nl>
import "./components/index.js";
import { ContentEnhancer } from "./utils/ContentEnhancer.js";
import { FormManager } from "./utils/FormManager.js";
import { Http } from "./utils/Http.js";
import { LocalTime } from "./utils/LocalTime.js";
import { MobileNav } from "./utils/MobileNav.js";
import { ModalManager } from "./utils/ModalManager.js";
import { PresenceManager } from "./utils/PresenceManager.js";
import { ScrollMemory } from "./utils/ScrollMemory.js";
import { Tabs } from "./utils/Tabs.js";
import { TermsGate } from "./utils/TermsGate.js";
import { Toast } from "./utils/Toast.js";
export class Application {
constructor() {
this.dialog = document.querySelector("dp-dialog") || document.body.appendChild(document.createElement("dp-dialog"));
this.toastHost = document.querySelector("dp-toast");
this.http = Http;
this.termsGate = new TermsGate(this.dialog);
Http.termsGate = this.termsGate;
this.forms = new FormManager(this.dialog);
this.modals = new ModalManager();
this.time = new LocalTime();
this.nav = new MobileNav();
this.tabs = new Tabs();
this.scroll = new ScrollMemory();
this.presence = new PresenceManager();
this.enhancer = new ContentEnhancer();
this.wireCopy();
}
toast(message, kind = "info") {
if (this.toastHost?.show) {
this.toastHost.show(message, kind);
}
}
wireCopy() {
document.querySelectorAll("[data-copy]").forEach((node) => {
node.title = "Klik om te kopi\u00ebren";
node.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(node.textContent.trim());
this.toast("Gekopieerd");
} catch {
Toast.flash(node, "Kopi\u00ebren mislukt", 1500);
}
});
});
}
}
window.app = new Application();
document.documentElement.dataset.app = "ready";
+35
View File
@@ -0,0 +1,35 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class Avatar extends Component {
static tag = "dp-avatar";
static observedAttributes = ["seed", "uid", "size", "online"];
setup() {
this.render();
}
attributeChangedCallback() {
if (this.dataset.ready) {
this.render();
}
}
render() {
const uid = this.getAttribute("uid") || "";
const size = Number(this.getAttribute("size") || 32);
this.replaceChildren();
const img = document.createElement("img");
img.className = "avatar";
img.alt = "";
img.width = size;
img.height = size;
const seed = this.getAttribute("seed") || uid;
img.src = `/avatar/svg/${encodeURIComponent(seed)}?size=${size}`;
const dot = document.createElement("span");
dot.className = "presence-dot";
dot.dataset.presenceUid = uid;
dot.classList.toggle("online", this.hasAttribute("online"));
this.append(img, dot);
}
}
+43
View File
@@ -0,0 +1,43 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class Chat extends Component {
static tag = "dp-chat";
setup() {
this.log = document.createElement("div");
this.log.className = "chat-log";
this.log.setAttribute("aria-live", "polite");
const form = document.createElement("form");
form.className = "chat-form";
this.input = document.createElement("textarea");
this.input.rows = 2;
this.input.setAttribute("aria-label", "Bericht");
const send = document.createElement("button");
send.type = "submit";
send.className = "button small";
send.textContent = "Verstuur";
form.append(this.input, send);
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = this.input.value.trim();
if (text) {
this.emit("dp-chat-send", { text });
this.input.value = "";
}
});
this.append(this.log, form);
}
addMessage(author, text) {
const item = document.createElement("div");
item.className = "chat-line";
const who = document.createElement("strong");
who.textContent = author;
const body = document.createElement("span");
body.textContent = ` ${text}`;
item.append(who, body);
this.log.append(item);
this.log.scrollTop = this.log.scrollHeight;
}
}
+37
View File
@@ -0,0 +1,37 @@
// retoor <retoor@molodetz.nl>
import hljs from "../../vendor/highlight.es.min.js";
import { Component } from "./Component.js";
import { Toast } from "../utils/Toast.js";
export class Code extends Component {
static tag = "dp-code";
setup() {
requestAnimationFrame(() => this.enhance());
}
enhance() {
const code = this.querySelector("pre > code");
if (!code) {
return;
}
try {
hljs.highlightElement(code);
} catch (error) {
console.debug("highlight failed", error);
}
const button = document.createElement("button");
button.type = "button";
button.className = "copy-button";
button.textContent = "Kopieer";
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(code.textContent);
Toast.flash(button, "Gekopieerd", 1500, "Kopieer");
} catch {
Toast.flash(button, "Mislukt", 1500, "Kopieer");
}
});
this.prepend(button);
}
}
@@ -0,0 +1,23 @@
// retoor <retoor@molodetz.nl>
export class Component extends HTMLElement {
static tag = "";
static define() {
if (this.tag && !customElements.get(this.tag)) {
customElements.define(this.tag, this);
}
}
emit(name, detail = {}) {
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
}
connectedCallback() {
if (!this.dataset.ready) {
this.dataset.ready = "1";
this.setup();
}
}
setup() {}
}
+23
View File
@@ -0,0 +1,23 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { ContentRenderer } from "../utils/ContentRenderer.js";
export class Content extends Component {
static tag = "dp-content";
setup() {
const source = this.querySelector('script[type="text/markdown"]');
if (source) {
this.markdown = source.textContent;
}
}
set markdown(text) {
this.innerHTML = ContentRenderer.render(text);
this.querySelectorAll("pre > code").forEach((code) => {
const host = document.createElement("dp-code");
code.parentElement.replaceWith(host);
host.appendChild(code.parentElement);
});
}
}
@@ -0,0 +1,41 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class ContextMenu extends Component {
static tag = "dp-context-menu";
setup() {
this.hidden = true;
document.addEventListener("click", (event) => {
if (!this.contains(event.target)) {
this.close();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
this.close();
}
});
}
open(x, y, items) {
this.replaceChildren();
items.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = item.label;
button.addEventListener("click", () => {
this.close();
item.action();
});
this.append(button);
});
this.style.left = `${Math.min(x, window.innerWidth - 220)}px`;
this.style.top = `${y}px`;
this.hidden = false;
}
close() {
this.hidden = true;
}
}
+50
View File
@@ -0,0 +1,50 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class Dialog extends Component {
static tag = "dp-dialog";
setup() {
this.className = "modal-overlay";
this.setAttribute("role", "alertdialog");
this.setAttribute("aria-modal", "true");
}
confirm(message, { okLabel = "Ja", cancelLabel = "Annuleren" } = {}) {
return new Promise((resolve) => {
this.replaceChildren();
const box = document.createElement("div");
box.className = "modal";
const text = document.createElement("p");
text.textContent = message;
const actions = document.createElement("div");
actions.className = "actions";
const ok = document.createElement("button");
ok.type = "button";
ok.className = "button";
ok.textContent = okLabel;
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "button ghost";
cancel.textContent = cancelLabel;
actions.append(cancel, ok);
box.append(text, actions);
this.append(box);
this.classList.add("visible");
const finish = (value) => {
this.classList.remove("visible");
document.removeEventListener("keydown", onKey);
resolve(value);
};
const onKey = (event) => {
if (event.key === "Escape") {
finish(false);
}
};
document.addEventListener("keydown", onKey);
ok.addEventListener("click", () => finish(true));
cancel.addEventListener("click", () => finish(false));
ok.focus();
});
}
}
+72
View File
@@ -0,0 +1,72 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class Lightbox extends Component {
static tag = "dp-lightbox";
setup() {
this.setAttribute("role", "dialog");
this.setAttribute("aria-modal", "true");
this.setAttribute("aria-label", "Afbeelding");
this.hidden = true;
this.image = document.createElement("img");
this.image.alt = "";
this.caption = document.createElement("p");
this.caption.className = "caption";
this.closeButton = document.createElement("button");
this.closeButton.type = "button";
this.closeButton.className = "modal-close lightbox-close";
this.closeButton.setAttribute("aria-label", "Sluiten");
this.closeButton.textContent = "\u00d7";
this.append(this.closeButton, this.image, this.caption);
this.items = [];
this.index = 0;
document.addEventListener("click", (event) => this.onDocumentClick(event));
this.addEventListener("click", (event) => {
if (event.target === this || event.target === this.closeButton) {
this.close();
}
});
document.addEventListener("keydown", (event) => this.onKey(event));
}
onDocumentClick(event) {
const link = event.target.closest("[data-lightbox]");
if (!link || event.metaKey || event.ctrlKey) {
return;
}
event.preventDefault();
this.items = [...document.querySelectorAll("[data-lightbox]")];
this.show(this.items.indexOf(link));
}
show(index) {
this.index = (index + this.items.length) % this.items.length;
const link = this.items[this.index];
this.image.src = link.getAttribute("href");
this.image.alt = link.dataset.caption || "";
this.caption.textContent = link.dataset.caption || "";
this.hidden = false;
this.classList.add("visible");
this.closeButton.focus();
}
close() {
this.hidden = true;
this.classList.remove("visible");
this.image.removeAttribute("src");
}
onKey(event) {
if (this.hidden) {
return;
}
if (event.key === "Escape") {
this.close();
} else if (event.key === "ArrowRight") {
this.show(this.index + 1);
} else if (event.key === "ArrowLeft") {
this.show(this.index - 1);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { ContentRenderer } from "../utils/ContentRenderer.js";
export class Title extends Component {
static tag = "dp-title";
setup() {
const text = this.textContent;
this.innerHTML = ContentRenderer.render(text, { inline: true });
}
}
@@ -0,0 +1,19 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class ToastHost extends Component {
static tag = "dp-toast";
setup() {
this.setAttribute("role", "status");
this.setAttribute("aria-live", "polite");
}
show(message, kind = "info", ms = 3500) {
const item = document.createElement("div");
item.className = `toast ${kind}`;
item.textContent = message;
this.append(item);
setTimeout(() => item.remove(), ms);
}
}
+31
View File
@@ -0,0 +1,31 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class Upload extends Component {
static tag = "dp-upload";
setup() {
this.input = document.createElement("input");
this.input.type = "file";
this.input.multiple = this.hasAttribute("multiple");
this.input.accept = this.getAttribute("accept") || "image/*";
this.input.className = "sr-only";
const button = document.createElement("button");
button.type = "button";
button.className = "button ghost small";
button.textContent = this.getAttribute("label") || "Bestand kiezen";
button.addEventListener("click", () => this.input.click());
this.input.addEventListener("change", () => this.emit("dp-files", { files: [...this.input.files] }));
this.addEventListener("dragover", (event) => {
event.preventDefault();
this.classList.add("dragging");
});
this.addEventListener("dragleave", () => this.classList.remove("dragging"));
this.addEventListener("drop", (event) => {
event.preventDefault();
this.classList.remove("dragging");
this.emit("dp-files", { files: [...event.dataTransfer.files] });
});
this.append(this.input, button);
}
}
+13
View File
@@ -0,0 +1,13 @@
// retoor <retoor@molodetz.nl>
import { Avatar } from "./Avatar.js";
import { Chat } from "./Chat.js";
import { Code } from "./Code.js";
import { Content } from "./Content.js";
import { ContextMenu } from "./ContextMenu.js";
import { Dialog } from "./Dialog.js";
import { Lightbox } from "./Lightbox.js";
import { Title } from "./Title.js";
import { ToastHost } from "./ToastHost.js";
import { Upload } from "./Upload.js";
[Avatar, Chat, Code, Content, ContextMenu, Dialog, Lightbox, Title, ToastHost, Upload].forEach((component) => component.define());
File diff suppressed because one or more lines are too long
+63
View File
@@ -0,0 +1,63 @@
// retoor <retoor@molodetz.nl>
import { LoadingButton } from "./LoadingButton.js";
export class ApiTester {
constructor() {
document.querySelectorAll("[data-endpoint] [data-send]").forEach((button) => {
button.addEventListener("click", () => this.send(button));
});
}
async send(button) {
const section = button.closest("[data-endpoint]");
const output = section.querySelector("[data-response]");
if (section.dataset.destructive === "1") {
const accepted = await window.app.dialog.confirm("Dit verzoek wijzigt echte data. Doorgaan?");
if (!accepted) {
return;
}
}
let path = section.dataset.path;
const query = new URLSearchParams();
const body = {};
section.querySelectorAll("[data-param]").forEach((input) => {
const name = input.dataset.param;
const value = input.value;
if (input.dataset.location === "path") {
path = path.replace(`{${name}}`, encodeURIComponent(value));
} else if (input.dataset.location === "query") {
if (value !== "") {
query.set(name, value);
}
} else {
body[name] = value;
}
});
const method = section.dataset.method;
const url = query.toString() ? `${path}?${query}` : path;
const options = { method, credentials: "same-origin", headers: { Accept: "application/json" } };
if (method !== "GET") {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
LoadingButton.setLoading(button, true);
const started = performance.now();
try {
const response = await fetch(url, options);
const text = await response.text();
let pretty = text;
try {
pretty = JSON.stringify(JSON.parse(text), null, 2);
} catch {
pretty = text.slice(0, 4000);
}
output.textContent = `${response.status} ${response.statusText} (${Math.round(performance.now() - started)} ms)\n\n${pretty}`;
} catch (error) {
output.textContent = `Fout: ${error.message}`;
} finally {
LoadingButton.setLoading(button, false);
}
}
}
window.apiTester = new ApiTester();
+16
View File
@@ -0,0 +1,16 @@
// retoor <retoor@molodetz.nl>
export class AssetUrl {
static version() {
const meta = document.querySelector('meta[name="asset-version"]');
return meta ? meta.content : "";
}
static resolve(path) {
if (!path.startsWith("/static/") || path.startsWith("/static/uploads/")) {
return path;
}
return `/static/v${AssetUrl.version()}/${path.slice("/static/".length)}`;
}
}
export const assetUrl = (path) => AssetUrl.resolve(path);
@@ -0,0 +1,10 @@
// retoor <retoor@molodetz.nl>
export class ContentEnhancer {
constructor() {
document.querySelectorAll("[data-enhance] pre > code, [data-docs-content] pre > code").forEach((code) => {
const host = document.createElement("dp-code");
code.parentElement.replaceWith(host);
host.appendChild(code.parentElement);
});
}
}
@@ -0,0 +1,23 @@
// retoor <retoor@molodetz.nl>
import { marked } from "../../vendor/marked.esm.js";
import DOMPurify from "../../vendor/purify.es.mjs";
import { EMOJI_MAP } from "../generated/EmojiMap.js";
export class ContentRenderer {
static ALLOWED_URI = /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
static normalize(text) {
return (text || "").replace(/[\u2013\u2014]/g, "-").replace(/:([a-z0-9_+-]+):/g, (match, name) => EMOJI_MAP[name] || match);
}
static render(text, { inline = false } = {}) {
try {
const source = ContentRenderer.normalize(text);
const html = inline ? marked.parseInline(source) : marked.parse(source, { gfm: true, breaks: true });
return DOMPurify.sanitize(html, { ALLOWED_URI_REGEXP: ContentRenderer.ALLOWED_URI });
} catch (error) {
console.error("render failed", error);
return "";
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// retoor <retoor@molodetz.nl>
export class EditorPreview {
constructor() {
this.source = document.querySelector("[data-preview-source]");
this.target = document.querySelector("[data-preview-target]");
if (!this.source || !this.target) {
return;
}
this.timer = null;
this.source.addEventListener("input", () => this.schedule());
customElements.whenDefined("dp-content").then(() => this.update());
}
schedule() {
clearTimeout(this.timer);
this.timer = setTimeout(() => this.update(), 150);
}
update() {
this.target.markdown = this.source.value;
}
}
window.editorPreview = new EditorPreview();
+30
View File
@@ -0,0 +1,30 @@
// retoor <retoor@molodetz.nl>
import { LoadingButton } from "./LoadingButton.js";
export class FormManager {
constructor(dialog) {
this.dialog = dialog;
document.addEventListener("submit", (event) => this.onSubmit(event), true);
}
async onSubmit(event) {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (form.dataset.confirm && form.dataset.confirmed !== "1") {
event.preventDefault();
const accepted = await this.dialog.confirm(form.dataset.confirm);
if (accepted) {
form.dataset.confirmed = "1";
form.requestSubmit(event.submitter || undefined);
}
return;
}
const button = event.submitter || form.querySelector("[data-loading]");
if (button && button.hasAttribute("data-loading")) {
LoadingButton.setLoading(button, true);
window.addEventListener("pageshow", () => LoadingButton.setLoading(button, false), { once: true });
}
}
}
+72
View File
@@ -0,0 +1,72 @@
// retoor <retoor@molodetz.nl>
export class Http {
static termsGate = null;
static suspend() {
return new Promise(() => {});
}
static async getJson(url) {
const response = await fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" });
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
static async #post(url, body, headers) {
const response = await fetch(url, {
method: "POST",
body,
credentials: "same-origin",
headers: { Accept: "application/json", ...headers },
});
if (response.status === 403 && Http.termsGate) {
const handled = await Http.termsGate.intercept(response.clone());
if (handled) {
return Http.#post(url, body, headers);
}
}
if (response.redirected && response.url.includes("/auth/login")) {
window.location.href = response.url;
return Http.suspend();
}
return response;
}
static async send(url, data = {}) {
const response = await Http.#post(url, JSON.stringify(data), { "Content-Type": "application/json" });
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.ok === false) {
const message = payload.error?.message || (payload.messages || []).join(", ") || response.statusText;
throw new Error(message);
}
return payload;
}
static async sendForm(url, form) {
const body = new URLSearchParams(new FormData(form));
const response = await Http.#post(url, body, { "Content-Type": "application/x-www-form-urlencoded" });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(`${response.status} ${payload.error?.message || (payload.messages || []).join(", ") || ""}`.trim());
}
return response.json().catch(() => ({ ok: true }));
}
static postJson(url, data) {
return Http.send(url, data);
}
static postForm(url, form) {
return Http.sendForm(url, form);
}
static async sendDelete(url) {
const response = await fetch(url, { method: "DELETE", credentials: "same-origin", headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error(`${response.status}`);
}
return response.json().catch(() => ({ ok: true }));
}
}
+20
View File
@@ -0,0 +1,20 @@
// retoor <retoor@molodetz.nl>
export class LoadingButton {
static setLoading(button, on) {
if (!button) {
return;
}
button.classList.toggle("is-loading", on);
button.disabled = on;
button.setAttribute("aria-busy", on ? "true" : "false");
const existing = button.querySelector(".spinner");
if (on && !existing) {
const spinner = document.createElement("span");
spinner.className = "spinner";
spinner.setAttribute("aria-hidden", "true");
button.prepend(spinner);
} else if (!on && existing) {
existing.remove();
}
}
}
+55
View File
@@ -0,0 +1,55 @@
// retoor <retoor@molodetz.nl>
import { Poller } from "./Poller.js";
export class LocalTime {
constructor() {
this.dateFormat = new Intl.DateTimeFormat("nl-NL", { day: "2-digit", month: "2-digit", year: "numeric" });
this.timeFormat = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit" });
this.fullFormat = new Intl.DateTimeFormat("nl-NL", { dateStyle: "full", timeStyle: "long" });
this.relative = new Intl.RelativeTimeFormat("nl-NL", { numeric: "auto" });
this.renderAll(document);
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => mutation.addedNodes.forEach((node) => node.nodeType === 1 && this.renderAll(node)));
});
this.observer.observe(document.body, { childList: true, subtree: true });
this.poller = new Poller(() => this.renderAll(document, "ago"), 60000, { pauseHidden: true }).start();
}
renderAll(root, onlyMode = null) {
const nodes = root.matches?.("time[data-dt]") ? [root] : root.querySelectorAll("time[data-dt]");
nodes.forEach((node) => {
if (!onlyMode || node.dataset.mode === onlyMode) {
this.render(node);
}
});
}
render(node) {
const date = new Date(node.dataset.dt);
if (Number.isNaN(date.getTime())) {
return;
}
const mode = node.dataset.mode || "datetime";
node.title = this.fullFormat.format(date);
if (mode === "date") {
node.textContent = this.dateFormat.format(date).replaceAll("-", "/");
} else if (mode === "time") {
node.textContent = this.timeFormat.format(date);
} else if (mode === "ago") {
node.textContent = this.ago(date);
} else {
node.textContent = `${this.dateFormat.format(date).replaceAll("-", "/")} ${this.timeFormat.format(date)}`;
}
}
ago(date) {
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
const steps = [["year", 31536000], ["month", 2592000], ["day", 86400], ["hour", 3600], ["minute", 60]];
for (const [unit, size] of steps) {
if (Math.abs(seconds) >= size) {
return this.relative.format(Math.round(seconds / size), unit);
}
}
return this.relative.format(seconds, "second");
}
}
+14
View File
@@ -0,0 +1,14 @@
// retoor <retoor@molodetz.nl>
export class MobileNav {
constructor() {
this.toggle = document.querySelector("[data-nav-toggle]");
this.panel = document.getElementById("nav-links");
if (!this.toggle || !this.panel) {
return;
}
this.toggle.addEventListener("click", () => {
const open = this.panel.classList.toggle("open");
this.toggle.setAttribute("aria-expanded", open ? "true" : "false");
});
}
}
+84
View File
@@ -0,0 +1,84 @@
// retoor <retoor@molodetz.nl>
export class ModalManager {
constructor() {
this.stack = [];
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && this.stack.length) {
this.close(this.stack[this.stack.length - 1]);
}
});
window.addEventListener("popstate", () => {
if (this.stack.length) {
this.close(this.stack[this.stack.length - 1], false);
}
});
new MutationObserver(() => this.wire()).observe(document.body, { childList: true, subtree: true });
this.wire();
}
wire() {
document.querySelectorAll(".modal-overlay:not([data-wired])").forEach((overlay) => {
overlay.dataset.wired = "1";
overlay.setAttribute("role", overlay.getAttribute("role") || "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.addEventListener("keydown", (event) => this.trap(event, overlay));
});
}
onClick(event) {
const trigger = event.target.closest("[data-modal]");
if (trigger) {
event.preventDefault();
this.open(document.getElementById(trigger.dataset.modal));
return;
}
if (event.target.closest(".modal-close")) {
this.close(event.target.closest(".modal-overlay"));
return;
}
if (event.target.classList?.contains("modal-overlay")) {
this.close(event.target);
}
}
open(overlay) {
if (!overlay) {
return;
}
overlay.classList.add("visible");
this.stack.push(overlay);
history.pushState({ modal: overlay.id }, "");
overlay.querySelector("button, a, input, select, textarea")?.focus();
}
close(overlay, popHistory = true) {
if (!overlay) {
return;
}
overlay.classList.remove("visible");
this.stack = this.stack.filter((item) => item !== overlay);
if (popHistory && history.state?.modal === overlay.id) {
history.back();
}
}
trap(event, overlay) {
if (event.key !== "Tab") {
return;
}
const focusable = [...overlay.querySelectorAll("button, a[href], input, select, textarea")].filter((el) => !el.disabled);
if (!focusable.length) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
// retoor <retoor@molodetz.nl>
export class Poller {
constructor(fn, intervalMs, { immediate = false, pauseHidden = true } = {}) {
this.fn = fn;
this.intervalMs = intervalMs;
this.immediate = immediate;
this.pauseHidden = pauseHidden;
this.timer = null;
this.busy = false;
}
start() {
if (this.timer) {
return this;
}
if (this.immediate) {
this.tick();
}
this.timer = setInterval(() => this.tick(), this.intervalMs);
return this;
}
stop() {
clearInterval(this.timer);
this.timer = null;
}
async tick() {
if (this.busy || (this.pauseHidden && document.hidden)) {
return;
}
this.busy = true;
try {
await this.fn();
} catch (error) {
console.debug("poller tick failed", error);
} finally {
this.busy = false;
}
}
}
@@ -0,0 +1,25 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class PresenceManager {
constructor() {
this.online = new Set();
this.poller = null;
if (document.querySelector("[data-presence-uid]")) {
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false }).start();
}
}
async refresh() {
const roster = await Http.getJson("/presence/roster");
this.online = new Set(roster.online || []);
this.render();
}
render() {
document.querySelectorAll("[data-presence-uid]").forEach((dot) => {
dot.classList.toggle("online", this.online.has(dot.dataset.presenceUid));
});
}
}
+64
View File
@@ -0,0 +1,64 @@
// retoor <retoor@molodetz.nl>
export class ScrollMemory {
static KEY = "molodetz-scroll";
static MAX_ENTRIES = 50;
static MAX_AGE_MS = 60 * 60 * 1000;
constructor() {
history.scrollRestoration = "manual";
this.key = location.pathname + location.search;
window.addEventListener("pagehide", () => this.save());
const navigation = performance.getEntriesByType("navigation")[0];
const type = navigation ? navigation.type : "navigate";
if (type === "back_forward" || type === "reload" || sessionStorage.getItem("molodetz-restore") === this.key) {
sessionStorage.removeItem("molodetz-restore");
this.restore();
}
document.addEventListener("click", (event) => {
const link = event.target.closest(".breadcrumbs a, a[data-back]");
if (link) {
sessionStorage.setItem("molodetz-restore", new URL(link.href).pathname);
}
});
}
read() {
try {
return JSON.parse(sessionStorage.getItem(ScrollMemory.KEY) || "{}");
} catch {
return {};
}
}
save() {
const entries = this.read();
entries[this.key] = { y: window.scrollY, at: Date.now() };
const now = Date.now();
const kept = Object.entries(entries)
.filter(([, value]) => now - value.at < ScrollMemory.MAX_AGE_MS)
.sort((a, b) => b[1].at - a[1].at)
.slice(0, ScrollMemory.MAX_ENTRIES);
sessionStorage.setItem(ScrollMemory.KEY, JSON.stringify(Object.fromEntries(kept)));
}
restore() {
const entry = this.read()[this.key];
if (!entry) {
return;
}
const deadline = performance.now() + 1500;
let aborted = false;
const abort = () => { aborted = true; };
["wheel", "touchstart", "keydown"].forEach((name) => window.addEventListener(name, abort, { once: true, passive: true }));
const apply = () => {
if (aborted) {
return;
}
window.scrollTo(0, entry.y);
if (Math.abs(window.scrollY - entry.y) > 2 && performance.now() < deadline) {
requestAnimationFrame(apply);
}
};
requestAnimationFrame(apply);
}
}
@@ -0,0 +1,81 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class ServiceMonitor {
constructor() {
this.root = document.querySelector("[data-service-monitor]");
if (!this.root) {
return;
}
this.mode = this.root.dataset.serviceMonitor;
this.poller = new Poller(() => this.refresh(), 3000).start();
}
async refresh() {
if (this.mode === "detail") {
const payload = await Http.getJson(`/admin/services/${encodeURIComponent(this.root.dataset.service)}`);
this.renderDetail(payload.service || {});
} else {
const payload = await Http.getJson("/admin/services");
(payload.services || []).forEach((service) => this.renderRow(service));
}
}
setStatus(node, status) {
if (node) {
node.className = `status ${status}`;
node.textContent = status;
}
}
setTime(node, iso) {
if (!node) {
return;
}
node.replaceChildren();
if (iso) {
const time = document.createElement("time");
time.dataset.dt = iso;
time.dataset.mode = "datetime";
time.dateTime = iso;
time.textContent = iso;
node.append(time);
}
}
renderRow(service) {
const row = this.root.querySelector(`[data-service="${CSS.escape(service.name)}"]`);
if (!row) {
return;
}
this.setStatus(row.querySelector('[data-field="status"]'), service.status);
this.setTime(row.querySelector('[data-field="last_run"]'), service.last_run);
this.setTime(row.querySelector('[data-field="next_run"]'), service.next_run);
}
renderDetail(service) {
this.setStatus(this.root.querySelector('[data-field="status"]'), service.status);
const logs = this.root.querySelector('[data-field="logs"]');
if (logs) {
logs.textContent = (service.logs || []).join("\n");
}
const cards = this.root.querySelector('[data-field="cards"]');
if (cards && service.metrics?.cards) {
cards.replaceChildren(...service.metrics.cards.map((card) => {
const box = document.createElement("div");
box.className = "stat-card";
const value = document.createElement("span");
value.className = "value";
value.textContent = card.value;
const name = document.createElement("span");
name.className = "name";
name.textContent = card.label;
box.append(value, name);
return box;
}));
}
}
}
window.serviceMonitor = new ServiceMonitor();
+20
View File
@@ -0,0 +1,20 @@
// retoor <retoor@molodetz.nl>
export class Tabs {
constructor() {
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-tabs] [data-tab]");
if (button) {
this.select(button);
}
});
}
select(button) {
const bar = button.closest("[data-tabs]");
const scope = bar.parentElement;
bar.querySelectorAll("[data-tab]").forEach((item) => item.classList.toggle("active", item === button));
scope.querySelectorAll(":scope > [data-panel]").forEach((panel) => {
panel.hidden = panel.dataset.panel !== button.dataset.tab;
});
}
}
+34
View File
@@ -0,0 +1,34 @@
// retoor <retoor@molodetz.nl>
export class TermsGate {
constructor(dialog) {
this.dialog = dialog;
this.pending = null;
}
async intercept(response) {
const payload = await response.json().catch(() => ({}));
if (payload.error?.code !== "terms_required") {
return false;
}
if (!this.pending) {
this.pending = this.dialog
.confirm(`De voorwaarden zijn gewijzigd (versie ${payload.error.terms_version}). Accepteren en doorgaan?`)
.then(async (accepted) => {
if (!accepted) {
return false;
}
const result = await fetch("/voorwaarden/accept", {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: "{}",
});
return result.ok;
})
.finally(() => {
setTimeout(() => { this.pending = null; }, 0);
});
}
return this.pending;
}
}
+11
View File
@@ -0,0 +1,11 @@
// retoor <retoor@molodetz.nl>
export class Toast {
static flash(el, message, ms = 2000, revertTo = null) {
if (!el) {
return;
}
const original = revertTo ?? el.textContent;
el.textContent = message;
setTimeout(() => { el.textContent = original; }, ms);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#0d0c0a"/><text x="32" y="46" text-anchor="middle" font-family="Georgia, serif" font-size="44" fill="#d4a020">M</text></svg>

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+3
View File
@@ -0,0 +1,3 @@
marked 15.0.7 (lib/marked.esm.js) https://cdn.jsdelivr.net/npm/marked@15.0.7/lib/marked.esm.js
DOMPurify 3.2.4 (dist/purify.es.mjs) https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.es.mjs
highlight.js 11.11.1 (build/es/highlight.min.js) https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/es/highlight.min.js
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff