Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60e5ef6844 | |||
| 7a5be9250a |
@@ -1,5 +1,6 @@
|
||||
# Hugo
|
||||
/public/
|
||||
/preview/
|
||||
/resources/
|
||||
.hugo_build.lock
|
||||
hugo_stats.json
|
||||
@@ -8,6 +9,9 @@ hugo_stats.json
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Node (CMS)
|
||||
node_modules/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
+536
-106
@@ -5,7 +5,7 @@
|
||||
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 {
|
||||
/* Typography — editorial:
|
||||
@@ -45,27 +45,63 @@
|
||||
--accent: #b54a2c;
|
||||
--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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Base body — serif by default (editorial)
|
||||
Base body — serif editorial, 3-col grid so header/footer can full-bleed
|
||||
while content stays boxed
|
||||
------------------------------------------------------------------------ */
|
||||
body {
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: var(--font-size-base);
|
||||
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);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-columns: minmax(auto, var(--container-width));
|
||||
justify-content: center;
|
||||
/* Boxed content column = 72ch with 1.75rem gutters, side columns absorb the rest */
|
||||
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; }
|
||||
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 */
|
||||
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 {
|
||||
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;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns:
|
||||
1fr
|
||||
min(var(--container-width), 100% - 3.5rem)
|
||||
1fr;
|
||||
align-items: center;
|
||||
column-gap: 2rem;
|
||||
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 {
|
||||
@@ -104,35 +155,20 @@ a:hover {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
font-family: var(--font-family-display);
|
||||
justify-self: center;
|
||||
/* Republik-style: serif wordmark, all caps, white on black */
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
|
||||
letter-spacing: -0.01em;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.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 {
|
||||
grid-column: 2;
|
||||
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;
|
||||
}
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
margin: 0;
|
||||
}
|
||||
.site-nav ul {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1.4rem;
|
||||
justify-content: center;
|
||||
gap: 0.25rem 1.6rem;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.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.ancestor {
|
||||
color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Breadcrumb (only on non-home pages) */
|
||||
@@ -226,22 +270,69 @@ a:hover {
|
||||
|
||||
.journal-entry {
|
||||
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); }
|
||||
|
||||
/* Grid with explicit row-gap: bulletproof against margin/line-height inheritance */
|
||||
.journal-entry-link {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
row-gap: 0.45rem;
|
||||
/* Card link: image (optional) + body block */
|
||||
.journal-entry-link,
|
||||
.journal-entry-link:hover,
|
||||
.journal-entry-link:focus {
|
||||
display: block;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.journal-entry-link > * { margin: 0; }
|
||||
.journal-entry-link:hover .journal-title { color: var(--accent); }
|
||||
.journal-entry-link:hover .journal-title { color: var(--section-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 {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
@@ -251,21 +342,208 @@ a:hover {
|
||||
gap: 0.8rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.journal-section { color: var(--accent); font-weight: 500; }
|
||||
.journal-date { font-variant-numeric: tabular-nums; }
|
||||
.journal-author { color: var(--color-text-primary); font-weight: 500; }
|
||||
.journal-author::before { content: "von "; color: var(--color-text-muted); font-weight: 400; }
|
||||
.journal-section {
|
||||
display: inline-block;
|
||||
background: color-mix(in oklab, var(--section-color, var(--accent)) 35%, transparent);
|
||||
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 {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.012em;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-primary);
|
||||
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 {
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: var(--font-size-base);
|
||||
@@ -274,18 +552,32 @@ a:hover {
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.journal-tags {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0;
|
||||
.journal-tags { /* now extends .tag-pills — base styles come from there */ }
|
||||
|
||||
/* Hero tags — sit inside the dark hero block, below the link, centered */
|
||||
.journal-entry--hero > .tag-pills {
|
||||
margin: 0 auto;
|
||||
padding: 0 1.75rem 2.2rem;
|
||||
max-width: var(--container-width);
|
||||
text-align: center;
|
||||
}
|
||||
.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 {
|
||||
margin-top: var(--spacing-md);
|
||||
@@ -304,12 +596,12 @@ a:hover {
|
||||
}
|
||||
.atlas-section h2,
|
||||
.atlas-tags h2 {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.018em;
|
||||
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);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
@@ -348,6 +640,36 @@ a:hover {
|
||||
/* ------------------------------------------------------------------------
|
||||
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 {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
@@ -365,9 +687,10 @@ a:hover {
|
||||
}
|
||||
.list-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
flex: 1;
|
||||
}
|
||||
.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 h2 {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.015em;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.024em;
|
||||
line-height: 1.08;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
.software-item h2 a { border: none; }
|
||||
@@ -428,14 +753,70 @@ a:hover {
|
||||
------------------------------------------------------------------------ */
|
||||
.single { margin-top: var(--spacing-md); }
|
||||
.single-header { margin-bottom: var(--spacing-md); }
|
||||
.single-header h1 {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
/* Single article — full-bleed cover image directly under the masthead.
|
||||
width:100vw breaks the boxed column horizontally; negative top margin
|
||||
cancels the body grid gap + header margin so the image sits flush. */
|
||||
.single-hero-image {
|
||||
display: block;
|
||||
width: 100vw;
|
||||
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 {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
@@ -458,17 +839,20 @@ a:hover {
|
||||
max-width: 55ch;
|
||||
}
|
||||
.single-content h2 {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.15rem;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: 1.45rem;
|
||||
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);
|
||||
}
|
||||
.single-content h3 {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-top: var(--spacing-sm);
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
.single-content blockquote {
|
||||
@@ -503,11 +887,51 @@ a:hover {
|
||||
.toc ul, .toc ol { margin: 0 0 0 1.2rem; padding: 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 {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--spacing-lg);
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -546,18 +970,24 @@ a:hover {
|
||||
.page-foot-nav li.current a { color: var(--color-text-primary); }
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Footer — mirrors the header rhythm: 2-col grid, baseline-aligned,
|
||||
wordmark left, nav right; below: tagline left, credit right.
|
||||
Text left-aligned everywhere (no centering).
|
||||
Footer — dark full-bleed bar mirroring the masthead
|
||||
------------------------------------------------------------------------ */
|
||||
footer {
|
||||
background: var(--color-dark-panel);
|
||||
color: var(--color-dark-panel-muted);
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-lg) 0;
|
||||
border-top: none;
|
||||
/* 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 a:hover { color: var(--accent); }
|
||||
footer > * { grid-column: 2; }
|
||||
footer a { color: #d4d4d4; border: none; }
|
||||
footer a:hover { color: var(--accent-soft); }
|
||||
footer p { text-align: left; margin: 0; }
|
||||
|
||||
.footer-grid {
|
||||
@@ -570,11 +1000,11 @@ footer p { text-align: left; margin: 0; }
|
||||
|
||||
.footer-mark {
|
||||
grid-column: 1;
|
||||
font-family: var(--font-family-display);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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
|
||||
tags: ["büroführung", "praxis", "open-source"]
|
||||
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.
|
||||
|
||||
@@ -5,6 +5,9 @@ weight: 10
|
||||
tags: ["software", "eigene-werkzeuge"]
|
||||
summary: "Projektorganisation für ein Architekturbüro — von der Anfrage zur Übergabe."
|
||||
external: "https://dossier.gabrielevarano.ch"
|
||||
color: kori
|
||||
cover_image: /images/tokyo-metro.jpg
|
||||
image_mode: icon
|
||||
---
|
||||
|
||||
**DOSSIER** ist die Projektverwaltung von OPENBUREAU.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: "Warum wir eigene Werkzeuge bauen"
|
||||
date: 2026-05-21
|
||||
tags: ["software", "praxis", "tools"]
|
||||
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.
|
||||
|
||||
@@ -5,6 +5,7 @@ weight: 20
|
||||
tags: ["software", "eigene-werkzeuge"]
|
||||
summary: "Zeit- und Aufwandserfassung für ein Architekturbüro."
|
||||
external: "https://rapport.gabrielevarano.ch"
|
||||
color: kusa
|
||||
---
|
||||
|
||||
**RAPPORT** erfasst Stunden, Wege und Aufwand.
|
||||
|
||||
@@ -4,6 +4,7 @@ date: 2026-05-23
|
||||
weight: 30
|
||||
tags: ["software", "stack", "empfehlungen"]
|
||||
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.
|
||||
|
||||
@@ -3,6 +3,7 @@ title: "Typologie als Werkzeug"
|
||||
date: 2026-05-20
|
||||
tags: ["theorie", "typologie", "methode"]
|
||||
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.
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
{{ 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">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ with .Params.summary }}
|
||||
<p class="single-summary">{{ . }}</p>
|
||||
{{ end }}
|
||||
{{ $author := .Params.author | default site.Params.author.name }}
|
||||
{{ if or $author .Date }}
|
||||
<p class="single-byline">
|
||||
{{ with $author }}<span class="byline-author">von {{ . }}</span>{{ 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 }}
|
||||
{{- with $author -}}<span class="byline-author">{{ . }}</span>{{- end -}}
|
||||
{{- if and $author .Date -}}, {{ end -}}
|
||||
{{- if .Date -}}<time class="byline-date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>{{- end -}}
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ with .Params.summary }}
|
||||
<p class="single-summary text-muted">{{ . }}</p>
|
||||
{{ end }}
|
||||
</header>
|
||||
|
||||
{{/* Table of Contents */}}
|
||||
@@ -31,13 +39,20 @@
|
||||
{{ .Content }}
|
||||
</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 }}
|
||||
{{ $showLastMod := .Params.showlastmod | default site.Params.showlastmod | default false }}
|
||||
{{ $hasLastmod := and $showLastMod .Lastmod (ne (.Lastmod.Format "2006-01-02") (.Date.Format "2006-01-02")) }}
|
||||
{{ if or (and $showReadingTime .ReadingTime) $hasLastmod }}
|
||||
<div class="time">
|
||||
{{ 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>
|
||||
{{ end }}
|
||||
</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">
|
||||
{{ range $journal }}
|
||||
<li class="journal-entry">
|
||||
{{ $author := .Params.author | default site.Params.author.name }}
|
||||
{{ $section := "" }}
|
||||
{{ 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 }}">
|
||||
<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 }}
|
||||
<span class="journal-section">{{ .Title }}</span>
|
||||
<p class="journal-rubric"><span class="journal-section">{{ .Title }}</span></p>
|
||||
{{ end }}
|
||||
<time class="journal-date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
|
||||
{{ with $author }}<span class="journal-author">{{ . }}</span>{{ end }}
|
||||
<h3 class="journal-title">{{ .LinkTitle }}</h3>
|
||||
{{ 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>
|
||||
<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>
|
||||
{{/* 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>
|
||||
{{ end }}
|
||||
</ol>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{{/* Library root: Atlas — gruppiert nach Untersection */}}
|
||||
<section class="atlas">
|
||||
{{ 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>
|
||||
{{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }}
|
||||
<ul class="atlas-list">
|
||||
@@ -37,7 +38,13 @@
|
||||
</section>
|
||||
{{ else }}
|
||||
{{/* 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>
|
||||
{{ range .RegularPages.ByDate.Reverse }}
|
||||
<li class="list-item">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Reference in New Issue
Block a user