Commit Graph

181 Commits

Author SHA1 Message Date
Peter Meier 0d13e85639 fix(strommix): discard SMARD snapshot when aligned timestamp >90 min stale
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 49s
When SMARD's load filter (id 410) lags 12+ hours, the aligned-min
strategy in fetchSmardSnapshot produces a snapshot stamped with that
12-hour-old timestamp — and the widget surfaces it as the live "Stand
HH:MM" header. With Energy-Charts already returning fresher numbers
through the CMS path, falling back entirely is the right move.

Threshold is 90 min: tight enough to catch obvious lag, loose enough
to keep normal 30–60 min SMARD delay as the canonical source.
2026-05-05 00:06:11 +02:00
Peter Meier 9ebca1c237 fix(strommix): full i18n + drop hardcoded grid constraints
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 52s
Two issues from the audit:

1. Hardcoded `lg:col-span-8 lg:col-start-3` collided with the layout
   coming from `getBlockLayoutClasses(block.layout)`. Tailwind picked
   `lg:col-start-3` from the hardcoded class (no opposing
   `lg:col-start-1` in the layout output) so the widget started at
   column 3 with span 12 → overflowed past the 12-col grid on
   desktop, looking like it was "hanging right". Drop the hardcoded
   constraints; the layout config from the CMS controls width
   (mobile/tablet/desktop = 12 by default = full width).
2. Roughly 25 German strings were inline. Extracted them to
   translation keys (T.strommix_*, registered in translations.ts) and
   read via `useTranslate()` like ImageGalleryBlock /
   PostOverviewBlock / YoutubeVideoBlock. Includes field labels for
   the breakdown disclosure, source attribution variants, status
   bucket markers, error / loading / fallback messages, and the
   conventional formula text shown under the "Konventionell jetzt"
   number.

Default DE strings live in cms_content/windwiderstand/translation_bundle/
app.json5 (separate CMS push).
2026-05-05 00:02:27 +02:00
Peter Meier 2d85681252 fix(strommix): per-field fallback to Energy-Charts when SMARD value missing
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 48s
The widget rendered "Keine gültigen Live-Werte" the moment SMARD's
load filter (id 410) lagged behind the production filters by even one
slot — the aggregator coalesced "filter unavailable" with "filter
returned 0" and the percent calculation refused to divide by zero.

Three changes:

1. SmardSnapshot.values now uses `number | null` so callers can tell
   "filter genuinely missing" apart from "filter reported zero" (e.g.
   solar at night, decommissioned nuclear).
2. Aggregator pulls Energy-Charts (CMS strommix_de) in parallel and
   uses it as a per-field fallback for any SMARD null. New
   `dataSource: "smard+energy-charts"` flag travels in the response so
   the attribution stays honest. Pure SMARD when everything resolves;
   pure Energy-Charts when SMARD is entirely down; the hybrid label
   for the common case where only load drops out.
3. Widget renders the production breakdown + context row + price row
   even when the percentage is unavailable, with a clear "Lastwert
   von SMARD gerade nicht verfügbar" hint instead of going blank.
2026-05-04 23:55:57 +02:00
Peter Meier 96cd9e9c57 fix(strommix): SMARD as primary source — accuracy parity with audit
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 49s
Audit at 22:15 MESZ flagged the conventional sum (lignite + hard coal
+ gas + nuclear) as 19.1 GW in the widget vs 15.65 GW in reality. The
breakdown showed the gap was Energy-Charts' `Fossil gas` (6.9 GW)
which bundles natural gas + GuD + similar, while SMARD's `Erdgas`
filter 4069 (3.8 GW) tracks just natural gas the way the widget label
implies. SMARD is the canonical German grid feed; switching the
primary source resolves the discrepancy directly.

- New `lib/smard.ts` fetches the 11 relevant filter IDs in parallel:
  conventional (1223/4070/4069/1224), wind (4067/1225), solar (1226),
  hydro (1228), biomass (4066), other-conventional + pumped storage
  (1227/4071, surfaced separately), load (410). Two-step protocol
  (index.json → archive.json) handled inside; output is MW (MWh × 4)
  aligned to one common slot timestamp so every value on screen comes
  from the same point in time.
- Aggregator route uses SMARD for production + load, Energy-Charts
  only for cross-border saldo + day-ahead prices (where SMARD has no
  convenient single-call endpoint). `dataSource: "smard" |
  "energy-charts"` flag travels in the response so the widget can
  attribute the numbers and fall back gracefully when SMARD is down.
- StrommixBlock gets a "Werte prüfen" disclosure that lists every
  component (wind onshore + offshore, solar, the four conventional
  buckets, biomass, hydro, load) with its MW value and the slot
  timestamp + data source. Fact-check users can verify the addition
  themselves now without asking.

Verified live: SMARD @ 21:00 UTC = lignite 8484 + hard coal 3480 +
gas 3811 + nuclear 0 = 15.78 GW conventional. Matches the user's
ground-truth 15.65 GW within sampling noise.
2026-05-04 23:47:52 +02:00
Peter Meier f4e4abd978 fix(strommix): pick price at "now" + surface cross-border timestamp
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 49s
Audit against ground truth at 22:15 lokal exposed three real bugs:

1. Day-ahead price was wrong (widget 125, real 153 €/MWh). The CMS'
   `last_non_null` transform always picks the last cell of the 96-slot
   array — typically a slot in the future, not the slot covering "now".
   Bypass the CMS for prices: aggregator fetches energy-charts /price
   directly, walks unix_seconds, and picks the largest start ≤ now.
   `priceMeasuredAtUnix` is surfaced so the widget can show the slot
   timestamp as a sanity check.

2. Cross-border saldo (cbpf) typically lags 1.5–2 h. Widget labelled
   it as "jetzt" even when the upstream's last datapoint was 90 min
   old. Now the cross-border timestamp travels through the aggregator
   and the widget renders "Stand 20:30 Uhr · verzögert" when the gap
   to the production snapshot exceeds 30 min.

3. Bucket label "Dunkelflaute" misuses the term — the formal
   definition needs ≥24 h history, not a single threshold crossing.
   Updated the strommix-default content entry to use "Sehr niedrige
   Einspeisung" / "Very low feed-in" instead, and explained the
   reasoning in a comment so editors don't re-introduce the misnomer.

Aggregator response shape gained `crossborderMeasuredAtUnix` +
`priceMeasuredAtUnix`; the widget renders both as small timestamps
under their values, plus a "verzögert" hint when cross-border data is
older than the 30-min tolerance window.

Cache TTLs on the price external collections lowered from 30 min to
15 min so the admin-side cache flips at the same cadence as the
day-ahead slots (since 30.09.2025 prices come in 15-min buckets).
2026-05-04 23:35:13 +02:00
Peter Meier 8ddfd026bc feat(blocks): live_strommix widget for energy-charts data
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 53s
Editor-controlled live-strommix card that surfaces what share of
electricity demand is currently covered by weather-dependent
renewables (Wind+Solar / Load), plus the conventional generation that
fills the gap, the cross-border saldo, and the day-ahead price (DE/FR
side-by-side). Status bucket + label + argument come from the CMS so
editors can iterate copy without a deploy.

- LiveStrommixBlockData type next to the other block-types.
- New `/api/strommix` aggregator route bundles four CMS external
  collections (strommix_de, crossborder_de, price_de, price_fr) into a
  single payload, computes the renewable + conventional sums and the
  cross-border saldo (Σ flow_xx_gw, sign convention: + = export from
  DE), surfaces stale-on-error flags from the CMS response headers.
  Cache-control 5min so a single node only hits energy-charts once
  per window.
- StrommixBlock.svelte renders the card: percentage, bucket label,
  bucket argument (markdown), context row (conventional + saldo with
  ↑/↓ arrow), price row with optional FR comparison + negative-price
  badge, footer with disclaimer + "Wie wird das berechnet?" link +
  stale-data hint when the CMS served fallback. Re-polls every 5min
  client-side; bucket thresholds + labels + arguments come from the
  block instance so multiple placements can carry different copy.
- ContentRows.svelte dispatcher branch on `_type === "live_strommix"`.

Wired against `cms_content/_types/core/live_strommix.json5` (added in
the rustycms repo today). content_layout.row{1,2,3}Content already
allow the new block-type via the same commit on the CMS side.
2026-05-04 22:57:40 +02:00
Peter Meier a2ca0db9ca feat(security+obs): SSRF allowlist, HTML sanitization, timing-safe tokens, fetch timeouts, structured logger
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m17s
Security:
  - cms-images / cms-files now share resolveCmsSource (cms-source.server.ts)
    which enforces an allowlist: asset filename, absolute path on the CMS
    host, or full URL exact-matching the configured CMS host+protocol.
    Foreign hosts, protocol-relative URLs and path traversal return 400.
  - markdown-safe.ts is the single configured marked entrypoint; it
    disables raw HTML rendering globally (renderer.html() returns ''),
    removing the inline script/iframe injection vector via markdown.
    All 8 marked imports across components and loaders now go through it.
  - sanitize-blocks.server.ts walks resolved page/post content and runs
    HtmlBlock html fields through sanitize-html with a tight allowlist
    (no script/style/on*, only youtube/vimeo/spotify iframes, mailto/tel
    plus http(s) schemes, target=_blank gets rel=noopener).
  - Webhook tokens (revalidate, cache/clear) compared via
    crypto.timingSafeEqual through auth-token.server.ts.
  - cms.ts wraps every fetch in cmsFetch(): 5 s default timeout via
    AbortSignal.timeout, override per call or globally via
    PUBLIC_CMS_FETCH_TIMEOUT_MS. Stops slow CMS responses from pinning
    SSR workers indefinitely.

Observability:
  - log.server.ts gives logWarn / logError / logInfo with scope + context;
    swallowed catch {} blocks in hooks, layout.server, +page.server,
    [...slug]/+page.server and sitemap now log instead of going silent.

Routing:
  - Legacy /blog, /blog/page/:n, /blog/tag/:t and /p/:slug stub routes
    deleted; their 301 redirects moved into hooks.server.ts so the
    request never enters the SvelteKit page pipeline.

Tests (vitest, server-side):
  - auth-token.server.test.ts (6 cases for constant-time compare)
  - cms-source.server.test.ts  (8 cases for SSRF allowlist)
  - sanitize-blocks.server.test.ts (9 cases for sanitizer + walker)
  All 23 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:09:25 +02:00
Peter Meier 615d9cdbd1 chore(tooling): add vitest + svelte-check, drop yarn.lock, CI gates, typed env imports
Toolchain hygiene:
  - svelte-check is now a pinned devDep (was npx-fetched per CI run)
  - vitest + coverage as devDeps; vitest.config.ts is separate from
    vite.config.ts so vite build does not pull vitest
  - $env stub for vitest (alias resolves $env/dynamic/{public,private}
    to a process.env proxy) — keeps server modules testable without
    booting SvelteKit
  - npm scripts: check / check:watch / typecheck / test / test:watch
  - drop yarn.lock + packageManager field; CI uses npm ci, single source
    of truth is package-lock.json
  - CI runs generate:icons + svelte-check + tsc --noEmit before deploy

Switch from raw process.env.X to $env/dynamic/{public,private} in
file-cache, image-cache, transform-cache and rusty-image.server. The
typed imports get tree-shaken correctly per server/client and fail
loudly at build time on accidental client-side use of private vars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:09:02 +02:00
Peter Meier d0dede92fe perf(iconify): tree-shake MDI to used icons (-99.5% JSON, -71% client bundle)
The previous setup loaded the full @iconify-json/mdi collection (7000+
icons, 2.95 MB) just to render the ~30 icons we actually use, producing a
2.99 MB client chunk.

generate-icons.mjs scans src/ for static `icon="mdi:..."` references,
extracts a minimal IconifyJSON containing only those icons (plus an
EXTRA_ICONS list for dynamic CMS-driven names) from
@iconify-json/mdi/icons.json, and writes it to a generated subset file.
iconify-offline.ts now registers that subset instead of the full pack.

  build/client: 5.6 MB -> 1.6 MB (-71%)
  build/server: 5.3 MB -> 2.4 MB (-55%)
  largest client chunk: 2.99 MB -> 127 KB (-96%)
  iconify subset: 2.95 MB -> 15.5 KB (-99.5%)

Hooked into predev/prebuild so the subset stays fresh; missing icons
fall back to the iconify API at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:08:47 +02:00
Peter Meier 56084ac542 feat(maintenance): static fallback when the CMS is unreachable
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m4s
Adds a self-contained maintenance page that the SvelteKit handle hook
serves before the first CMS call when `isCmsAvailable()` returns
false. The probe hits `/api-docs/openapi.json` with a short timeout
and caches the result (10s healthy, 3s unhealthy) so we don't pay a
round-trip per request. `/maintenance` and `?cms_down=1` simulate
the page without taking the CMS down — useful for previewing the
copy.

The maintenance HTML is fully inline (no CMS calls, no layout, no
external CSS) so it survives even when every dependency is down.
Returned with 503 + Retry-After:60 + noindex headers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:47:15 +02:00
Peter Meier 1e9ef44b52 feat(header): highlight active nav link with bold + underline
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 1m9s
Compares the link href against `$page.url.pathname` (with trailing-slash
normalization + subpath match), applies `font-bold underline` and
`aria-current="page"` on a match. Wired into both the desktop horizontal
nav and the mobile slide-down menu.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:29:05 +02:00
Peter Meier 5cf141fa4a chore(blog-overview): apply prettier formatting
No behavior change — IDE auto-format pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:29:00 +02:00
Peter Meier 616fb66c00 fix(event-badges): suppress parent-link underline on hover
When EventBadges sits inside a `<a hover:underline>` (upcoming-events
list, post cards) the underline propagated through the badge spans on
hover. Force `text-decoration: none` on the date and location pills so
only the headline text underlines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:07:56 +02:00
Peter Meier 4db2fd8562 fix(blog-overview): use EventBadges in upcoming-events list
The plain `<time>` was visually weak next to the headline. Reuse the
`EventBadges` component from the post card so the date renders as a
proper wald-tinted pill — same shape as event posts. Drops the local
`formatEventDate` since EventBadges does the localization itself
(weekday + time included, matching post cards).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:06:41 +02:00
Peter Meier dcce4fdea1 fix(blog-overview): upcoming-events block escapes prose ul-bullet rule
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 1m7s
`main ul:not(.not-prose) li` injects a disc bullet on every list under
`main`, which the upcoming-events list inherited. Mark the ul `not-prose`
+ `list-none` so the rule doesn't match. Also bump the calendar-clock
icon from `h-4 w-4` to `size-5` so it visually matches the heading
ascender.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:03:47 +02:00
Peter Meier 00d42ebb42 feat(preview): consume rustycms live-preview drafts
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 1m1s
`?preview_draft=<sid>` on any page/post route forwards the signed
session id to the CMS, which renders the in-memory draft instead of
the persisted entry. Layout listens for `rustycms:reload` postMessages
from the admin iframe parent and re-runs `invalidateAll()` to pick up
fresh draft state. CSP `frame-ancestors` is set per-request when
`PUBLIC_PREVIEW_PARENT_ORIGIN` is configured so the admin iframe can
embed the response.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 09:24:05 +02:00
Peter Meier 3bc7273e71 perf(cms): batch layout bootstrap, sparse fieldsets, depth caps
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m16s
Adopt RustyCMS' new resolve semantics (_depth, _fields,
POST /_batch) where they pay off:

- +layout.server.ts: 4 sequential GETs (navHeader, navSocial,
  footer, page_config) collapsed into one batchFetch() call.
  Legacy "default" page_config slug stays as fallback Single-
  Batch only when the canonical slug is missing.
- New getPageStubs() / getPostStubs() use _fields= so the
  Slug→Label/Slug→URL maps don't pull body/rows. Typically
  10-50× smaller payload, eligible for the same TTL cache.
- loadPostsList() now requests posts with _depth=2 and a
  sparse fieldset that keeps card-level fields plus
  postImage.* and postTag.{name,icon,color,_slug}. Body
  Markdown stays out of the listing entirely.
- Page + post detail routes set _depth=3 as a cycle/blast-radius
  cap (page → block → image → img reaches in three levels).
- cms.ts: ContentGetOptions gains depth/fields, getPageBySlug /
  getPostBySlug / getPosts thread them through (cache key
  extended). New batchFetch() / batchData<T>() helpers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:00:10 +02:00
Peter Meier 35f7aa8f90 feat(blocks): YoutubeVideoGalleryBlock (carousel + grid)
Deploy / verify (push) Successful in 2m19s
Deploy / deploy (push) Successful in 1m7s
New block for `youtube_video_gallery` CMS type. Carousel renders
2 iframes side-by-side on md+ (1 on mobile) with prev/next/dots; grid
shows YT thumbnails opening modal embeds. Description supports
markdown via marked; long URLs break with overflow-wrap:anywhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:31:38 +02:00
Peter Meier 3d343822e9 feat: print-friendly layout + comment count on every post overview
Deploy / verify (push) Successful in 42s
Deploy / deploy (push) Successful in 1m4s
- Hides Header / HeaderOverlay / Footer / TopBanner / Breadcrumbs and
  the comment accordion when printing. Action toolbar already had
  `print:hidden`.
- Adds @media print rules in app.css: 1.5cm page margins, 11pt body,
  forced black-on-white, expand link URLs after anchor text inside
  articles, keep images and headings together at page breaks.
- PostCard now renders the count pill whenever a value is known (incl.
  zero); previously hidden for `count === 0`, so posts whose comments
  are still pending always looked empty.
