Compare commits

...

2 Commits

Author SHA1 Message Date
karim 60e5ef6844 cms: headless CMS vor Hugo (Supabase + Node-API + React-Admin)
All-in-One docker-compose-Stack (Muster von RAPPORT-SERVER gespiegelt):
db/auth/rest/kong + cms-Service (Node-API + Hugo-Binary 0.161.1 + Admin-SPA).

- DB-backed: posts-Tabelle kanonisch, MD ist generiertes Artefakt
- echte Hugo-Vorschau via draft:true + --buildDrafts → /_preview
- Publish: DB → content/library/<section>/<slug>.md → hugo build → live
- Bild-Upload nach static/images/, Supabase-Auth schützt /api/*
- Proxmox-LXC-Script: legt Container an, generiert Secrets, startet Stack

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 00:21:04 +02:00
karim 7a5be9250a republik-style redesign: dark masthead, hero block, palette, pills
- Dark full-bleed masthead with serif wordmark and stacked nav
- Hero entry treatment: full-bleed image bg with gradient overlay
- Per-section color system (data-section) + per-post override (data-color)
- Japanese accent palette (ajisai, sakura, suna, ichigo, yuyake, sora,
  kusa, kori, amagumo, yuki) — set via `color:` in front matter
- Tag pills replace hashtag-style tags; rendered as sibling of card link
  to avoid invalid nested <a> elements
- Single article: clean Republik-style header, no section rubric,
  tags as pills at bottom
- Cover image support (`cover_image:` in front matter):
    - wide mode: full-card banner above text
    - icon mode: small thumbnail right of text with colored card bg
- Library subsection header redesigned with section-colored stripe
- Dates use Swiss DD.MM.YYYY display (ISO retained in datetime attr)
- Custom Tokyo metro photo as demo cover image
2026-05-24 14:32:13 +02:00
43 changed files with 4227 additions and 132 deletions
+4
View File
@@ -1,5 +1,6 @@
# Hugo # Hugo
/public/ /public/
/preview/
/resources/ /resources/
.hugo_build.lock .hugo_build.lock
hugo_stats.json hugo_stats.json
@@ -8,6 +9,9 @@ hugo_stats.json
.DS_Store .DS_Store
**/.DS_Store **/.DS_Store
# Node (CMS)
node_modules/
# Editors # Editors
.vscode/ .vscode/
.idea/ .idea/
+536 -106
View File
@@ -5,7 +5,7 @@
no 8bit cursor. no 8bit cursor.
======================================================================== */ ======================================================================== */
@import url('https://fonts.bunny.net/css?family=newsreader:400,400i,500,500i,600,600i|ibm-plex-mono:400,500|space-grotesk:400,500,700|inter:400,500,600'); @import url('https://fonts.bunny.net/css?family=newsreader:400,400i,500,500i,600,600i,700,700i,800,800i|ibm-plex-mono:400,500|space-grotesk:400,500,700|inter:400,500,600');
:root { :root {
/* Typography — editorial: /* Typography — editorial:
@@ -45,27 +45,63 @@
--accent: #b54a2c; --accent: #b54a2c;
--accent-soft: #d97a5a; --accent-soft: #d97a5a;
/* Republik-aligned tokens */
--color-dark-panel: #191919; /* masthead/footer/hero (Republik #191919) */
--color-dark-panel-text: #f0f0f0;
--color-dark-panel-muted: #a9a9a9;
/* Section accents — warm palette tuned to OPENBUREAU's terracotta brand.
Each library section reads on the warm-paper background. */
--section-color-software: #b54a2c; /* terracotta — brand primary */
--section-color-buerofuehrung: #D0913C; /* gold — warm/editorial */
--section-color-theorie: #7d2e16; /* deep rust — heavier weight */
/* Per-post color palette — Japanese accents, soft and editorial.
Set `color: <name>` in front matter (e.g. color: sakura). */
--palette-ajisai: #A39EC4; /* hydrangea (lavender) */
--palette-sakura: #C49EC4; /* cherry blossom (pink) */
--palette-suna: #C4C19E; /* sand (warm beige) */
--palette-ichigo: #C49EA0; /* strawberry (rose) */
--palette-yuyake: #CEB188; /* sunset (peach) */
--palette-sora: #9EC3C4; /* sky (teal) */
--palette-kusa: #9EC49F; /* grass (green) */
--palette-kori: #A5B4CB; /* ice (cool blue) */
--palette-amagumo: #4C4C4C; /* rain cloud (grey) */
--palette-yuki: #F0F0F0; /* snow (near white) */
color-scheme: light; color-scheme: light;
} }
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
Base body — serif by default (editorial) Base body — serif editorial, 3-col grid so header/footer can full-bleed
while content stays boxed
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
body { body {
font-family: var(--font-family-serif); font-family: var(--font-family-serif);
font-size: var(--font-size-base); font-size: var(--font-size-base);
line-height: 1.55; line-height: 1.55;
padding: var(--spacing-sm) 1.75rem var(--spacing-xl); padding: 0 0 var(--spacing-xl);
margin: 0;
gap: var(--spacing-sm); gap: var(--spacing-sm);
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
grid-template-columns: minmax(auto, var(--container-width)); /* Boxed content column = 72ch with 1.75rem gutters, side columns absorb the rest */
justify-content: center; grid-template-columns:
1fr
min(var(--container-width), 100% - 3.5rem)
1fr;
} }
/* Default: every direct body child sits in the content column */
body > * { grid-column: 2; }
/* Opt-in: full-bleed children break out edge-to-edge */
body > .full-bleed,
body > header.site-header,
body > footer { grid-column: 1 / -1; }
p { margin: var(--spacing-sm) 0; } p { margin: var(--spacing-sm) 0; }
h1, h2, h3, h4, h5 { font-family: var(--font-family-display); font-weight: 600; } /* Republik-style: serif headlines as the visual anchor, not sans display */
h1, h2, h3, h4, h5 { font-family: var(--font-family-serif); font-weight: 600; letter-spacing: -0.015em; }
/* Disable shibui's nested h2/h3/h4 counters everywhere on this site */ /* Disable shibui's nested h2/h3/h4 counters everywhere on this site */
h2::before, h3::before, h4::before, h5::before { content: none !important; } h2::before, h3::before, h4::before, h5::before { content: none !important; }
@@ -85,18 +121,33 @@ a:hover {
} }
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
Site header — strong wordmark, not breadcrumb-first Site header — black masthead, Republik-style serif wordmark
Full-bleed bar with content-column-aligned inner layout
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
/* 2-column header: wordmark left edge of content | nav right edge of content.
Center-aligned vertically so wordmark and nav read as one masthead line. */
.site-header { .site-header {
background: var(--color-dark-panel);
color: var(--color-dark-panel-text);
padding: 0.9rem 0;
border-bottom: none;
margin-bottom: var(--spacing-md);
/* inner 3-col grid matches body so wordmark/nav align with content column */
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns:
1fr
min(var(--container-width), 100% - 3.5rem)
1fr;
align-items: center; align-items: center;
column-gap: 2rem;
row-gap: 0.6rem; row-gap: 0.6rem;
padding-bottom: var(--spacing-sm); }
border-bottom: 1px solid var(--color-border); .site-header > * { grid-column: 2; }
.site-header .wordmark-link,
.site-header .site-nav {
grid-column: 2;
}
/* Inner row: wordmark center, nav under it (closer to Republik's stacked masthead).
To get wordmark + nav as one row instead, change to grid-template-columns: auto 1fr. */
.site-header {
/* override: stack wordmark above nav, both within content column */
} }
.wordmark-link { .wordmark-link {
@@ -104,35 +155,20 @@ a:hover {
margin: 0; margin: 0;
padding: 0; padding: 0;
display: inline-block; display: inline-block;
grid-column: 1; justify-self: center;
justify-self: start; /* Republik-style: serif wordmark, all caps, white on black */
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-weight: 700; font-weight: 700;
font-size: clamp(1.2rem, 2.4vw, 1.5rem); font-size: clamp(1.5rem, 3vw, 2rem);
letter-spacing: -0.01em; letter-spacing: 0.02em;
line-height: 1; line-height: 1;
color: var(--color-text-primary); color: #fff;
} }
.wordmark-link:hover, .wordmark-link:hover,
.wordmark-link:focus { color: var(--color-text-primary); border: none; } .wordmark-link:focus { color: #fff; border: none; opacity: 0.85; }
.site-header .site-nav { .site-header .site-nav {
grid-column: 2; justify-self: center;
justify-self: end;
}
/* Mobile: stack */
@media (max-width: 720px) {
.site-header {
grid-template-columns: 1fr;
align-items: start;
row-gap: 0.4rem;
}
.wordmark-link,
.site-header .site-nav {
grid-column: 1;
justify-self: start;
}
} }
.wordmark-link:focus-visible { outline: 2px dotted var(--color-text-muted); outline-offset: 4px; } .wordmark-link:focus-visible { outline: 2px dotted var(--color-text-muted); outline-offset: 4px; }
@@ -151,26 +187,34 @@ a:hover {
.wordmark-tagline { text-align: left; max-width: 38ch; } .wordmark-tagline { text-align: left; max-width: 38ch; }
} }
/* Site nav — horizontal, lowercase-strong, mono */ /* Site nav — under wordmark, white on black masthead.
Keeps the original display font (Space Grotesk) — only colour changes. */
.site-nav nav { .site-nav nav {
margin: 0; margin: 0;
} }
.site-nav ul { .site-nav ul {
list-style: none; list-style: none;
margin-left: 0; margin: 0;
padding: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem 1.4rem; justify-content: center;
gap: 0.25rem 1.6rem;
font-family: var(--font-family-display); font-family: var(--font-family-display);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.site-nav li { margin: 0; } .site-nav li { margin: 0; }
.site-nav a { border: none; } .site-nav a {
border: none;
color: #d4d4d4;
}
.site-nav a:hover { color: #fff; }
.site-nav a.active, .site-nav a.active,
.site-nav a.ancestor { .site-nav a.ancestor {
color: var(--accent); color: #fff;
font-weight: 600;
} }
/* Breadcrumb (only on non-home pages) */ /* Breadcrumb (only on non-home pages) */
@@ -226,22 +270,69 @@ a:hover {
.journal-entry { .journal-entry {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding: 1rem 0 1.1rem; padding: 1.6rem 0 1.7rem;
} }
.journal-entry:last-child { border-bottom: 1px solid var(--color-border); } .journal-entry:last-child { border-bottom: 1px solid var(--color-border); }
/* Grid with explicit row-gap: bulletproof against margin/line-height inheritance */ /* Card link: image (optional) + body block */
.journal-entry-link { .journal-entry-link,
display: grid; .journal-entry-link:hover,
grid-auto-flow: row; .journal-entry-link:focus {
row-gap: 0.45rem; display: block;
border: none; border: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
color: inherit;
text-decoration: none;
} }
.journal-entry-link > * { margin: 0; } .journal-entry-link:hover .journal-title { color: var(--section-color, var(--accent)); }
.journal-entry-link:hover .journal-title { color: var(--accent); }
.journal-entry-body {
display: grid;
grid-auto-flow: row;
row-gap: 0.7rem;
}
.journal-entry-body > * { margin: 0; }
/* Center non-hero entries' content (excluding icon-mode which has horizontal layout) */
.journal-entry:not(.journal-entry--hero):not([data-image="icon"]) .journal-entry-body {
justify-items: center;
text-align: center;
}
.journal-entry:not(.journal-entry--hero):not([data-image="icon"]) .journal-entry-body > * {
max-width: 55ch;
}
.journal-entry:not(.journal-entry--hero):not([data-image="icon"]) > .tag-pills {
text-align: center;
}
/* Rubric (section label, own line above headline) — Republik pattern */
.journal-rubric {
margin: 0;
line-height: 1;
}
/* Byline: single smooth serif line, "Von Author, DD.MM.YYYY" — Republik-style */
.journal-byline {
font-family: var(--font-family-serif);
font-size: 1rem;
font-style: italic;
line-height: 1.4;
color: var(--color-text-muted);
margin: 0;
white-space: normal;
}
.journal-byline .journal-author {
color: var(--color-text-primary);
font-style: normal;
font-weight: 500;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
.journal-byline .journal-date { font-variant-numeric: tabular-nums; font-style: normal; }
/* Legacy .journal-meta retained for backwards-compat if any markup references it */
.journal-meta { .journal-meta {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: var(--font-size-small); font-size: var(--font-size-small);
@@ -251,21 +342,208 @@ a:hover {
gap: 0.8rem; gap: 0.8rem;
align-items: baseline; align-items: baseline;
} }
.journal-section { color: var(--accent); font-weight: 500; } .journal-section {
.journal-date { font-variant-numeric: tabular-nums; } display: inline-block;
.journal-author { color: var(--color-text-primary); font-weight: 500; } background: color-mix(in oklab, var(--section-color, var(--accent)) 35%, transparent);
.journal-author::before { content: "von "; color: var(--color-text-muted); font-weight: 400; } color: var(--color-text-primary);
font-family: var(--font-family-display);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
border-radius: 2px;
line-height: 1.35;
}
/* Hero section label sits on its own line above the headline */
.journal-entry--hero .journal-section {
font-size: 0.9rem;
letter-spacing: 0.12em;
}
.journal-author::before { content: "Von "; color: var(--color-text-muted); font-weight: 400; font-style: italic; }
.journal-entry--hero .journal-author::before { color: var(--color-dark-panel-muted); }
.journal-title { .journal-title {
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-size: 1.2rem; font-size: 1.65rem;
font-weight: 600; font-weight: 700;
letter-spacing: -0.012em; letter-spacing: -0.02em;
line-height: 1.2; line-height: 1.2;
color: var(--color-text-primary); color: var(--color-text-primary);
transition: color 0.15s ease; transition: color 0.15s ease;
} }
/* Section-color mapping — sets --section-color per entry so meta label
and hero top-border pick up the right accent. */
[data-section="software"] { --section-color: var(--section-color-software); }
[data-section="buerofuehrung"] { --section-color: var(--section-color-buerofuehrung); }
[data-section="theorie"] { --section-color: var(--section-color-theorie); }
/* Per-post color override (front matter `color: <name>`) — wins over section */
[data-color="ajisai"] { --section-color: var(--palette-ajisai); }
[data-color="sakura"] { --section-color: var(--palette-sakura); }
[data-color="suna"] { --section-color: var(--palette-suna); }
[data-color="ichigo"] { --section-color: var(--palette-ichigo); }
[data-color="yuyake"] { --section-color: var(--palette-yuyake); }
[data-color="sora"] { --section-color: var(--palette-sora); }
[data-color="kusa"] { --section-color: var(--palette-kusa); }
[data-color="kori"] { --section-color: var(--palette-kori); }
[data-color="amagumo"] { --section-color: var(--palette-amagumo); }
[data-color="yuki"] { --section-color: var(--palette-yuki); }
/* Hero treatment — first entry as a coloured Republik-style block.
Full-bleed dark panel, white serif title, light meta on top. */
/* Hero entry — Republik pattern.
Without cover_image: warm terracotta panel, centered text on it.
With cover_image: image fills the hero as background, text sits
at the bottom on a dark gradient that fades into the terracotta. */
.journal-entry--hero {
border: none;
background: #3a1a0d;
color: var(--color-dark-panel-text);
padding: 0;
margin: 0 calc(50% - 50vw) var(--spacing-lg);
border-top: 10px solid var(--section-color, var(--accent));
}
.journal-entry--hero:last-child { border-bottom: none; }
.journal-entry--hero .journal-entry-link {
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
overflow: hidden;
color: inherit;
min-height: auto;
}
/* When a hero image is present, give the link a viewport-anchored height */
.journal-entry--hero:has(.journal-hero-image) .journal-entry-link {
min-height: 72vh;
}
.journal-hero-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
margin: 0;
filter: none;
}
.journal-hero-image:hover { filter: none; }
.journal-entry--hero .journal-entry-body {
position: relative;
z-index: 2;
display: grid;
justify-items: center;
row-gap: 1.1rem;
padding: 2.6rem 1.75rem 3rem;
text-align: center;
background: #3a1a0d;
}
/* When a hero image is present, body sits at the bottom over a gradient
so text reads cleanly over the photo while the photo stays visible above. */
.journal-entry--hero:has(.journal-hero-image) .journal-entry-body {
padding-top: 7rem;
background: linear-gradient(180deg,
rgba(58,26,13,0) 0%,
rgba(58,26,13,0.55) 25%,
rgba(58,26,13,0.92) 65%,
#3a1a0d 100%);
}
.journal-entry--hero .journal-entry-body > * {
max-width: 60ch;
margin: 0;
}
.journal-entry--hero .journal-summary { max-width: 38ch; }
/* ------------------------------------------------------------------------
Non-hero card with cover image — two presentation modes:
1) default (wide): Republik-style — image as full-card wide banner above body.
2) icon mode: small square/portrait thumbnail next to the body text,
with the card background filled in --section-color (when set).
Trigger by adding `image_mode: icon` in the front matter
(which adds `data-image="icon"` to the <li>).
------------------------------------------------------------------------ */
/* Default wide banner image (no data-image) */
.journal-entry:not(.journal-entry--hero) .journal-card-image {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
margin: 0 0 1rem;
filter: grayscale(40%);
transition: filter 0.4s ease;
}
.journal-entry-link:hover .journal-card-image { filter: grayscale(0%); }
/* Icon mode — colored card with thumbnail to the right */
.journal-entry[data-image="icon"]:not(.journal-entry--hero) {
border: none;
background: color-mix(in oklab, var(--section-color, var(--accent)) 12%, transparent);
padding: 1.2rem 1.4rem;
border-radius: 4px;
border-left: 4px solid var(--section-color, var(--accent));
}
.journal-entry[data-image="icon"]:not(.journal-entry--hero) + .journal-entry { border-top: none; }
.journal-entry[data-image="icon"]:not(.journal-entry--hero) .journal-entry-link {
display: grid;
grid-template-columns: 1fr auto;
column-gap: 1.4rem;
align-items: start;
}
.journal-entry[data-image="icon"]:not(.journal-entry--hero) .journal-entry-body {
grid-column: 1;
}
.journal-entry[data-image="icon"]:not(.journal-entry--hero) .journal-card-image {
grid-column: 2;
grid-row: 1;
width: 96px;
height: 96px;
aspect-ratio: 1 / 1;
object-fit: cover;
margin: 0;
border-radius: 4px;
filter: none;
}
@media (max-width: 540px) {
.journal-entry[data-image="icon"]:not(.journal-entry--hero) .journal-card-image {
width: 64px;
height: 64px;
}
}
.journal-entry--hero .journal-section {
/* on the dark hero bg: brighter tint, light text */
background: color-mix(in oklab, var(--section-color, var(--accent-soft)) 45%, transparent);
color: var(--color-dark-panel-text);
}
.journal-entry--hero .journal-byline { color: var(--color-dark-panel-muted); }
.journal-entry--hero .journal-byline .journal-author { color: var(--color-dark-panel-text); }
.journal-entry--hero .journal-byline .journal-author::before { color: var(--color-dark-panel-muted); }
.journal-entry--hero .journal-title {
font-size: clamp(2.1rem, 4.2vw, 3rem);
line-height: 1.02;
letter-spacing: -0.028em;
font-weight: 800;
color: var(--color-dark-panel-text);
}
.journal-entry--hero .journal-summary {
font-size: 1.35rem;
line-height: 1.35;
color: var(--color-dark-panel-text);
max-width: 38ch;
}
.journal-entry--hero .journal-byline {
font-size: 1.05rem;
color: var(--color-dark-panel-muted);
}
.journal-entry--hero .journal-byline .journal-author {
color: var(--color-dark-panel-text);
}
.journal-entry--hero .journal-tags { color: var(--color-dark-panel-muted); }
.journal-entry--hero .journal-entry-link:hover .journal-title { color: var(--accent-soft); }
.journal-summary { .journal-summary {
font-family: var(--font-family-serif); font-family: var(--font-family-serif);
font-size: var(--font-size-base); font-size: var(--font-size-base);
@@ -274,18 +552,32 @@ a:hover {
max-width: 60ch; max-width: 60ch;
} }
.journal-tags { .journal-tags { /* now extends .tag-pills — base styles come from there */ }
list-style: none;
display: flex; /* Hero tags — sit inside the dark hero block, below the link, centered */
flex-wrap: wrap; .journal-entry--hero > .tag-pills {
gap: 0.6rem; margin: 0 auto;
font-family: var(--font-family-sans); padding: 0 1.75rem 2.2rem;
font-size: 0.78rem; max-width: var(--container-width);
line-height: 1.2; text-align: center;
color: var(--color-text-muted); }
padding: 0; .journal-entry--hero > .tag-pills a {
background: color-mix(in oklab, var(--section-color, var(--accent-soft)) 30%, transparent);
color: var(--color-dark-panel-text);
}
.journal-entry--hero > .tag-pills a:hover {
background: color-mix(in oklab, var(--section-color, var(--accent-soft)) 50%, transparent);
}
/* Non-hero tags — sit just below the body of the card */
.journal-entry:not(.journal-entry--hero) > .tag-pills {
margin-top: 0.6rem;
}
/* In icon-mode card: tags align with the body (left of the thumbnail) */
.journal-entry[data-image="icon"]:not(.journal-entry--hero) > .tag-pills {
margin-top: 0.6rem;
padding-left: 0;
} }
.journal-tags li { margin: 0; }
.more { .more {
margin-top: var(--spacing-md); margin-top: var(--spacing-md);
@@ -304,12 +596,12 @@ a:hover {
} }
.atlas-section h2, .atlas-section h2,
.atlas-tags h2 { .atlas-tags h2 {
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-size: 1.15rem; font-size: 1.4rem;
font-weight: 600; font-weight: 700;
letter-spacing: -0.005em; letter-spacing: -0.018em;
color: var(--color-text-primary); color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border); border-bottom: 3px solid var(--section-color, var(--accent));
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
} }
@@ -348,6 +640,36 @@ a:hover {
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
Library subsection (chronological list) Library subsection (chronological list)
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
.section-header {
border-top: 8px solid var(--section-color, var(--accent));
padding-top: var(--spacing-sm);
margin-top: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.section-rubric {
font-family: var(--font-family-display);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.85rem;
color: var(--section-color, var(--accent));
margin: 0 0 0.4rem;
}
.section-title {
font-family: var(--font-family-serif);
font-size: clamp(1.9rem, 4vw, 2.6rem);
font-weight: 800;
letter-spacing: -0.026em;
line-height: 1.05;
margin: 0 0 0.4rem;
}
.section-description {
font-style: italic;
color: var(--color-text-muted);
margin: 0 0 var(--spacing-sm);
max-width: 60ch;
}
.time-list ul { .time-list ul {
list-style: none; list-style: none;
margin-left: 0; margin-left: 0;
@@ -365,9 +687,10 @@ a:hover {
} }
.list-title { .list-title {
margin: 0; margin: 0;
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-size: 1.15rem; font-size: 1.25rem;
font-weight: 500; font-weight: 600;
letter-spacing: -0.01em;
flex: 1; flex: 1;
} }
.list-title a { border: none; } .list-title a { border: none; }
@@ -398,9 +721,11 @@ a:hover {
} }
.software-item:last-child { border-bottom: 1px solid var(--color-border); padding-bottom: var(--spacing-md); } .software-item:last-child { border-bottom: 1px solid var(--color-border); padding-bottom: var(--spacing-md); }
.software-item h2 { .software-item h2 {
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-size: 1.8rem; font-size: 2rem;
letter-spacing: -0.015em; font-weight: 800;
letter-spacing: -0.024em;
line-height: 1.08;
margin: 0 0 0.4rem; margin: 0 0 0.4rem;
} }
.software-item h2 a { border: none; } .software-item h2 a { border: none; }
@@ -428,14 +753,70 @@ a:hover {
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
.single { margin-top: var(--spacing-md); } .single { margin-top: var(--spacing-md); }
.single-header { margin-bottom: var(--spacing-md); } .single-header { margin-bottom: var(--spacing-md); }
.single-header h1 { /* Single article — full-bleed cover image directly under the masthead.
font-family: var(--font-family-display); width:100vw breaks the boxed column horizontally; negative top margin
font-size: clamp(1.2rem, 2.4vw, 1.5rem); cancels the body grid gap + header margin so the image sits flush. */
font-weight: 700; .single-hero-image {
letter-spacing: -0.01em; display: block;
line-height: 1.15; width: 100vw;
margin: 0 0 var(--spacing-xs); max-width: 100vw;
height: auto;
max-height: 60vh;
object-fit: cover;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
margin-top: calc(-1 * (var(--spacing-md) + var(--spacing-sm)));
margin-bottom: var(--spacing-md);
filter: none;
} }
.single-hero-image:hover { filter: none; }
.single-header {
position: relative;
margin-top: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.single-header h1 {
font-family: var(--font-family-serif);
font-size: clamp(2.1rem, 4.6vw, 3rem);
font-weight: 800;
letter-spacing: -0.028em;
line-height: 1.05;
margin: 0 0 var(--spacing-sm);
}
.single-summary {
font-family: var(--font-family-serif);
font-style: normal;
font-size: 1.25rem;
line-height: 1.4;
color: var(--color-text-primary);
margin: 0 0 var(--spacing-sm);
max-width: 55ch;
}
.single-byline {
font-family: var(--font-family-serif);
font-style: italic;
font-size: 1rem;
line-height: 1.4;
color: var(--color-text-muted);
margin: 0;
}
.single-byline .byline-author {
color: var(--color-text-primary);
font-style: normal;
font-weight: 500;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
.single-byline .byline-author::before {
content: "Von ";
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
text-decoration: none;
}
.single-byline .byline-date { font-style: normal; font-variant-numeric: tabular-nums; }
.single-byline { .single-byline {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: 0.85rem; font-size: 0.85rem;
@@ -458,17 +839,20 @@ a:hover {
max-width: 55ch; max-width: 55ch;
} }
.single-content h2 { .single-content h2 {
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-size: 1.15rem; font-size: 1.45rem;
font-weight: 600; font-weight: 600;
margin-top: var(--spacing-md); letter-spacing: -0.015em;
line-height: 1.2;
margin-top: var(--spacing-lg);
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.single-content h3 { .single-content h3 {
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-size: 1rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
margin-top: var(--spacing-sm); letter-spacing: -0.01em;
margin-top: var(--spacing-md);
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.single-content blockquote { .single-content blockquote {
@@ -503,11 +887,51 @@ a:hover {
.toc ul, .toc ol { margin: 0 0 0 1.2rem; padding: 0; } .toc ul, .toc ol { margin: 0 0 0 1.2rem; padding: 0; }
.toc li { margin: 0.15rem 0; } .toc li { margin: 0.15rem 0; }
/* Tag pills — small section-tinted chips at the foot of the article.
No hash symbol, lowercase, rounded; subtle on the warm paper. */
.tag-pills {
list-style: none;
margin: var(--spacing-sm) 0 0;
margin-left: 0;
padding: 0;
line-height: 1;
}
.tag-pills li {
display: inline-block;
margin: 0 0.3rem 0.3rem 0;
padding: 0;
list-style: none;
vertical-align: middle;
}
.tag-pills a {
display: inline-block;
padding: 0.35rem 0.85rem;
line-height: 1.3;
font-family: var(--font-family-display);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: lowercase;
background: color-mix(in oklab, var(--section-color, var(--accent)) 14%, transparent);
color: var(--color-text-primary);
border: none;
border-radius: 999px;
margin: 0;
white-space: nowrap;
text-decoration: none;
vertical-align: middle;
transition: background-color 0.15s ease;
}
.tag-pills a:hover {
background: color-mix(in oklab, var(--section-color, var(--accent)) 30%, transparent);
border: none;
}
.time { .time {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: var(--font-size-small); font-size: var(--font-size-small);
color: var(--color-text-muted); color: var(--color-text-muted);
margin-top: var(--spacing-lg); margin-top: var(--spacing-md);
padding-top: var(--spacing-sm); padding-top: var(--spacing-sm);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
@@ -546,18 +970,24 @@ a:hover {
.page-foot-nav li.current a { color: var(--color-text-primary); } .page-foot-nav li.current a { color: var(--color-text-primary); }
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
Footer — mirrors the header rhythm: 2-col grid, baseline-aligned, Footer — dark full-bleed bar mirroring the masthead
wordmark left, nav right; below: tagline left, credit right.
Text left-aligned everywhere (no centering).
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
footer { footer {
background: var(--color-dark-panel);
color: var(--color-dark-panel-muted);
margin-top: var(--spacing-xl); margin-top: var(--spacing-xl);
padding-top: var(--spacing-md); padding: var(--spacing-lg) 0;
border-top: 1px solid var(--color-border); border-top: none;
color: var(--color-text-muted); /* inner grid aligns with content column, same trick as header */
display: grid;
grid-template-columns:
1fr
min(var(--container-width), 100% - 3.5rem)
1fr;
} }
footer a { color: var(--color-text-muted); border: none; } footer > * { grid-column: 2; }
footer a:hover { color: var(--accent); } footer a { color: #d4d4d4; border: none; }
footer a:hover { color: var(--accent-soft); }
footer p { text-align: left; margin: 0; } footer p { text-align: left; margin: 0; }
.footer-grid { .footer-grid {
@@ -570,11 +1000,11 @@ footer p { text-align: left; margin: 0; }
.footer-mark { .footer-mark {
grid-column: 1; grid-column: 1;
font-family: var(--font-family-display); font-family: var(--font-family-serif);
font-weight: 600; font-weight: 700;
font-size: 0.95rem; font-size: 1.05rem;
letter-spacing: 0.01em; letter-spacing: 0.02em;
color: var(--color-text-primary); color: #fff;
} }
.footer-nav { .footer-nav {
+30
View File
@@ -0,0 +1,30 @@
# Kopiere nach .env und ersetze die markierten Werte. Niemals committen.
# ═══ Pflicht: Secrets ═══
# Zufallswerte: openssl rand -hex 32
POSTGRES_PASSWORD=CHANGE-ME-mindestens-32-zufällige-zeichen
JWT_SECRET=CHANGE-ME-mindestens-32-zufällige-zeichen
# Aus JWT_SECRET ableiten: node scripts/generate-keys.mjs
ANON_KEY=CHANGE-ME-aus-jwt-secret-abgeleitet
SERVICE_ROLE_KEY=CHANGE-ME-aus-jwt-secret-abgeleitet
# ═══ Pflicht: URLs ═══
# Nur LAN: SITE_URL=http://192.168.1.50:8080
# Via Proxy: SITE_URL=https://openbureau.ch
SITE_URL=http://localhost:8080
# Öffentliche Supabase-Adresse (Browser/Admin erreichen Kong hierüber).
API_EXTERNAL_URL=http://localhost:8000
# ═══ Optional: Ports ═══
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
KONG_HTTP_PORT=8000 # Supabase-API-Gateway
KONG_HTTPS_PORT=8443
DB_PORT=5432
# ═══ Optional: Git-Backup beim Publish ═══
# true = CMS committet generierte MD nach Gitea (History + Notausgang).
GIT_PUBLISH=false
GIT_REMOTE=origin
GIT_BRANCH=main
GIT_AUTHOR_NAME=OPENBUREAU CMS
GIT_AUTHOR_EMAIL=cms@openbureau.ch
+98
View File
@@ -0,0 +1,98 @@
# OPENBUREAU CMS
Headless CMS vor der Hugo-Engine. Hugo bleibt die Render-Engine; dieser Stack
schreibt Content aus Supabase in `content/*.md`, baut die Site und serviert sie.
## Architektur
**Ein** docker-compose-Stack / ein LXC (Muster gespiegelt von RAPPORT-SERVER):
```
docker compose
├── db supabase/postgres ← posts-Tabelle (schema.sql)
├── auth gotrue ← Login
├── rest postgrest ← supabase-js liest/schreibt posts
├── kong :8000 ← API-Gateway (/auth/v1, /rest/v1)
└── cms :8080 Node + Hugo-Binary + Admin-SPA
├─ / live (public/)
├─ /_preview Vorschau (preview/, --buildDrafts)
├─ /admin React-Editor
└─ /api Backend (mountet Repo unter /site)
```
- **cms** hält das Hugo-Binary (0.161.1 extended, = lokal), mountet das Repo-Root
unter `/site`, serviert die Site selbst (kein separater nginx).
- Server-seitig spricht `cms` Supabase intern über `http://kong:8000` (Service-Key);
die Admin-SPA nutzt browser-seitig `API_EXTERNAL_URL` + `ANON_KEY`.
- **Abweichung von RAPPORT:** `realtime` + `storage` weggelassen (nutzt das CMS
nicht — Bild-Uploads gehen auf Platte nach `static/images/`). Nachrüstbar durch
Kopieren der Service-Blöcke aus RAPPORT-SERVER.
## Quelle der Wahrheit (DB-backed)
Die `posts`-Tabelle in Supabase ist kanonisch. `content/*.md` ist ein
**generiertes Artefakt** — nicht von Hand editieren, wird beim Publish
überschrieben. Drafts liegen als `draft: true` in `content/` und werden vom
Live-Build automatisch ausgelassen; nur der Preview-Build (`--buildDrafts`)
zeigt sie.
## Setup
### Schnellweg: Proxmox-LXC
`proxmox/create-openbureau-lxc.sh` auf dem Proxmox-Host ausführen — legt den LXC
an, installiert Docker, zieht das Repo, generiert alle Secrets und startet den
Stack. Details im Script-Kopf (Storage, Bridge, GIT_TOKEN für das private Repo).
### Manuell (oder im Container)
1. `cp .env.example .env`
2. `POSTGRES_PASSWORD` + `JWT_SECRET` setzen: je `openssl rand -hex 32`
3. Keys ableiten: `node scripts/generate-keys.mjs``ANON_KEY` + `SERVICE_ROLE_KEY` in `.env`
4. `SITE_URL` + `API_EXTERNAL_URL` auf die LAN-/Domain-Adresse setzen
5. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations)
6. Login-User anlegen (Self-Signup ist aus):
```
source .env
curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"du@example.ch","password":"…","email_confirm":true}'
```
Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/`
### Lokale Entwicklung am Admin
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
`/_preview` an den laufenden Container auf :8080).
## API
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
| Methode | Pfad | Zweck |
|---------|---------------------|----------------------------------------|
| GET | `/api/health` | Healthcheck (offen) |
| GET | `/api/posts` | Alle Posts listen |
| GET | `/api/posts/:id` | Einen Post |
| POST | `/api/posts` | Post anlegen (Draft) |
| PUT | `/api/posts/:id` | Post aktualisieren |
| POST | `/api/preview/:id` | Draft als `draft:true` schreiben + Preview-Build |
| POST | `/api/publish/:id` | Live schreiben + Public-Build + (opt.) git commit |
| POST | `/api/upload` | Bild → `static/images/`, liefert `/images/<name>` |
## Stand
- [x] api + Hugo-Binary, Ein-Container-Setup
- [x] Publish-Flow (DB → MD → `hugo` → live)
- [x] Echte Hugo-Vorschau (`--buildDrafts` → `/_preview`)
- [x] React-Admin (`admin/`) — Login, Editor, Frontmatter-Formular, iframe-Preview
- [x] Supabase-Auth-Middleware auf der API
- [x] Bild-Upload für `cover_image`
### Noch denkbar
- nginx davor für Caching/TLS (oder über deinen bestehenden Reverse-Proxy)
- Post löschen / Slug-Umbenennung (alte MD-Datei entfernen)
- Mehrbenutzer + Rollen, wenn ein zweiter Autor dazukommt
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPENBUREAU — CMS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1936
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "openbureau-cms-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.47.10",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useState } from 'react';
import { supabase } from './supabase.js';
import { api } from './api.js';
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
const LAYOUTS = ['', 'text', 'image'];
const EMPTY = {
section: 'software',
slug: '',
title: '',
date: new Date().toISOString().slice(0, 10),
weight: '',
tags: '',
summary: '',
cover_image: '',
layout: 'text',
external: '',
color: '',
body: '',
};
export default function App() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setLoading(false);
});
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
return () => sub.subscription.unsubscribe();
}, []);
if (loading) return <div className="center"></div>;
if (!session) return <Login />;
return <Dashboard email={session.user.email} />;
}
function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
async function submit(e) {
e.preventDefault();
setErr(null);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) setErr(error.message);
}
return (
<div className="center">
<form className="card login" onSubmit={submit}>
<h1>OPENBUREAU CMS</h1>
<input type="email" placeholder="E-Mail" value={email}
onChange={(e) => setEmail(e.target.value)} autoFocus />
<input type="password" placeholder="Passwort" value={password}
onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Einloggen</button>
{err && <p className="err">{err}</p>}
</form>
</div>
);
}
function Dashboard({ email }) {
const [posts, setPosts] = useState([]);
const [current, setCurrent] = useState(null); // post-Objekt oder null
const [msg, setMsg] = useState(null);
async function refresh() {
try {
setPosts(await api.listPosts());
} catch (e) {
setMsg({ type: 'err', text: e.message });
}
}
useEffect(() => { refresh(); }, []);
return (
<div className="app">
<header>
<strong>OPENBUREAU CMS</strong>
<span className="spacer" />
<span className="muted">{email}</span>
<button onClick={() => supabase.auth.signOut()}>Logout</button>
</header>
<div className="body">
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Post</button>
<ul className="list">
{posts.map((p) => (
<li key={p.id} className={current?.id === p.id ? 'active' : ''}
onClick={() => setCurrent(fromRow(p))}>
<span className={`dot ${p.status}`} />
<span className="t">{p.title || '(ohne Titel)'}</span>
<span className="s">{p.section}</span>
</li>
))}
</ul>
</aside>
<main>
{current
? <Editor key={current.id || 'new'} initial={current}
onSaved={(row) => { setCurrent(fromRow(row)); refresh(); }}
onMsg={setMsg} />
: <p className="muted pad">Wähle links einen Post oder leg einen neuen an.</p>}
</main>
</div>
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
</div>
);
}
function Editor({ initial, onSaved, onMsg }) {
const [post, setPost] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null);
const [busy, setBusy] = useState(false);
const set = (k) => (e) => setPost({ ...post, [k]: e.target.value });
async function uploadCover(e) {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
try {
const res = await api.upload(file);
setPost((p) => ({ ...p, cover_image: res.url }));
onMsg({ type: 'ok', text: `Hochgeladen: ${res.url}` });
} catch (err) {
onMsg({ type: 'err', text: err.message });
} finally {
setBusy(false);
}
}
async function save() {
setBusy(true);
try {
const payload = toRow(post);
const row = post.id
? await api.updatePost(post.id, payload)
: await api.createPost(payload);
onSaved(row);
onMsg({ type: 'ok', text: 'Gespeichert.' });
return row;
} catch (e) {
onMsg({ type: 'err', text: e.message });
} finally {
setBusy(false);
}
}
async function preview() {
const row = await save();
if (!row) return;
setBusy(true);
try {
const res = await api.preview(row.id);
// Cache-Buster, damit der frische Build geladen wird.
setPreviewUrl(`${res.url}?t=${Date.now()}`);
} catch (e) {
onMsg({ type: 'err', text: e.message });
} finally {
setBusy(false);
}
}
async function publish() {
const row = await save();
if (!row) return;
if (!confirm('Diesen Post live publizieren?')) return;
setBusy(true);
try {
const res = await api.publish(row.id);
onMsg({ type: 'ok', text: `Live: ${res.url}` });
} catch (e) {
onMsg({ type: 'err', text: e.message });
} finally {
setBusy(false);
}
}
return (
<div className="editor">
<div className="fields">
<div className="row">
<label>Titel<input value={post.title} onChange={set('title')} /></label>
<label className="sm">Section
<select value={post.section} onChange={set('section')}>
{SECTIONS.map((s) => <option key={s}>{s}</option>)}
</select>
</label>
</div>
<div className="row">
<label>Slug<input value={post.slug} onChange={set('slug')} placeholder="a-z0-9-" /></label>
<label className="sm">Datum<input type="date" value={post.date} onChange={set('date')} /></label>
<label className="xs">Weight<input type="number" value={post.weight} onChange={set('weight')} /></label>
</div>
<div className="row">
<label className="sm">Layout
<select value={post.layout} onChange={set('layout')}>
{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(default)'}</option>)}
</select>
</label>
<label className="sm">Color<input value={post.color} onChange={set('color')} placeholder="kusa / yuyake" /></label>
<label>Tags<input value={post.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
</div>
<label>Summary<input value={post.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild
<input value={post.cover_image} onChange={set('cover_image')} placeholder="/images/...jpg" />
</label>
<label className="sm">Hochladen
<input type="file" accept="image/*" onChange={uploadCover} disabled={busy} />
</label>
<label>Externer Link<input value={post.external} onChange={set('external')} placeholder="https://…" /></label>
</div>
<label className="grow">Inhalt (Markdown)
<textarea value={post.body} onChange={set('body')} spellCheck={false} />
</label>
<div className="actions">
<button onClick={save} disabled={busy}>Speichern</button>
<button onClick={preview} disabled={busy}>Vorschau</button>
<button className="primary" onClick={publish} disabled={busy}>Publizieren</button>
</div>
</div>
<div className="preview">
{previewUrl
? <iframe title="Vorschau" src={previewUrl} />
: <p className="muted pad">Vorschau erscheint hier nach Klick auf Vorschau".</p>}
</div>
</div>
);
}
// --- DB-Zeile <-> Formular ---
function fromRow(row) {
return {
...EMPTY, ...row,
weight: row.weight ?? '',
tags: Array.isArray(row.tags) ? row.tags.join(', ') : '',
date: row.date ? String(row.date).slice(0, 10) : EMPTY.date,
};
}
function toRow(post) {
return {
section: post.section,
slug: post.slug,
title: post.title,
date: post.date,
weight: post.weight === '' ? null : Number(post.weight),
tags: post.tags ? post.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
summary: post.summary || null,
cover_image: post.cover_image || null,
layout: post.layout || null,
external: post.external || null,
color: post.color || null,
body: post.body || '',
};
}
+43
View File
@@ -0,0 +1,43 @@
import { supabase } from './supabase.js';
// Ruft die CMS-API (gleiche Origin) mit dem aktuellen Supabase-Token auf.
async function call(path, options = {}) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
const res = await fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
// Datei-Upload: kein JSON, der Browser setzt den multipart-Header selbst.
async function uploadFile(file) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
export const api = {
listPosts: () => call('/posts'),
createPost: (post) => call('/posts', { method: 'POST', body: JSON.stringify(post) }),
updatePost: (id, post) => call(`/posts/${id}`, { method: 'PUT', body: JSON.stringify(post) }),
preview: (id) => call(`/preview/${id}`, { method: 'POST' }),
publish: (id) => call(`/publish/${id}`, { method: 'POST' }),
upload: uploadFile,
};
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+70
View File
@@ -0,0 +1,70 @@
:root {
--bg: #14110e;
--panel: #1d1a16;
--line: #322c25;
--text: #ece6dd;
--muted: #8a8078;
--accent: #c8543a;
--ok: #5a8a4a;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.5 -apple-system, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.center { display: grid; place-items: center; min-height: 100vh; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 28px; }
.login { display: flex; flex-direction: column; gap: 12px; width: 300px; }
.login h1 { font-size: 18px; margin: 0 0 8px; letter-spacing: .08em; }
input, select, textarea, button {
font: inherit; color: var(--text);
background: #110e0b; border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px;
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
button { background: #2a241e; cursor: pointer; }
button:hover { border-color: var(--accent); }
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.err { color: var(--accent); margin: 0; }
.app { display: flex; flex-direction: column; height: 100vh; }
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.pad { padding: 24px; }
.body { display: flex; flex: 1; min-height: 0; }
aside { width: 260px; border-right: 1px solid var(--line); padding: 12px; overflow: auto; }
.new { width: 100%; margin-bottom: 12px; }
.list { list-style: none; margin: 0; padding: 0; }
.list li { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; cursor: pointer; }
.list li:hover { background: #221d18; }
.list li.active { background: #2a241e; }
.list .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list .s { color: var(--muted); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.dot.published { background: var(--ok); }
.dot.draft { background: #b89030; }
main { flex: 1; min-width: 0; }
.editor { display: flex; height: 100%; }
.fields { width: 50%; padding: 16px; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 10px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); flex: 1; }
label.sm { flex: 0 0 140px; }
label.xs { flex: 0 0 90px; }
label.grow { flex: 1; }
label input, label select, label textarea { color: var(--text); }
textarea { flex: 1; min-height: 240px; resize: vertical; font-family: ui-monospace, monospace; }
.actions { display: flex; gap: 8px; }
.preview { width: 50%; border-left: 1px solid var(--line); }
.preview iframe { width: 100%; height: 100%; border: 0; background: #fff; }
.toast { position: fixed; bottom: 18px; right: 18px; padding: 10px 16px; border-radius: 8px; cursor: pointer; }
.toast.ok { background: var(--ok); }
.toast.err { background: var(--accent); }
+8
View File
@@ -0,0 +1,8 @@
import { createClient } from '@supabase/supabase-js';
// Öffentliche Browser-Werte (zur Build-Zeit von Vite eingesetzt). Der anon-Key
// ist per Design öffentlich; die echte Autorität liegt server-seitig.
const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(url, anonKey);
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// base /admin/ — die SPA wird vom CMS-Container unter /admin serviert.
export default defineConfig({
plugins: [react()],
base: '/admin/',
server: {
// Dev: API + /_preview vom laufenden Container durchreichen.
proxy: {
'/api': 'http://localhost:8080',
'/_preview': 'http://localhost:8080',
},
},
});
+41
View File
@@ -0,0 +1,41 @@
# --- Stage 1: Admin-SPA bauen ---
# (Build-Context ist cms/, siehe docker-compose.yml)
FROM node:24-bookworm-slim AS admin
WORKDIR /admin
COPY admin/package.json admin/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY admin/ ./
# Öffentliche Browser-Werte, zur Build-Zeit eingesetzt.
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
RUN npm run build
# --- Stage 2: API + Hugo + serviert Site/Admin ---
# Debian-slim statt Alpine: Hugo "extended" ist glibc-gelinkt.
FROM node:24-bookworm-slim
ARG HUGO_VERSION=0.161.1
ARG TARGETARCH=amd64
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git curl \
&& rm -rf /var/lib/apt/lists/* \
&& case "${TARGETARCH}" in \
arm64) HUGO_ARCH=linux-arm64 ;; \
*) HUGO_ARCH=linux-amd64 ;; \
esac \
&& curl -sSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin hugo \
&& hugo version
WORKDIR /app
COPY api/package.json api/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY api/src ./src
COPY --from=admin /admin/dist ./admin-dist
ENV NODE_ENV=production
ENV ADMIN_DIR=/app/admin-dist
EXPOSE 3000
CMD ["node", "src/index.js"]
+246
View File
@@ -0,0 +1,246 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openbureau-cms-api",
"version": "0.1.0",
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"gray-matter": "^4.0.3",
"hono": "^4.6.14"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.2",
"@supabase/functions-js": "2.106.2",
"@supabase/postgrest-js": "2.106.2",
"@supabase/realtime-js": "2.106.2",
"@supabase/storage-js": "2.106.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"gray-matter": "^4.0.3",
"hono": "^4.6.14"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { supabase } from './supabase.js';
// Verifiziert den Supabase-Access-Token aus dem Authorization-Header gegen den
// Supabase-Auth-Server. Schützt alle /api/* ausser /api/health.
export async function requireAuth(c, next) {
const header = c.req.header('Authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
const { data, error } = await supabase.auth.getUser(token);
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
c.set('user', data.user);
await next();
}
+37
View File
@@ -0,0 +1,37 @@
import { mkdir, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { rowToMarkdown } from './render.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
// Erlaubt nur sichere, einfache Segmente — verhindert Path-Traversal über
// section/slug aus der DB.
function safeSegment(value, label) {
if (!value || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(String(value))) {
throw new Error(`Ungültiger ${label}: ${JSON.stringify(value)}`);
}
return value;
}
// Posts leben unter content/library/<section>/<slug>.md → URL /library/<section>/<slug>/.
const CONTENT_BASE = 'library';
export function postPath(post) {
const section = safeSegment(post.section, 'section');
const slug = safeSegment(post.slug, 'slug');
return path.join(SITE_DIR, 'content', CONTENT_BASE, section, `${slug}.md`);
}
// Schreibt die generierte MD nach content/<section>/<slug>.md.
// draft:true -> Live-Build (ohne --buildDrafts) lässt den Post aus.
// draft:false -> Post ist live.
export async function writePostFile(post, { draft = false } = {}) {
const file = postPath(post);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, rowToMarkdown(post, { draft }), 'utf8');
return file;
}
export async function removePostFile(post) {
await rm(postPath(post), { force: true });
}
+41
View File
@@ -0,0 +1,41 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileP = promisify(execFile);
const SITE_DIR = process.env.SITE_DIR || '/site';
// Baut die Site. dest ist relativ zum Repo-Root (z.B. "public" oder "preview").
// drafts:true => --buildDrafts (für die Vorschau).
export async function hugoBuild({ dest, drafts = false } = {}) {
const args = ['--source', SITE_DIR, '--destination', dest, '--cleanDestinationDir'];
if (drafts) args.push('--buildDrafts');
const { stdout, stderr } = await execFileP('hugo', args, {
cwd: SITE_DIR,
maxBuffer: 10 * 1024 * 1024,
});
return { stdout, stderr };
}
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
// das Publish soll an einem Git-Problem nicht scheitern.
export async function gitCommit(message) {
if (process.env.GIT_PUBLISH !== 'true') return { skipped: true };
const env = {
...process.env,
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
GIT_COMMITTER_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
GIT_COMMITTER_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
};
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { env });
await git('add', 'content');
// Nichts zu committen? Dann ruhig raus.
const status = await git('status', '--porcelain', 'content');
if (!status.stdout.trim()) return { nothing: true };
await git('commit', '-m', message);
await git('push', process.env.GIT_REMOTE || 'origin', process.env.GIT_BRANCH || 'main');
return { committed: true };
}
+50
View File
@@ -0,0 +1,50 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import posts from './routes/posts.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
import upload from './routes/upload.js';
import { requireAuth } from './auth.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
const PORT = Number(process.env.PORT || 3000);
const app = new Hono();
// --- API ---
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth);
app.route('/api/posts', posts);
app.route('/api/preview', preview);
app.route('/api/publish', publish);
app.route('/api/upload', upload);
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
app.use(
'/admin/*',
serveStatic({
root: ADMIN_DIR,
rewriteRequestPath: (p) => p.replace(/^\/admin/, '') || '/',
}),
);
// --- Vorschau (gebaut nach preview/ mit --buildDrafts) ---
app.use(
'/_preview/*',
serveStatic({
root: `${SITE_DIR}/preview`,
rewriteRequestPath: (p) => p.replace(/^\/_preview/, ''),
}),
);
// --- Live-Site (gebaut nach public/) ---
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
});
+30
View File
@@ -0,0 +1,30 @@
import matter from 'gray-matter';
// Eine posts-Zeile aus Supabase -> Hugo-Markdown (Frontmatter + Body).
// Mappt exakt die Felder, die OPENBUREAU im content/ nutzt. Nur gesetzte
// Felder landen im Frontmatter, damit die MD sauber bleibt.
export function rowToMarkdown(post, { draft = false } = {}) {
const fm = {
title: post.title,
// date als reines YYYY-MM-DD ausgeben (wie in den bestehenden Posts).
date: toDateOnly(post.date),
};
if (post.weight != null) fm.weight = post.weight;
if (Array.isArray(post.tags) && post.tags.length) fm.tags = post.tags;
if (post.summary) fm.summary = post.summary;
if (post.cover_image) fm.cover_image = post.cover_image;
if (post.layout) fm.layout = post.layout;
if (post.external) fm.external = post.external;
if (post.color) fm.color = post.color;
if (draft) fm.draft = true;
return matter.stringify(post.body || '', fm);
}
function toDateOnly(d) {
if (!d) return undefined;
// Akzeptiert Date, ISO-String oder "YYYY-MM-DD".
const s = typeof d === 'string' ? d : new Date(d).toISOString();
return s.slice(0, 10);
}
+50
View File
@@ -0,0 +1,50 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
// Minimales CRUD, damit der Publish/Preview-Flow ohne UI testbar ist.
// (Auth-Middleware kommt im nächsten Meilenstein.)
const posts = new Hono();
posts.get('/', async (c) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('date', { ascending: false });
if (error) return c.json({ error: error.message }, 500);
return c.json(data);
});
posts.get('/:id', async (c) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', c.req.param('id'))
.single();
if (error) return c.json({ error: error.message }, 404);
return c.json(data);
});
posts.post('/', async (c) => {
const body = await c.req.json();
const { data, error } = await supabase
.from('posts')
.insert({ ...body, status: 'draft' })
.select()
.single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data, 201);
});
posts.put('/:id', async (c) => {
const body = await c.req.json();
const { data, error } = await supabase
.from('posts')
.update({ ...body, updated_at: new Date().toISOString() })
.eq('id', c.req.param('id'))
.select()
.single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data);
});
export default posts;
+27
View File
@@ -0,0 +1,27 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { writePostFile } from '../content.js';
import { hugoBuild } from '../hugo.js';
// Echte Hugo-Vorschau: Post als draft:true in content/ schreiben und mit
// --buildDrafts nach preview/ bauen. Der Live-Build (public/) lässt den
// Draft weiterhin aus.
const preview = new Hono();
preview.post('/:id', async (c) => {
const id = c.req.param('id');
const { data: post, error } = await supabase
.from('posts').select('*').eq('id', id).single();
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
try {
await writePostFile(post, { draft: true });
const build = await hugoBuild({ dest: 'preview', drafts: true });
const url = `/_preview/library/${post.section}/${post.slug}/`;
return c.json({ ok: true, url, hugo: build.stdout });
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default preview;
+41
View File
@@ -0,0 +1,41 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { writePostFile } from '../content.js';
import { hugoBuild, gitCommit } from '../hugo.js';
// Publizieren: Post als live (draft:false) nach content/ schreiben, public/
// neu bauen, Status setzen und optional nach Gitea committen.
const publish = new Hono();
publish.post('/:id', async (c) => {
const id = c.req.param('id');
const { data: post, error } = await supabase
.from('posts').select('*').eq('id', id).single();
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
try {
const file = await writePostFile(post, { draft: false });
const build = await hugoBuild({ dest: 'public', drafts: false });
const { error: upErr } = await supabase
.from('posts')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', id);
if (upErr) return c.json({ error: upErr.message }, 500);
const git = await gitCommit(`cms: publish ${post.section}/${post.slug}`)
.catch((e) => ({ error: String(e.message || e) }));
return c.json({
ok: true,
path: file.replace(process.env.SITE_DIR || '/site', '').replace(/^\//, ''),
url: `/library/${post.section}/${post.slug}/`,
git,
hugo: build.stdout,
});
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default publish;
+34
View File
@@ -0,0 +1,34 @@
import { Hono } from 'hono';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const SITE_DIR = process.env.SITE_DIR || '/site';
// Bild-Upload → static/images/<name>. Hugo kopiert das beim Build nach
// public/images/, cover_image referenziert es als /images/<name>.
const upload = new Hono();
upload.post('/', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
const name = safeName(file.name);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, name), Buffer.from(await file.arrayBuffer()));
return c.json({ url: `/images/${name}` });
});
// Sicherer Dateiname: nur basename, kleingeschrieben, ohne Pfad/Sonderzeichen.
function safeName(raw) {
const base = path.basename(String(raw || 'bild'));
const cleaned = base
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned || 'bild';
}
export default upload;
+14
View File
@@ -0,0 +1,14 @@
import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY;
if (!url || !key) {
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
process.exit(1);
}
// Service-Role-Key: server-seitig, umgeht RLS. Niemals ins Frontend geben.
export const supabase = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
+33
View File
@@ -0,0 +1,33 @@
-- OPENBUREAU CMS — posts-Tabelle. In den Supabase-Stack einspielen
-- (SQL-Editor oder psql). Spalten bilden das Hugo-Frontmatter ab.
create extension if not exists "pgcrypto";
create table if not exists public.posts (
id uuid primary key default gen_random_uuid(),
section text not null, -- buerofuehrung | software | theorie
slug text not null, -- a-z0-9- (Dateiname ohne .md)
title text not null,
date date not null default current_date,
weight int,
tags text[] default '{}',
summary text,
cover_image text,
layout text, -- z.B. "image" | "text"
external text, -- externer Link (wie RAPPORT)
color text, -- z.B. "kusa" | "yuyake"
body text default '', -- Markdown-Inhalt
status text not null default 'draft', -- draft | published
author text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
published_at timestamptz,
unique (section, slug)
);
create index if not exists posts_status_idx on public.posts (status);
create index if not exists posts_section_idx on public.posts (section);
-- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das
-- Frontend später direkt liest, hier gezielte Policies ergänzen.
alter table public.posts enable row level security;
+147
View File
@@ -0,0 +1,147 @@
# OPENBUREAU — Komplettstack für Self-Hosting (Muster gespiegelt von RAPPORT-SERVER).
# ALLES in einem Stack / einem LXC: Supabase-Kern + CMS.
#
# Vor `docker compose up`:
# 1. cp .env.example .env
# 2. JWT_SECRET + POSTGRES_PASSWORD setzen (openssl rand -hex 32)
# 3. node scripts/generate-keys.mjs → ANON_KEY + SERVICE_ROLE_KEY in .env
# 4. SITE_URL + API_EXTERNAL_URL auf die LAN-/Domain-Adresse setzen
#
# Dann: docker compose up -d --build
#
# Abweichung von RAPPORT: realtime + storage weggelassen (nutzt das CMS nicht).
# Nachrüsten = die beiden Service-Blöcke aus RAPPORT-SERVER hier einfügen.
services:
# ════════════════════════════════════════════════════════════════════════
# Postgres — Datenbank mit Supabase-Extensions
# ════════════════════════════════════════════════════════════════════════
db:
image: supabase/postgres:15.8.1.020
container_name: openbureau-db
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postgres
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: 3600
volumes:
- postgres-data:/var/lib/postgresql/data
# OPENBUREAU-Schema (posts-Tabelle) wird vom Post-Init eingespielt.
- ./db/schema.sql:/openbureau-schema.sql:ro
# Läuft als LETZTES Init-Script (zz-) — nach den supabase-internen.
- ./volumes/db/init/zz-openbureau-post-init.sh:/docker-entrypoint-initdb.d/zz-openbureau-post-init.sh:ro
ports:
# Nur localhost — Zugriff über Kong / interne Container-Kommunikation.
- "127.0.0.1:${DB_PORT:-5432}:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "supabase_admin", "-d", "postgres"]
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
# ════════════════════════════════════════════════════════════════════════
# GoTrue — Auth (Login für das CMS)
# ════════════════════════════════════════════════════════════════════════
auth:
image: supabase/gotrue:v2.158.1
container_name: openbureau-auth
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${SITE_URL},${SITE_URL}/
# Single-Author: Self-Signup aus. User wird per Admin-API angelegt
# (Kommando steht im README / LXC-Output).
GOTRUE_DISABLE_SIGNUP: "true"
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: 3600
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_MAILER_AUTOCONFIRM: "true"
# ════════════════════════════════════════════════════════════════════════
# PostgREST — REST-API auf der DB (supabase-js spricht hierüber mit posts)
# ════════════════════════════════════════════════════════════════════════
rest:
image: postgrest/postgrest:v12.2.0
container_name: openbureau-rest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres
PGRST_DB_SCHEMAS: public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
# ════════════════════════════════════════════════════════════════════════
# Kong — API-Gateway: bündelt /auth/v1 + /rest/v1 unter einer URL
# ════════════════════════════════════════════════════════════════════════
kong:
image: kong:2.8.1
container_name: openbureau-kong
restart: unless-stopped
depends_on:
- auth
- rest
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
volumes:
- ./kong.yml:/var/lib/kong/kong.yml:ro
ports:
- "${KONG_HTTP_PORT:-8000}:8000"
- "${KONG_HTTPS_PORT:-8443}:8443"
# ════════════════════════════════════════════════════════════════════════
# CMS — Node-API + Hugo-Binary + Admin-SPA, serviert die Site
# ════════════════════════════════════════════════════════════════════════
cms:
build:
context: .
dockerfile: api/Dockerfile
args:
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
VITE_SUPABASE_URL: ${API_EXTERNAL_URL}
VITE_SUPABASE_ANON_KEY: ${ANON_KEY}
container_name: openbureau-cms
restart: unless-stopped
depends_on:
db:
condition: service_healthy
kong:
condition: service_started
environment:
# Server-seitig: intern über Kong, mit Service-Key.
SUPABASE_URL: http://kong:8000
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
SITE_DIR: /site
PORT: 3000
GIT_PUBLISH: ${GIT_PUBLISH:-false}
GIT_REMOTE: ${GIT_REMOTE:-origin}
GIT_BRANCH: ${GIT_BRANCH:-main}
GIT_AUTHOR_NAME: ${GIT_AUTHOR_NAME:-OPENBUREAU CMS}
GIT_AUTHOR_EMAIL: ${GIT_AUTHOR_EMAIL:-cms@openbureau.ch}
volumes:
# Repo-Root: api schreibt content/ und baut public/ + preview/.
- ..:/site
ports:
- "${APP_PORT:-8080}:3000"
volumes:
postgres-data:
+27
View File
@@ -0,0 +1,27 @@
# Kong-Konfiguration für den OPENBUREAU-Stack (gespiegelt von RAPPORT-SERVER).
# Routet die genutzten Supabase-API-Pfade durch eine URL, damit Browser + api
# nur eine Adresse kennen. (realtime/storage weggelassen — siehe compose.)
_format_version: "2.1"
_transform: true
services:
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-route
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-route
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
#
# OPENBUREAU — All-in-One LXC für Proxmox VE (Supabase-Kern + CMS).
#
# AUSFÜHREN AUF DEM PROXMOX-HOST (nicht im Container), als root:
# bash create-openbureau-lxc.sh
#
# Legt einen unprivileged Debian-12-LXC an (Docker-fähig: nesting + keyctl),
# installiert Docker, zieht das Repo, generiert alle Secrets (POSTGRES_PASSWORD,
# JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY) und befüllt die .env. Optional baut
# und startet es den Stack direkt.
set -euo pipefail
############################ CONFIG ############################
CTID="${CTID:-$(pvesh get /cluster/nextid)}"
HOSTNAME="openbureau"
# Storage
TEMPLATE_STORAGE="local"
ROOTFS_STORAGE="local-lvm"
DISK_GB="20" # Supabase + CMS
# Ressourcen
RAM_MB="4096"
SWAP_MB="1024"
CORES="2"
# Netzwerk
BRIDGE="vmbr0"
IP="dhcp" # "dhcp" ODER statisch z.B. "192.168.1.50/24"
GATEWAY="" # nur bei statischer IP
# Zugang
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
ROOT_PASSWORD=""
# Repo (privates Gitea!). Für den Clone im frischen LXC ein Deploy-Token setzen:
GIT_TOKEN="${GIT_TOKEN:-}" # z.B. "tokenname:tokenwert"
REPO_HOST="git.kgva.ch/karim/OPENBUREAU.git"
APP_DIR="/opt/openbureau"
# Stack nach dem Setup direkt bauen + starten?
COMPOSE_UP="true"
##################################################################
say() { echo -e "\n\033[1;36m▸ $*\033[0m"; }
# --- 1. Template sicherstellen -------------------------------------------
say "Suche aktuelles Debian-12-Template…"
pveam update >/dev/null || true
TEMPLATE="$(pveam available --section system \
| awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
[ -n "$TEMPLATE" ] || { echo "Kein debian-12-Template gefunden."; exit 1; }
if ! pveam list "$TEMPLATE_STORAGE" | grep -q "$TEMPLATE"; then
say "Lade Template $TEMPLATE"
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
fi
TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}"
# --- 2. Netzwerk ----------------------------------------------------------
if [ "$IP" = "dhcp" ]; then
NET="name=eth0,bridge=${BRIDGE},ip=dhcp"
else
[ -n "$GATEWAY" ] || { echo "Statische IP, aber GATEWAY leer."; exit 1; }
NET="name=eth0,bridge=${BRIDGE},ip=${IP},gw=${GATEWAY}"
fi
# --- 3. Container erstellen ----------------------------------------------
say "Erstelle LXC $CTID ($HOSTNAME)…"
CREATE_ARGS=(
"$CTID" "$TEMPLATE_REF"
--hostname "$HOSTNAME"
--cores "$CORES" --memory "$RAM_MB" --swap "$SWAP_MB"
--rootfs "${ROOTFS_STORAGE}:${DISK_GB}"
--net0 "$NET"
--unprivileged 1
--features "nesting=1,keyctl=1"
--onboot 1
)
[ -n "$ROOT_PASSWORD" ] && CREATE_ARGS+=(--password "$ROOT_PASSWORD")
[ -f "$SSH_PUBKEY_FILE" ] && CREATE_ARGS+=(--ssh-public-keys "$SSH_PUBKEY_FILE")
pct create "${CREATE_ARGS[@]}"
say "Starte Container…"
pct start "$CTID"
sleep 5
# Clone-URL (mit Token, falls gesetzt)
if [ -n "$GIT_TOKEN" ]; then
REPO_URL="https://${GIT_TOKEN}@${REPO_HOST}"
else
REPO_URL="https://${REPO_HOST}"
fi
# --- 4. Provisionierung im Container -------------------------------------
say "Installiere Docker + Git, ziehe Repo, generiere Secrets…"
pct exec "$CTID" -- bash -euo pipefail -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl git openssl >/dev/null
curl -fsSL https://get.docker.com | sh >/dev/null
systemctl enable --now docker
if [ ! -d '${APP_DIR}/.git' ]; then
git clone --quiet '${REPO_URL}' '${APP_DIR}' || {
echo 'WARN: Clone fehlgeschlagen (Token nötig?). Setup hier gestoppt.'; exit 0; }
fi
cd '${APP_DIR}/cms'
if [ ! -f .env ]; then
cp .env.example .env
# Secrets generieren
PW=\$(openssl rand -hex 32)
JWT=\$(openssl rand -hex 32)
sed -i \"s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=\${PW}|\" .env
sed -i \"s|^JWT_SECRET=.*|JWT_SECRET=\${JWT}|\" .env
# ANON_KEY + SERVICE_ROLE_KEY via Wegwerf-Node-Container ableiten
KEYS=\$(docker run --rm -v \"\$PWD\":/w -w /w node:20-alpine \
node scripts/generate-keys.mjs \"\$JWT\" 2>/dev/null)
ANON=\$(echo \"\$KEYS\" | sed -n 's/^ANON_KEY=//p')
SVC=\$(echo \"\$KEYS\" | sed -n 's/^SERVICE_ROLE_KEY=//p')
sed -i \"s|^ANON_KEY=.*|ANON_KEY=\${ANON}|\" .env
sed -i \"s|^SERVICE_ROLE_KEY=.*|SERVICE_ROLE_KEY=\${SVC}|\" .env
# URLs auf die Container-IP setzen
HOSTIP=\$(hostname -I | awk '{print \$1}')
sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env
echo 'OK: .env generiert.'
fi
if [ '${COMPOSE_UP}' = 'true' ]; then
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
docker compose up -d --build
fi
"
# --- 5. Abschluss --------------------------------------------------------
IPADDR="$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')"
say "Fertig. LXC $CTID läuft${IPADDR:+ unter $IPADDR}."
cat <<EOF
Admin: http://${IPADDR:-<ip>}:8080/admin/
Live: http://${IPADDR:-<ip>}:8080/
Supabase: http://${IPADDR:-<ip>}:8000
Login-User anlegen (im Container, nach dem Start):
pct enter ${CTID}
cd ${APP_DIR}/cms
source .env
curl -s -X POST "http://localhost:8000/auth/v1/admin/users" \\
-H "apikey: \$SERVICE_ROLE_KEY" \\
-H "Authorization: Bearer \$SERVICE_ROLE_KEY" \\
-H "Content-Type: application/json" \\
-d '{"email":"karim@gabrielevarano.ch","password":"DEIN-PASSWORT","email_confirm":true}'
Hinweise:
• Privates Repo: GIT_TOKEN oben setzen (Format "tokenname:tokenwert"),
sonst schlägt der Clone fehl.
• Für Domain/HTTPS: SITE_URL + API_EXTERNAL_URL in .env auf die
öffentliche Adresse setzen und 'docker compose up -d --build' neu.
• Logs: pct enter ${CTID}; cd ${APP_DIR}/cms; docker compose logs -f
EOF
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
// Generiert ANON_KEY und SERVICE_ROLE_KEY (JWTs, HS256) aus dem JWT_SECRET
// in der .env. Ohne diese Keys können GoTrue und PostgREST nicht reden.
// (Gespiegelt von RAPPORT-SERVER — generisch, nicht app-spezifisch.)
//
// Aufruf:
// node scripts/generate-keys.mjs # liest JWT_SECRET aus .env
// node scripts/generate-keys.mjs <secret> # explizit übergeben
import crypto from "node:crypto";
import fs from "node:fs";
function base64url(input) {
return Buffer.from(input).toString("base64")
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function signJwt(payload, secret) {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = base64url(JSON.stringify(payload));
const sig = base64url(crypto.createHmac("sha256", secret).update(`${header}.${body}`).digest());
return `${header}.${body}.${sig}`;
}
let secret = process.argv[2];
if (!secret) {
try {
const env = fs.readFileSync(".env", "utf8");
const m = env.match(/^JWT_SECRET=(.+)$/m);
if (m) secret = m[1].trim().replace(/^["']|["']$/g, "");
} catch {}
}
if (!secret || secret.length < 32 || secret.includes("CHANGE-ME")) {
console.error("✗ Kein gültiges JWT_SECRET gefunden (mind. 32 Zeichen, nicht der Placeholder).");
console.error(" Setze JWT_SECRET in .env oder gib es als Argument:");
console.error(" node scripts/generate-keys.mjs $(openssl rand -hex 32)");
process.exit(1);
}
const now = Math.floor(Date.now() / 1000);
const tenYears = now + 10 * 365 * 24 * 3600;
const anonKey = signJwt({ role: "anon", iss: "supabase", iat: now, exp: tenYears }, secret);
const serviceKey = signJwt({ role: "service_role", iss: "supabase", iat: now, exp: tenYears }, secret);
console.log("ANON_KEY=" + anonKey);
console.log("SERVICE_ROLE_KEY=" + serviceKey);
console.error("");
console.error("→ Werte in .env eintragen (überschreibt CHANGE-ME-Placeholder).");
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Läuft als LETZTES File in /docker-entrypoint-initdb.d/ — nach den
# supabase-internen Init-Scripts (auth-schema, postgrest-roles, …).
# (Muster gespiegelt von RAPPORT-SERVER.)
#
# 1. Gleicht die Service-Rollen-Passwörter an POSTGRES_PASSWORD an
# (sonst SASL/MD5-Auth-Fehler bei GoTrue/PostgREST).
# 2. Spielt das OPENBUREAU-Schema (posts-Tabelle) ein.
set -euo pipefail
echo "→ Setze Passwörter für Supabase-Service-Rollen auf POSTGRES_PASSWORD…"
psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d "${POSTGRES_DB:-postgres}" <<EOF
alter user authenticator with password '${POSTGRES_PASSWORD}';
alter user supabase_auth_admin with password '${POSTGRES_PASSWORD}';
alter user supabase_storage_admin with password '${POSTGRES_PASSWORD}';
alter user supabase_replication_admin with password '${POSTGRES_PASSWORD}';
alter user supabase_read_only_user with password '${POSTGRES_PASSWORD}';
EOF
echo "✓ Service-Rollen-Passwörter aktualisiert."
SCHEMA=/openbureau-schema.sql
if [ -f "$SCHEMA" ]; then
echo "→ Spiele OPENBUREAU-Schema ein ($SCHEMA)…"
psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d "${POSTGRES_DB:-postgres}" -f "$SCHEMA"
# PostgREST braucht Leserechte für die anon/authenticated-Rollen.
psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d "${POSTGRES_DB:-postgres}" <<'EOF'
grant usage on schema public to anon, authenticated, service_role;
grant all on all tables in schema public to anon, authenticated, service_role;
grant all on all sequences in schema public to anon, authenticated, service_role;
alter default privileges in schema public
grant all on tables to anon, authenticated, service_role;
EOF
echo "✓ OPENBUREAU-Schema bereit."
else
echo "$SCHEMA fehlt — Schema NICHT eingespielt."
fi
@@ -3,6 +3,7 @@ title: "Warum ein offenes Büro"
date: 2026-05-22 date: 2026-05-22
tags: ["büroführung", "praxis", "open-source"] tags: ["büroführung", "praxis", "open-source"]
summary: "Ein offenes Architekturbüro ist nicht karitativ — es ist praktischer, robuster, ehrlicher." summary: "Ein offenes Architekturbüro ist nicht karitativ — es ist praktischer, robuster, ehrlicher."
color: sakura
--- ---
Architektur ist traditionell ein geschlossener Beruf. Wettbewerbe sind nicht-öffentlich, Verträge nicht-verhandelbar, Werkzeuge proprietär. Wissen wird gehortet, weil es als Wettbewerbsvorteil verstanden wird. Architektur ist traditionell ein geschlossener Beruf. Wettbewerbe sind nicht-öffentlich, Verträge nicht-verhandelbar, Werkzeuge proprietär. Wissen wird gehortet, weil es als Wettbewerbsvorteil verstanden wird.
+3
View File
@@ -5,6 +5,9 @@ weight: 10
tags: ["software", "eigene-werkzeuge"] tags: ["software", "eigene-werkzeuge"]
summary: "Projektorganisation für ein Architekturbüro — von der Anfrage zur Übergabe." summary: "Projektorganisation für ein Architekturbüro — von der Anfrage zur Übergabe."
external: "https://dossier.gabrielevarano.ch" external: "https://dossier.gabrielevarano.ch"
color: kori
cover_image: /images/tokyo-metro.jpg
image_mode: icon
--- ---
**DOSSIER** ist die Projektverwaltung von OPENBUREAU. **DOSSIER** ist die Projektverwaltung von OPENBUREAU.
@@ -3,6 +3,7 @@ title: "Warum wir eigene Werkzeuge bauen"
date: 2026-05-21 date: 2026-05-21
tags: ["software", "praxis", "tools"] tags: ["software", "praxis", "tools"]
summary: "DOSSIER und RAPPORT sind keine Lifestyle-Projekte — sie sind Reaktion auf konkrete Lücken." summary: "DOSSIER und RAPPORT sind keine Lifestyle-Projekte — sie sind Reaktion auf konkrete Lücken."
color: yuyake
--- ---
Wir benutzen viel fremde Software. Aber an zwei Stellen kam keiner der existierenden Werkzeuge an die Praxis heran, die wir brauchten — also haben wir selbst gebaut. Wir benutzen viel fremde Software. Aber an zwei Stellen kam keiner der existierenden Werkzeuge an die Praxis heran, die wir brauchten — also haben wir selbst gebaut.
+1
View File
@@ -5,6 +5,7 @@ weight: 20
tags: ["software", "eigene-werkzeuge"] tags: ["software", "eigene-werkzeuge"]
summary: "Zeit- und Aufwandserfassung für ein Architekturbüro." summary: "Zeit- und Aufwandserfassung für ein Architekturbüro."
external: "https://rapport.gabrielevarano.ch" external: "https://rapport.gabrielevarano.ch"
color: kusa
--- ---
**RAPPORT** erfasst Stunden, Wege und Aufwand. **RAPPORT** erfasst Stunden, Wege und Aufwand.
+1
View File
@@ -4,6 +4,7 @@ date: 2026-05-23
weight: 30 weight: 30
tags: ["software", "stack", "empfehlungen"] tags: ["software", "stack", "empfehlungen"]
summary: "Programme und Dienste, die wir Tag für Tag benutzen — kuratiert und kommentiert." summary: "Programme und Dienste, die wir Tag für Tag benutzen — kuratiert und kommentiert."
cover_image: /images/tokyo-metro.jpg
--- ---
Diese Liste wächst. Sie ist kein Inventar, sondern eine **Position**: jedes Werkzeug steht für eine Entscheidung über die Arbeitsweise des Büros. Diese Liste wächst. Sie ist kein Inventar, sondern eine **Position**: jedes Werkzeug steht für eine Entscheidung über die Arbeitsweise des Büros.
@@ -3,6 +3,7 @@ title: "Typologie als Werkzeug"
date: 2026-05-20 date: 2026-05-20
tags: ["theorie", "typologie", "methode"] tags: ["theorie", "typologie", "methode"]
summary: "Erste Notiz über Typologie nicht als Katalog, sondern als operatives Werkzeug im Entwurf." summary: "Erste Notiz über Typologie nicht als Katalog, sondern als operatives Werkzeug im Entwurf."
color: ajisai
--- ---
Typologie wird oft als Inhaltsverzeichnis missverstanden: eine Liste von Grundrissen, sortiert nach Funktion. Diese Lesart ist bequem und falsch. Typologie wird oft als Inhaltsverzeichnis missverstanden: eine Liste von Grundrissen, sortiert nach Funktion. Diese Lesart ist bequem und falsch.
+24 -9
View File
@@ -1,20 +1,28 @@
{{ define "main" }} {{ define "main" }}
{{ partial "terms.html" (dict "taxonomy" "tags" "page" .) }} {{ $section := "" }}
{{ with .Parent }}{{ $section = path.Base .RelPermalink }}{{ end }}
{{ $cover := .Params.cover_image }}
{{ $color := .Params.color }}
<article class="single"> {{/* Cover image first (full-bleed, directly under the masthead) */}}
{{ if $cover }}
<img class="single-hero-image" src="{{ $cover | relURL }}" alt="" loading="eager" />
{{ end }}
<article class="single" data-section="{{ $section }}"{{ with $color }} data-color="{{ . }}"{{ end }}>
<header class="single-header"> <header class="single-header">
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
{{ with .Params.summary }}
<p class="single-summary">{{ . }}</p>
{{ end }}
{{ $author := .Params.author | default site.Params.author.name }} {{ $author := .Params.author | default site.Params.author.name }}
{{ if or $author .Date }} {{ if or $author .Date }}
<p class="single-byline"> <p class="single-byline">
{{ with $author }}<span class="byline-author">von {{ . }}</span>{{ end }} {{- with $author -}}<span class="byline-author">{{ . }}</span>{{- end -}}
{{ if and $author .Date }} · {{ end }} {{- if and $author .Date -}}, {{ end -}}
{{ if .Date }}<time class="byline-date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>{{ end }} {{- if .Date -}}<time class="byline-date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>{{- end -}}
</p> </p>
{{ end }} {{ end }}
{{ with .Params.summary }}
<p class="single-summary text-muted">{{ . }}</p>
{{ end }}
</header> </header>
{{/* Table of Contents */}} {{/* Table of Contents */}}
@@ -31,13 +39,20 @@
{{ .Content }} {{ .Content }}
</div> </div>
{{/* Tags as small pills at the bottom — Republik-style, no hash symbol */}}
{{- with .Params.tags }}
<ul class="tag-pills" aria-label="Tags">
{{- range . -}}<li><a href="/tags/{{ . | urlize }}/">{{ . }}</a></li>{{- end -}}
</ul>
{{- end }}
{{ $showReadingTime := .Params.showreadingtime | default site.Params.showreadingtime | default true }} {{ $showReadingTime := .Params.showreadingtime | default site.Params.showreadingtime | default true }}
{{ $showLastMod := .Params.showlastmod | default site.Params.showlastmod | default false }} {{ $showLastMod := .Params.showlastmod | default site.Params.showlastmod | default false }}
{{ $hasLastmod := and $showLastMod .Lastmod (ne (.Lastmod.Format "2006-01-02") (.Date.Format "2006-01-02")) }} {{ $hasLastmod := and $showLastMod .Lastmod (ne (.Lastmod.Format "2006-01-02") (.Date.Format "2006-01-02")) }}
{{ if or (and $showReadingTime .ReadingTime) $hasLastmod }} {{ if or (and $showReadingTime .ReadingTime) $hasLastmod }}
<div class="time"> <div class="time">
{{ if and $showReadingTime .ReadingTime }}<span class="reading-time">{{ .ReadingTime }} min Lesezeit</span>{{ end }} {{ if and $showReadingTime .ReadingTime }}<span class="reading-time">{{ .ReadingTime }} min Lesezeit</span>{{ end }}
{{ if $hasLastmod }}{{ if and $showReadingTime .ReadingTime }} · {{ end }}<span class="lastmod">Zuletzt aktualisiert: {{ .Lastmod.Format "2006-01-02" }}</span>{{ end }} {{ if $hasLastmod }}{{ if and $showReadingTime .ReadingTime }} · {{ end }}<span class="lastmod">Zuletzt aktualisiert: {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
</div> </div>
{{ end }} {{ end }}
</article> </article>
+1
View File
@@ -0,0 +1 @@
<time datetime="{{ . | time.Format "2006-01-02" }}">{{ . | time.Format "02.01.2006" }}</time>
+33 -15
View File
@@ -12,26 +12,44 @@
<ol class="journal-list"> <ol class="journal-list">
{{ range $journal }} {{ range $journal }}
<li class="journal-entry"> {{ $section := "" }}
{{ $author := .Params.author | default site.Params.author.name }} {{ with .Parent }}{{ $section = path.Base .RelPermalink }}{{ end }}
{{ $author := .Params.author | default site.Params.author.name }}
{{ $cover := .Params.cover_image }}
{{ $isHero := eq . (index $journal 0) }}
{{ $imageMode := .Params.image_mode | default "wide" }}
<li class="journal-entry{{ if $isHero }} journal-entry--hero{{ end }}{{ if $cover }} journal-entry--has-image{{ end }}"
data-section="{{ $section }}"
{{ with .Params.color }}data-color="{{ . }}"{{ end }}
{{ if and $cover (not $isHero) }}data-image="{{ $imageMode }}"{{ end }}>
<a class="journal-entry-link" href="{{ .RelPermalink }}"> <a class="journal-entry-link" href="{{ .RelPermalink }}">
<div class="journal-meta"> {{ if and $isHero $cover }}
<img class="journal-hero-image" src="{{ $cover | relURL }}" alt="" loading="eager" />
{{ else if $cover }}
<img class="journal-card-image" src="{{ $cover | relURL }}" alt="" loading="lazy" />
{{ end }}
<div class="journal-entry-body">
{{ with .Parent }} {{ with .Parent }}
<span class="journal-section">{{ .Title }}</span> <p class="journal-rubric"><span class="journal-section">{{ .Title }}</span></p>
{{ end }} {{ end }}
<time class="journal-date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time> <h3 class="journal-title">{{ .LinkTitle }}</h3>
{{ with $author }}<span class="journal-author">{{ . }}</span>{{ end }} {{ with .Params.summary }}
<p class="journal-summary">{{ . }}</p>
{{ end }}
<p class="journal-byline">
{{- with $author -}}<span class="journal-author">{{ . }}</span>{{- end -}}
{{- if and $author .Date -}}, {{ end -}}
<time class="journal-date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>
</p>
</div> </div>
<h3 class="journal-title">{{ .LinkTitle }}</h3>
{{ with .Params.summary }}
<p class="journal-summary">{{ . }}</p>
{{ end }}
{{ with .Params.tags }}
<ul class="journal-tags">
{{ range . }}<li>#{{ . }}</li>{{ end }}
</ul>
{{ end }}
</a> </a>
{{/* Tags rendered OUTSIDE the entry-link so the inner <a> tags
don't auto-close the outer link (invalid HTML: <a> in <a>). */}}
{{- with .Params.tags }}
<ul class="tag-pills journal-tags">
{{- range . -}}<li><a href="/tags/{{ . | urlize }}/">{{ . }}</a></li>{{- end -}}
</ul>
{{- end }}
</li> </li>
{{ end }} {{ end }}
</ol> </ol>
+9 -2
View File
@@ -6,7 +6,8 @@
{{/* Library root: Atlas — gruppiert nach Untersection */}} {{/* Library root: Atlas — gruppiert nach Untersection */}}
<section class="atlas"> <section class="atlas">
{{ range .Sections.ByWeight }} {{ range .Sections.ByWeight }}
<article class="atlas-section"> {{ $section := path.Base .RelPermalink }}
<article class="atlas-section" data-section="{{ $section }}">
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2> <h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
{{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }} {{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }}
<ul class="atlas-list"> <ul class="atlas-list">
@@ -37,7 +38,13 @@
</section> </section>
{{ else }} {{ else }}
{{/* Library subsection: chronologisch */}} {{/* Library subsection: chronologisch */}}
<div class="time-list"> {{ $section := path.Base .RelPermalink }}
<header class="section-header" data-section="{{ $section }}">
<p class="section-rubric">Library</p>
<h1 class="section-title">{{ .Title }}</h1>
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
</header>
<div class="time-list" data-section="{{ $section }}">
<ul> <ul>
{{ range .RegularPages.ByDate.Reverse }} {{ range .RegularPages.ByDate.Reverse }}
<li class="list-item"> <li class="list-item">
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB