Compare commits
2 Commits
af2b8c5060
...
60e5ef6844
| Author | SHA1 | Date | |
|---|---|---|---|
| 60e5ef6844 | |||
| 7a5be9250a |
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Generated
+1936
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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); }
|
||||||
@@ -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);
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
Generated
+246
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
Executable
+167
@@ -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
|
||||||
Executable
+50
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<time datetime="{{ . | time.Format "2006-01-02" }}">{{ . | time.Format "02.01.2006" }}</time>
|
||||||
+33
-15
@@ -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>
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user