- Extracts the bulk-fetch helper to `lib/comment-counts.ts`
  (`fetchCommentCounts` + `postSlugForComments`) and uses it from both
  `BlogOverview` and `PostOverviewBlock`. Homepage / any page with a
  post_overview block now shows the badges too — previously only the
  paginated /posts/* routes did.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 15:29:15 +02:00
Peter Meier 6f14e4f150 style(post): polish post header + actions toolbar
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m4s
- PostActions: every item is a rounded-full pill with subtle border;
  the three icon-only actions (share/copy/print) cluster in one
  segmented control with divider lines, count + reading-time stand
  alone left and right
- Share button only renders when `navigator.share` is available so
  desktop doesn't get a duplicate of "Copy link"
- All action buttons drop their inline labels and use `title=` +
  `aria-label` for tooltip-only access; copy still flips icon to a
  checkmark for 1.5s on success
- Empty comment state stacks vertically on mobile (icon above), goes
  horizontal + left-aligned on ≥sm
- Post header rebuilt: image fixed at 150x150 (no more mobile blow-up),
  right column is `flex flex-col gap-3` so date/tags/actions share one
  rhythm instead of ad-hoc margins
- Reading-time now also walks `row*Content` and pulls `content`/`body`/
  `text` strings — Termin-Posts with empty body but markdown rows now
  show a meaningful estimate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 15:08:04 +02:00
Peter Meier 6b2b7245f4 refactor: fold comment count into PostActions toolbar
CommentCountBadge was a separate component rendered next to PostActions
— two adjacent strips of buttons that should have been one toolbar.
Merges the count fetch + click-to-scroll behaviour into PostActions
behind `commentPageId` / `commentTargetId` props. The standalone
CommentCountBadge.svelte is now redundant; deleted.

Layout under the post hero is now a single row:
[💬 3]  [↗ Teilen]  [📋 Link]  [🖨️]  [⏱ 4 min]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:52:30 +02:00
Peter Meier 3318fe9e17 feat(post): post actions toolbar (share / copy / print / reading time)
Adds a small toolbar next to the comment-count badge with four actions:

- Share — uses `navigator.share()` on supporting devices, falls back
  to copy-link otherwise
- Copy link — clipboard API with `<input>` + `execCommand` fallback,
  flashes a checkmark for 1.5s on success
- Print — `window.print()`, hidden via `print:hidden` so it doesn't
  show up in the printed output itself
- Reading time — server-computed from headline+body HTML at ~200 wpm,
  hidden when 0

Reading time + canonical URL come from `+page.server.ts` so SSR
output is identical and shareable URLs render correctly.

All labels go through the translations layer (5 new
`post_action_*` keys).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:42:16 +02:00
Peter Meier e4804ce673 i18n(comments): all UI strings through translations
Pulls every German string out of Comments.svelte, PostCard.svelte and
CommentCountBadge.svelte and routes it through `useTranslate()` +
the `T.*` key registry. New keys (`comments_*`, `post_overview` count
aria, `comments_jump_to`) are registered in `lib/translations.ts`.

Default DE values land in
`cms_content/windwiderstand/de/translation_bundle/app.json5` (committed
separately) so the CMS-loaded bundle keeps the same look without each
component falling back to its raw key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:34:48 +02:00
Peter Meier 8f791ebc54 feat(blog): comment count badge on post overview
Deploy / verify (push) Successful in 38s
Deploy / deploy (push) Successful in 59s
PostCard now shows a small comment-count pill in the top-left of the
image when count > 0. BlogOverview bulk-fetches counts for the visible
posts via the new `/api/comments/counts?ids=…` endpoint and passes
them down. One request per page render, regardless of card count.

- New helper `getCommentCounts()` in `cms.ts`
- `BlogOverview.svelte` derives slugs from posts, fires bulk fetch
  in `$effect`, hands the result map to `PostCard`
- `PostCard.svelte` accepts `commentCount?: number | null`, renders
  pill only when value is a positive number

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:26:09 +02:00
Peter Meier 8f45094f64 style(comments): move count badge under image, drop underline
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m3s
Repositions the CommentCountBadge out of the date row into its own
container below the post image. Removes hover colour-change + the
prose underline (`no-underline\!`) so the badge sits flat with the
post chrome.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:21:05 +02:00
Peter Meier 40b2d406ae fix(rusty-image): derive arValue from arString for layout match
`arValue` (height computation) and `arString` (URL `ar=` param) used
to read from different sources, so the URL could request a different
aspect than the `<img>` element reserved — caused a visible flicker
when the transformed image loaded. Both now derive from `arString`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:20:59 +02:00
Peter Meier 9d1a7ab46d feat(comments): polish UI + send client_age_ms
- Avatars with hash-derived hue, initials per author
- Relative dates (Intl.RelativeTimeFormat), absolute as title
- localStorage remembers author name for next visit
- Auto-resize textarea, char counter, Cmd+Enter submit
- Skeleton loading state, icon empty state, retry button on error
- Pending comments get amber card + status pill (visible only to author)
- Reply count in toggle ("3 Antworten")
- Edit/delete buttons fade in on hover (group-hover)
- DRY: meta + body rendering as snippets, used by top-level + replies
- Sends `client_age_ms` for backend's anti-instant-bot check

No content filtering — only timing / structure / re-use checks live in
the backend now (sha256 dedupe, session cooldown, banned IPs, author
char sanity).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:18:23 +02:00
Peter Meier 817f658977 fix(comments): inline repliesOf() — {@const} placement
Deploy / verify (push) Successful in 47s
Deploy / deploy (push) Successful in 1m3s
Svelte rejected `{@const replies = repliesOf(c.id)}` because it sat
inside `<li>` rather than directly under `{#each}`. The Gitea deploy
typecheck (`svelte-check --threshold error`) failed on this, leaving
the previous Cusdis bundle live. Inlining the call costs one extra
filter per render — fine here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:49:27 +02:00
Peter Meier febc027e6b feat: replace Cusdis with own comment plugin
Deploy / verify (push) Failing after 45s
Deploy / deploy (push) Has been skipped
Drops the Cusdis embed (postCommentSlot in pageConfig) in favour of the
new RustyCMS comment plugin. New Comments.svelte renders the thread,
form, replies and inline edit/delete via /api/comments. Count-badge near
the post date links to the accordion and uses /api/comments/:id/count.

Accordion gets a `lazy` mode so the comment iframe / fetch happens only
on first open (avoids the previous "empty on first render" issue with
hidden details).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:17:10 +02:00
Peter Meier 9b4191151c docs(images): correct misleading AVIF default in RustyImage / TopBanner
Deploy / verify (push) Successful in 41s
Deploy / deploy (push) Successful in 1m5s
The runtime default has been `["webp", "jpeg"]` for a while, but the doc
comments still referenced AVIF. Update them and add the rationale (server-side
AVIF encoding is 5-10× slower than WebP for marginal bandwidth gain).

No runtime change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 18:56:39 +02:00
Peter Meier 354d3f09d3 feat(youtube): playlist support + URL parsing in youtube_video block
Deploy / verify (push) Successful in 42s
Deploy / deploy (push) Successful in 1m5s
YoutubeVideoBlockData gains an optional `playlistId`. The block now
accepts either a video, a playlist, or both, and either field can be
filled with a raw ID *or* a full youtube.com / youtu.be URL — a
helper extracts the ?v= and ?list= parameters automatically.

Embed routing:
- video only           → /embed/<id>?rel=0
- playlist only        → /embed/videoseries?list=<plid>&rel=0
- video + playlist     → /embed/<id>?list=<plid>&rel=0
- block.params merged  via URLSearchParams

Also tweaks ImageGalleryBlock (grid variant): green padding/contain
fit, darker caption gradient, smaller left-aligned label that expands
on hover. Hides post_overview block when no posts and no intro text
(prevents a bare heading on /mediathek).
2026-04-25 13:15:11 +02:00
Peter Meier 942b9feefa feat(files-block): icons for tsv/json/xml/py/js/ts/rs/sh
Deploy / verify (push) Successful in 39s
Deploy / deploy (push) Successful in 59s
iconForExt now matches the backend's expanded ALLOWED_EXTENSIONS:
- tsv routes to the excel-box icon (alongside csv).
- json → mdi:code-json, xml/html → mdi:xml.
- py → mdi:language-python; js/ts/jsx/tsx → language-javascript;
  rs → language-rust; sh/bash → console.

Visitors who download a CMS-hosted dataset, source dump or archive
now see a meaningful icon instead of the generic file-outline
fallback.
2026-04-25 11:21:40 +02:00
Peter Meier 9eab404240 fix(post-overview): hide block entirely when no posts + no intro text
Deploy / verify (push) Successful in 38s
Deploy / deploy (push) Successful in 59s
The post_overview block kept rendering its <h3> headline + the
"all posts" footer link even when both `postsResolved` was empty
and `text` was unset, leaving a heading with nothing under it on
pages like /mediathek that include the block defensively.

Wrap the whole render in `{#if hasContent}` so empty overviews
disappear; the heading reappears as soon as posts or intro text
are present.
2026-04-25 10:54:05 +02:00
Peter Meier 998fba8904 feat(gallery): grid title, subtle download icon, full i18n
Deploy / verify (push) Successful in 38s
Deploy / deploy (push) Successful in 59s
- Render block.title as <h3> above the grid when set; the carousel
  variant is unchanged.
- Download CTA in the modal shrunk to a 9×9 icon-only button with
  text-white/80 hover-tint instead of the dark green link colour the
  app's `main a` rule was forcing in. Adds `no-underline` so the
  global underline doesn't sneak back in.
- All previously hardcoded German strings (Download, Schließen,
  modal aria, open aria) now go through translations.ts → CMS
  translation_bundle. Cms_content app.json5 ships the matching keys.

Open follow-up: per-image title/alt/caption is empty on the existing
windwiderstand mediathek gallery → editor needs to fill those if
hover labels are wanted.
2026-04-25 10:44:30 +02:00
Peter Meier 9946061105 fix(css): global main ul/ol bullet rules respect .not-prose opt-out
Deploy / verify (push) Successful in 38s
Deploy / deploy (push) Successful in 58s
The global typography rules in app.css (main ul li → list-style: disc)
ran independently of Tailwind Typography, so `not-prose` on the
gallery grid couldn't suppress the bullets. Adding :not(.not-prose)
to those selectors makes the same opt-out class work for both
prose-managed and globally-styled lists, so the gallery grid renders
without disc markers and any future grid-of-list use case can opt out
the same way.
2026-04-25 10:34:58 +02:00
Peter Meier 1154be3c25 fix(gallery): use not-prose on grid <ul> to escape Typography styles
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s
The Tailwind Typography (`prose prose-zinc`) wrapper applied higher in
the page hierarchy injects `.prose ul` rules — list-style: disc, an
indent, and ::marker color — at a higher specificity than single-class
utilities, so list-none/p-0/m-0 silently lost.

`not-prose` is the official escape hatch from the typography plugin
and zaps every prose rule on the element plus its descendants. One
class instead of three \!important overrides.
2026-04-25 10:34:06 +02:00
Peter Meier 6246d7c3a4 feat(gallery): grid variant with hover labels + download modal
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s
ImageGalleryBlock now branches on `block.variant`:

- "standard" (default) → existing carousel.
- "grid" → 2/3/4-column thumbnail grid (1:1 squares, w=400/q=75
  through /cms-images). Each tile scales 5% on hover and surfaces
  the image's title/alt/caption/description as a gradient overlay
  caption that fades in on hover and keyboard focus.

Click any tile → fullscreen lightbox (z-100 overlay + 90vh image).
The lightbox carries:
- A download button using the asset's filename (extracted from the
  src URL).
- Prev/Next arrows when there's more than one image (keyboard:
  ←/→), Esc to close, click on the backdrop closes too.
- Position counter (n/total) when applicable.

Type updates in block-types.ts: ImageGalleryBlockData gains
`variant?: "standard"|"grid"` and `title?`. ImageGalleryImage gains
`alt`/`caption`/`title` so the schema's `image` field shape is
honoured for the hover label.
2026-04-25 10:32:37 +02:00
Peter Meier ecff7b0bbf feat(images): cache-bust /cms-images URLs by post._updated
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s
Browser caches /cms-images responses immutably for a year. When the
editor changes a focal point or alt text, the underlying transform
output changes too, but the URL stays the same — so the browser keeps
serving the previous bytes. Manual hard-reloads and /api/cache/clear
help once but not for visitors who already have the file.

- RustyImageTransformParams gains an optional `version` that
  serializes as `_v` in the /cms-images URL.
- RustyImage.svelte takes a matching `version` prop and forwards it
  through `baseParams`, so every <picture> source carries the tag.
- The post thumbnail passes `post._updated` (truncated to seconds)
  as version. Each save bumps `_updated`, the URL changes, browsers
  re-fetch.

`_v` is intentionally not consumed by `/cms-images` — its sole job is
to break the browser cache key. The server-side image-cache and the
RustyCMS transform cache key both ignore it, so disk usage doesn't
balloon when the version tag changes.
2026-04-25 09:43:34 +02:00
Peter Meier bb9df1f9bd fix(post): align microformat block with PostEntry types for svelte-check
Deploy / verify (push) Successful in 37s
Deploy / deploy (push) Successful in 1m0s
Three CI typecheck failures from the previous microformat patch:

- post.created is the only date field in the schema (no _created on the
  generated PostEntry). Drop the _created fallback.
- postTag items can be a slug string OR a tag object. Type narrowing
  with `'name' in tag` widened to `never`; pull the name through an
  explicit `{ name?: string }` cast in @const so the iteration stays
  honest about the runtime shape.
- author isn't on PostEntry either; cast at the use-site for the
  hidden h-card author block.
2026-04-25 09:22:18 +02:00
Peter Meier 1ff4dc7a9a feat(api): POST /api/cache/clear + flush images on asset webhooks
Deploy / verify (push) Failing after 38s
Deploy / deploy (push) Has been skipped
Two changes:
- New POST /api/cache/clear (auth: REVALIDATE_TOKEN). Wipes the
  TTL content cache, the image disk + memory cache, and forwards to
  the RustyCMS /api/transform/cache/invalidate endpoint when an
  API key is configured. Single trigger to flush everything.
- /api/revalidate now also calls clearImageCache() on asset.* events
  so a re-upload (focal change, alt edit, replacement) doesn't keep
  serving the previous transformed image from disk.

clearImageCache() in image-cache.server.ts wipes the in-memory map
and removes every file under IMAGE_CACHE_DIR.
2026-04-25 09:20:48 +02:00
Peter Meier 7264ad6707 feat(post): emit VideoObject JSON-LD for embedded YouTube videos
Deploy / verify (push) Failing after 38s
Deploy / deploy (push) Has been skipped
Posts whose row*Content contains a `youtube_video` block now ship a
schema.org VideoObject per video alongside the existing BlogPosting /
Event JSON-LD. Lets Google attach a video rich-result to the post URL
(thumbnail, duration where available, embed/content URLs) instead of
treating the page as plain text whose body happens to be a single
iframe.

Thumbnail uses YouTube's predictable maxresdefault + hqdefault URLs;
upload date falls back to post.created when the embed has none.
2026-04-25 09:16:33 +02:00
Peter Meier 5b82d37be4 feat(post): add h-entry microformat + tag-name keywords fallback
Deploy / verify (push) Failing after 44s
Deploy / deploy (push) Has been skipped
Microformats:
- Wrap the post <article> in h-entry with p-name on the headline,
  p-summary on the subheadline (or sr-only fallback from excerpt),
  e-content on the rendered body, dt-published on the created date
  (hidden time element) and an invisible p-author h-card. Tag names
  are mirrored as hidden p-category entries so crawlers see them
  even though the visual Tags component already links them.
- itemscope/itemtype maps to schema.org BlogPosting (or Event when
  `isEvent`), aligning the invisible microdata with the existing
  JSON-LD.

Keywords:
- When post.seoKeywords is empty, fall back to the comma-joined tag
  names so <meta name="keywords"> still ships something meaningful.
2026-04-24 11:04:14 +02:00
Peter Meier 36288befe1 fix(post): render post.headline/subheadline when markdown body has no H1
Deploy / verify (push) Successful in 49s
Deploy / deploy (push) Successful in 1m6s
The article template only rendered the H1 it could pluck from the
markdown body (`contentHeadlineHtml`). Posts whose body opens with H2
(or no heading) silently lost both the schema-level `headline` and
`subheadline` — they were used in SEO/JSON-LD/breadcrumb only.

Fall back to the structured fields when no inline H1 is present.
2026-04-24 08:30:50 +02:00
Peter Meier 724b75b6bf feat(cms-images): forward dpr + format=auto to RustyCMS
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m2s
- Accept dpr query param (clamped 1..4) and include it in cache key so
  each density gets its own cached file.
- Accept format=auto and negotiate locally from the client's Accept
  header (avif > webp > jpeg) before building the cache key — concrete
  formats in the cache avoid cross-client leaks.
2026-04-23 13:36:38 +02:00
Peter Meier 50bb0ef398 feat(preview): implement preview mode for content management
Deploy / verify (push) Successful in 41s
Deploy / deploy (push) Successful in 1m3s
- Added support for a preview mode in the CMS, allowing draft content to be viewed without being indexed.
- Updated hooks and server logic to handle preview parameters, adjusting cache control and robots tags accordingly.
- Enhanced layout to display a preview notification when in preview mode, improving user experience for content editors.
2026-04-23 11:11:27 +02:00
Peter Meier 9216138a8c style(FilesBlock): enhance list styling for improved presentation
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m0s
- Updated the list styling in FilesBlock component to remove default list styles and improve layout consistency.
- Adjusted list item classes for better alignment and visual clarity, enhancing the overall user experience.
2026-04-22 10:51:39 +02:00
Peter Meier 5b5ec7c659 feat: add FilesBlock interface and component integration
Deploy / verify (push) Successful in 46s
Deploy / deploy (push) Successful in 1m8s
- Introduced FilesBlockItem and FilesBlockData interfaces to support file handling in the CMS.
- Integrated FilesBlock component into ContentRows.svelte for rendering file blocks.
- Updated block type handling to include support for files, enhancing content versatility.
2026-04-22 10:23:20 +02:00
Peter Meier bedfe597ab style: update typography and enhance OrganisationsBlock layout
Deploy / verify (push) Successful in 41s
Deploy / deploy (push) Successful in 1m0s
- Changed comment in app.css to reflect German terminology for quotes.
- Added Button component to OrganisationsBlock for improved call-to-action presentation.
- Adjusted button styling and layout in OrganisationsBlock for better responsiveness and visual consistency.
2026-04-21 13:39:57 +02:00
Peter Meier 47cca3abc6 chore: update package.json and yarn.lock for dependency management
Deploy / verify (push) Successful in 48s
Deploy / deploy (push) Successful in 1m15s
- Added `packageManager` field to package.json for Yarn versioning.
- Updated yarn.lock to include new dependencies for `@emnapi` and `@esbuild` packages, enhancing compatibility and performance.
- Adjusted styling in TopBanner.svelte for improved layout and responsiveness.
2026-04-21 13:16:55 +02:00
Peter Meier 6794d41d8d chore: update package-lock and yarn.lock to include class-variance-authority
Deploy / verify (push) Successful in 50s
Deploy / deploy (push) Successful in 1m13s
- Added `class-variance-authority` version 0.7.1 to both package-lock.json and yarn.lock for enhanced styling capabilities.
- Removed outdated entries from yarn.lock to streamline dependencies and improve project maintainability.
2026-04-20 23:16:07 +02:00