Update project configuration and enhance translation support. Added caching mechanism for CMS responses with a new script, updated .gitignore to include cache files, and introduced translation handling in various components. Enhanced package.json with new scripts for cache warming and deployment. Added new translation-related components and improved existing ones for better localization support.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Erkenntnisse und Konventionen in Cursor-Regeln festhalten, damit der Kontext automatisch erweitert wird
|
||||
globs: ".cursor/rules/**/*.mdc"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Kontext bei Erkenntnissen anpassen und erweitern
|
||||
|
||||
Wenn im Gespräch **wichtige Erkenntnisse** zu diesem Projekt entstehen (Architektur, Konventionen, Patterns, „so machen wir es hier“), diese **sofort in den Cursor-Kontext übernehmen**:
|
||||
|
||||
1. **Neue Regel anlegen oder bestehende erweitern** unter `.cursor/rules/`:
|
||||
- Neue Themen → neue `.mdc`-Datei (kurzer, prägnanter Titel, z. B. `translations-i18n.mdc`).
|
||||
- Gehört zu einem bestehenden Thema → die passende Regel-Datei bearbeiten und den Inhalt ergänzen/aktualisieren.
|
||||
|
||||
2. **Format:** `.mdc` mit YAML-Frontmatter (`description`, `globs` oder `alwaysApply: true`). Inhalt knapp, unter ~50 Zeilen pro Regel, mit konkreten Beispielen wo sinnvoll.
|
||||
|
||||
3. **Wann eine Regel anpassen/erweitern:**
|
||||
- Einverständnis oder expliziter Wunsch des Users (z. B. „füge das in den Cursor-Context hinzu“).
|
||||
- Oder: Wir haben gemeinsam eine wiederkehrende Konvention oder Architektur-Entscheidung geklärt, die für künftige Arbeit relevant ist – dann **proaktiv** eine Regel anlegen oder aktualisieren, ohne extra nachzufragen.
|
||||
|
||||
4. **Nicht** in Regeln packen: Einmalige Fixes, reine Code-Änderungen ohne Konventions-Charakter, Dinge die nur für einen einzelnen Task gelten.
|
||||
|
||||
**Ziel:** Der Projekt-Kontext wächst mit jeder wichtigen Erkenntnis; spätere Sessions und andere Agent-Durchläufe haben die gleichen Konventionen automatisch zur Verfügung.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: Übersetzungssystem (i18n) – Keys, CMS, Nutzung in Astro und Svelte
|
||||
globs: "src/lib/translations.ts,**/Layout.astro,src/pages/**/*.astro,src/components/**/*.svelte,src/middleware.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Übersetzungssystem (i18n)
|
||||
|
||||
Alle sichtbaren UI-Texte kommen aus dem CMS (RustyCMS) über das Translation-Bundle.
|
||||
|
||||
## Quelle
|
||||
|
||||
- **CMS:** Translation-Bundle über API (Single Source of Truth). Slug `app`, geladen in der Middleware mit `_resolve=all`.
|
||||
- **Slug:** Konstante `TRANSLATION_BUNDLE_SLUG = "app"` in `src/lib/constants.ts`.
|
||||
- **Middleware:** `src/middleware.ts` lädt das Bundle einmal pro Request und setzt `Astro.locals.translations`.
|
||||
|
||||
## Keys definieren
|
||||
|
||||
1. **Key in `src/lib/translations.ts`** in das Array `TRANSLATION_KEYS` eintragen (damit `T.key_name` und Typ `TranslationKey` existieren).
|
||||
2. **String in RustyCMS** in `content/de/translation_bundle/app.json5` unter `strings` eintragen. Platzhalter: `{{name}}` wird durch `t(..., { name: value })` ersetzt.
|
||||
|
||||
## Nutzung
|
||||
|
||||
### In Astro (.astro)
|
||||
|
||||
- `translations = Astro.locals.translations ?? {}`
|
||||
- `t(translations, T.xy)` oder mit Ersetzungen: `t(translations, T.xy, { count: 5 })`
|
||||
- Breadcrumbs: optional `translationKey: T.nav_start` statt `label: "Start"`; Layout löst das mit `t(translations, item.translationKey)` auf.
|
||||
|
||||
### In Svelte
|
||||
|
||||
- **Innerhalb von TranslationProvider** (z. B. Header, Footer): `const t = useTranslate();` dann `t(T.xy)`.
|
||||
- **client:load-Inseln (Astro Islands):** `useTranslate()` hat keinen Zugriff auf den Svelte-Context, und beim SSR gibt es kein `window` → Übersetzungen fehlen, es erscheint der Key (z. B. `blog_tag_all`).
|
||||
**Lösung:** Übersetzungen immer als Prop von der Astro-Seite übergeben und die statische `t()` nutzen:
|
||||
- Komponente: Prop `translations?: Translations`; keine `useTranslate()`, sondern lokale Hilfsfunktion `t(key, replacements) => t(translations ?? null, key, replacements)` mit `t` aus `../lib/translations`.
|
||||
- Astro-Seite: `<BlogOverview ... translations={translations} />` mit `translations = Astro.locals.translations ?? {}`.
|
||||
- Siehe `BlogOverview.svelte` als Referenz.
|
||||
|
||||
### Neue UI-Texte
|
||||
|
||||
Keine hartkodierten deutschen/englischen Sätze in Komponenten oder Seiten. Stattdessen: Key in `TRANSLATION_KEYS` + Eintrag in `app.json5`, dann überall `t(translations, T.key)` bzw. `t(T.key)` nutzen.
|
||||
@@ -1,5 +1,7 @@
|
||||
# build output
|
||||
dist/
|
||||
# CMS response cache (Dev-Fallback bei nicht erreichbarem CMS)
|
||||
.cache/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
"dev": "yarn generate:api-types; astro dev",
|
||||
"build": "yarn generate:api-types && astro build && pagefind --site dist && cp -r dist/pagefind public/",
|
||||
"preview": "astro preview",
|
||||
"cache:warm": "node scripts/warm-cache.mjs",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"astro": "astro",
|
||||
"generate:api-types": "node scripts/generate-api-types.mjs",
|
||||
"postinstall": "yarn generate:api-types || true",
|
||||
"firebase-deploy": "yarn build && firebase deploy --only hosting",
|
||||
"deploy": "yarn firebase-deploy",
|
||||
"firebase-deploy:nocache": "yarn build && firebase deploy --only hosting"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -23,6 +25,7 @@
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.17.1",
|
||||
"json5": "^2.2.3",
|
||||
"marked": "^17.0.2",
|
||||
"svelte": "^5.51.2",
|
||||
"tailwindcss": "^4.1.18"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" fill="#f0f5f1" rx="6" />
|
||||
<path
|
||||
d="M6 8l4 16 4-10 4 10 4-16"
|
||||
stroke="#2d7a45"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
+9
-8
@@ -1,9 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path
|
||||
d="M6 8l4 16 4-10 4 10 4-16"
|
||||
stroke="#2d7a45"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 241 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path
|
||||
d="M6 8l4 16 4-10 4 10 4-16"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Befüllt .cache/cms mit allen relevanten API-Responses.
|
||||
* CMS muss erreichbar sein (PUBLIC_CMS_URL, Standard: http://localhost:3000).
|
||||
* Nutzung: yarn cache:warm oder node scripts/warm-cache.mjs
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
const CACHE_DIR = join(root, ".cache", "cms");
|
||||
|
||||
const BASE =
|
||||
process.env.PUBLIC_CMS_URL?.trim() || "http://localhost:3000";
|
||||
|
||||
function cacheKey(url) {
|
||||
return createHash("sha256").update(url).digest("hex");
|
||||
}
|
||||
|
||||
async function fetchAndCache(url) {
|
||||
const key = cacheKey(url);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const body = await res.text();
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
const path = join(CACHE_DIR, `${key}.json`);
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({ status: res.status, body }),
|
||||
"utf-8"
|
||||
);
|
||||
return { url, ok: res.ok, status: res.status };
|
||||
} catch (err) {
|
||||
return { url, ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Basis-URLs, die beim Build/Dev immer angefragt werden. */
|
||||
const BASE_URLS = [
|
||||
`${BASE}/api-docs/openapi.json`,
|
||||
`${BASE}/api/content/page`,
|
||||
`${BASE}/api/content/page_config?_per_page=50&_locale=de`,
|
||||
`${BASE}/api/content/translation_bundle/app?_resolve=all&_locale=de`,
|
||||
`${BASE}/api/content/navigation?_locale=de&_resolve=links&_page=1&_per_page=50`,
|
||||
`${BASE}/api/content/navigation?_locale=de&_resolve=all&_page=1&_per_page=50`,
|
||||
`${BASE}/api/content/post?_per_page=100`,
|
||||
`${BASE}/api/content/tag`,
|
||||
`${BASE}/api/content/footer/footer-main?_locale=de&_resolve=row1Content,row2Content,row3Content`,
|
||||
`${BASE}/api/content/page_config/page-config-default?_locale=de&_resolve=logo`,
|
||||
`${BASE}/api/content/page_config/default?_locale=de&_resolve=logo`,
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("Cache befüllen: .cache/cms");
|
||||
console.log("CMS-Basis:", BASE);
|
||||
const results = [];
|
||||
for (const url of BASE_URLS) {
|
||||
const r = await fetchAndCache(url);
|
||||
results.push(r);
|
||||
const status = r.ok ? r.status : r.error || r.status;
|
||||
console.log(r.ok ? " ✓" : " ✗", url.replace(BASE, ""), status);
|
||||
}
|
||||
|
||||
// Page-Slugs und einzelne Pages
|
||||
try {
|
||||
const pageRes = await fetch(`${BASE}/api/content/page?_per_page=200`);
|
||||
const pageData = await pageRes.json();
|
||||
const items = pageData?.items ?? [];
|
||||
for (const p of items.slice(0, 50)) {
|
||||
const slug = p._slug ?? p.slug ?? "";
|
||||
if (!slug) continue;
|
||||
const url = `${BASE}/api/content/page/${encodeURIComponent(slug)}?_locale=de&_resolve=all`;
|
||||
const r = await fetchAndCache(url);
|
||||
results.push(r);
|
||||
if (!r.ok) console.log(" ✗", slug, r.error || r.status);
|
||||
}
|
||||
if (items.length > 0) {
|
||||
console.log(" ✓", items.length, "Pages (erste 50 gecacht)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(" ✗ Pages-Liste", e.message);
|
||||
}
|
||||
|
||||
// Post-Slugs und einzelne Posts
|
||||
try {
|
||||
const postRes = await fetch(
|
||||
`${BASE}/api/content/post?_per_page=200&_resolve=postImage,postTag,row1Content,row2Content,row3Content`
|
||||
);
|
||||
const postData = await postRes.json();
|
||||
const items = postData?.items ?? [];
|
||||
for (const p of items.slice(0, 50)) {
|
||||
const slug = p._slug ?? p.slug ?? "";
|
||||
if (!slug) continue;
|
||||
const url = `${BASE}/api/content/post/${encodeURIComponent(slug)}?_locale=de&_resolve=postImage,postTag,row1Content,row2Content,row3Content`;
|
||||
const r = await fetchAndCache(url);
|
||||
results.push(r);
|
||||
if (!r.ok) console.log(" ✗", slug, r.error || r.status);
|
||||
}
|
||||
if (items.length > 0) {
|
||||
console.log(" ✓", items.length, "Posts (erste 50 gecacht)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(" ✗ Posts-Liste", e.message);
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length > 0) {
|
||||
console.log("\nFehlgeschlagen:", failed.length);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("\nCache befüllt.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
import Pagination from "./Pagination.svelte";
|
||||
import type { PostEntry } from "../lib/cms";
|
||||
import { postHref } from "../lib/blog-utils";
|
||||
import { t as tStatic, T } from "../lib/translations";
|
||||
import type { Translations } from "../lib/translations";
|
||||
|
||||
interface TagEntry {
|
||||
_slug?: string;
|
||||
@@ -18,6 +20,7 @@
|
||||
totalPages = 1,
|
||||
totalPosts = 0,
|
||||
basePath = "/posts",
|
||||
translations = {},
|
||||
}: {
|
||||
posts: PostEntry[];
|
||||
tags: TagEntry[];
|
||||
@@ -26,8 +29,14 @@
|
||||
totalPages: number;
|
||||
totalPosts?: number;
|
||||
basePath?: string;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
/** Immer Übersetzungen von der Astro-Seite nutzen (SSR + Insel), damit Keys wie blog_tag_all ankommen. */
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
|
||||
function tagHref(tagSlug: string | null): string {
|
||||
if (!tagSlug) return basePath;
|
||||
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
|
||||
@@ -39,9 +48,9 @@
|
||||
<div class="blog-overview">
|
||||
{#if tags.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="flex flex-wrap gap-2" role="navigation" aria-label="Nach Tag filtern">
|
||||
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
|
||||
<Tag
|
||||
label="Alle"
|
||||
label={t(T.blog_tag_all)}
|
||||
href={tagHref(null)}
|
||||
variant={activeTag ? "blue" : "green"}
|
||||
active={!activeTag}
|
||||
@@ -87,9 +96,19 @@
|
||||
<div class="bg-white rounded-md font-thin">
|
||||
<div class="inline-flex text-xs p-2">
|
||||
{#if totalCount > 0}
|
||||
{posts.length} von {totalCount} Beiträgen, Seite {currentPage} von {totalPages}
|
||||
{t(T.blog_count, {
|
||||
visible: posts.length,
|
||||
total: totalCount,
|
||||
current: currentPage,
|
||||
totalPages,
|
||||
})}
|
||||
{:else}
|
||||
Seite {currentPage} von {totalPages}
|
||||
{t(T.blog_count, {
|
||||
visible: 0,
|
||||
total: 0,
|
||||
current: currentPage,
|
||||
totalPages,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
import LinkListBlock from "./blocks/LinkListBlock.svelte";
|
||||
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
|
||||
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
|
||||
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
||||
import { isBlockLayoutBreakout, getSpaceBottomClass } from "../lib/block-layout";
|
||||
import type { BlockLayout } from "../lib/block-layout";
|
||||
import type { Translations } from "../lib/translations";
|
||||
import type {
|
||||
RowContentLayout,
|
||||
ResolvedBlock,
|
||||
@@ -26,13 +28,10 @@
|
||||
LinkListBlockData,
|
||||
PostOverviewBlockData,
|
||||
SearchableTextBlockData,
|
||||
CalendarBlockData,
|
||||
} from "../lib/block-types";
|
||||
|
||||
let {
|
||||
layout,
|
||||
class: className = "",
|
||||
searchableTextHelpTooltip,
|
||||
}: { layout: RowContentLayout; class?: string; searchableTextHelpTooltip?: string } = $props();
|
||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||
|
||||
function isResolvedBlock(item: unknown): item is ResolvedBlock {
|
||||
return typeof item === "object" && item !== null;
|
||||
@@ -100,7 +99,7 @@
|
||||
{:else if blockType(item) === "html"}
|
||||
<HtmlBlock block={item as HtmlBlockData} />
|
||||
{:else if blockType(item) === "iframe"}
|
||||
<IframeBlock block={item as IframeBlockData} />
|
||||
<IframeBlock block={item as IframeBlockData} translations={translations} />
|
||||
{:else if blockType(item) === "image"}
|
||||
<ImageBlock block={item as ImageBlockData} />
|
||||
{:else if blockType(item) === "image_gallery"}
|
||||
@@ -114,10 +113,9 @@
|
||||
{:else if blockType(item) === "post_overview"}
|
||||
<PostOverviewBlock block={item as PostOverviewBlockData} />
|
||||
{:else if blockType(item) === "searchable_text"}
|
||||
<SearchableTextBlock
|
||||
block={item as SearchableTextBlockData}
|
||||
helpTooltip={searchableTextHelpTooltip}
|
||||
/>
|
||||
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
|
||||
{:else if blockType(item) === "calendar"}
|
||||
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
|
||||
{:else}
|
||||
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Unbekannter Block: <code>{blockType(item)}</code>
|
||||
|
||||
@@ -6,10 +6,9 @@ import type { RowContentLayout } from "../lib/block-types";
|
||||
interface Props {
|
||||
layout: RowContentLayout;
|
||||
class?: string;
|
||||
searchableTextHelpTooltip?: string;
|
||||
}
|
||||
|
||||
const { layout, class: className = "", searchableTextHelpTooltip } = Astro.props;
|
||||
const { layout, class: className = "" } = Astro.props;
|
||||
|
||||
function justifyToClass(j?: string): string {
|
||||
switch (j) {
|
||||
@@ -90,11 +89,7 @@ function layoutBreakout(item: unknown): boolean {
|
||||
{row.content.map((item) => {
|
||||
const content = isResolvedBlock(item) ? (
|
||||
blockType(item) === "searchable_text" ? (
|
||||
<SearchableTextBlock
|
||||
client:load
|
||||
block={item}
|
||||
helpTooltip={searchableTextHelpTooltip}
|
||||
/>
|
||||
<SearchableTextBlock client:load block={item} />
|
||||
) : blockType(item) === "iframe" ||
|
||||
blockType(item) === "image_gallery" ? (
|
||||
<BlockRenderer client:load block={item} />
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import ContentRows from "./ContentRows.svelte";
|
||||
import type { RowContentLayout } from "../lib/block-types";
|
||||
import { t as tStatic, T } from "../lib/translations";
|
||||
import type { Translations } from "../lib/translations";
|
||||
|
||||
interface FooterEntry extends RowContentLayout {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { footer = null }: { footer?: FooterEntry | null } = $props();
|
||||
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
const justifyClass = $derived(
|
||||
footer?.row1JustifyContent === "end"
|
||||
@@ -37,12 +43,14 @@
|
||||
>
|
||||
<div class="container-custom py-6">
|
||||
{#if hasRowContent}
|
||||
<div class="content-footer">
|
||||
<ContentRows layout={footer} />
|
||||
<div class="content-footer text-xs">
|
||||
<ContentRows layout={footer} translations={translations} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-white/80 {justifyClass} flex">
|
||||
© {new Date().getFullYear()} RustyAstro
|
||||
{t(T.footer_copyright) !== T.footer_copyright
|
||||
? t(T.footer_copyright)
|
||||
: `© ${new Date().getFullYear()} RustyAstro`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import Search from "./Search.svelte";
|
||||
import { overlayOpen, overlayClose } from "../lib/overlay-store";
|
||||
import { t as tStatic, T } from "../lib/translations";
|
||||
import type { Translations } from "../lib/translations";
|
||||
|
||||
interface NavLink {
|
||||
href: string;
|
||||
@@ -16,11 +18,21 @@
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { links = [], socialLinks = [], logoUrl = null }: {
|
||||
let {
|
||||
links = [],
|
||||
socialLinks = [],
|
||||
logoUrl = null,
|
||||
translations = {},
|
||||
}: {
|
||||
links?: NavLink[];
|
||||
socialLinks?: SocialLink[];
|
||||
logoUrl?: string | null;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
let menuOpen = $state(false);
|
||||
let searchOpen = $state(false);
|
||||
|
||||
@@ -77,61 +89,59 @@
|
||||
|
||||
<header
|
||||
id="horizontal-navigation"
|
||||
class="sticky top-0 z-20 backdrop-blur bg-zinc-950/90 shadow-2xl"
|
||||
class="sticky top-0 z-20 backdrop-blur bg-stein-900/95 shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between container-custom py-2">
|
||||
<a
|
||||
href="/"
|
||||
class="logo flex items-center text-white font-bold text-lg tracking-wide hover:text-green-100 transition-colors"
|
||||
class="logo flex items-center text-stein-0 font-bold text-lg tracking-wide hover:text-wald-100 transition-colors relative"
|
||||
>
|
||||
{#if logoUrl && logoUrl.trim() !== ""}
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
class="h-12 w-auto object-contain"
|
||||
/>
|
||||
<img src={logoUrl} alt="" class="h-12 w-auto object-contain" />
|
||||
{:else}
|
||||
<span>RustyAstro</span>
|
||||
<span>{t(T.site_name_fallback)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Desktop: horizontale Nav (ab lg) -->
|
||||
<nav
|
||||
class="hidden lg:flex items-center gap-4"
|
||||
aria-label="Hauptnavigation"
|
||||
aria-label={t(T.header_nav_aria)}
|
||||
>
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-white/90 hover:text-green-100 hover:animate-pulse transition-all text-xs tracking-wide"
|
||||
class="text-stein-100 hover:text-wald-100 hover:animate-pulse transition-all text-xs tracking-wide"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
<div><span class="text-white/90">|</span></div>
|
||||
<div><span class="text-stein-100">|</span></div>
|
||||
{#each socialLinks as social}
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-1.5 -m-1.5 text-white/90 hover:text-green-100 hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={social.label || "Social Media"}
|
||||
class="p-1.5 -m-1.5 text-stein-100 hover:text-wald-100 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={social.label || t(T.header_social_aria)}
|
||||
>
|
||||
<Icon icon={social.icon} aria-hidden="true" />
|
||||
</a>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen ? "Suche schließen" : "Suche öffnen"}
|
||||
class="p-2 -mr-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen
|
||||
? t(T.header_search_close)
|
||||
: t(T.header_search_open)}
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="search-panel"
|
||||
onclick={() => (searchOpen = !searchOpen)}
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon icon="mdi:close" class="text-red-400!" aria-hidden="true" />
|
||||
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
|
||||
{:else}
|
||||
<Icon icon="mdi:magnify" class="text-emerald-400!" aria-hidden="true" />
|
||||
<Icon icon="mdi:magnify" class="text-wald-400!" aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
@@ -140,26 +150,38 @@
|
||||
<div class="lg:hidden flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 hover:text-white hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen ? "Suche schließen" : "Suche öffnen"}
|
||||
class="p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label={searchOpen
|
||||
? t(T.header_search_close)
|
||||
: t(T.header_search_open)}
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="search-panel"
|
||||
onclick={() => (searchOpen = !searchOpen)}
|
||||
>
|
||||
{#if searchOpen}
|
||||
<Icon icon="mdi:close" class="text-2xl text-red-400!" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="mdi:close"
|
||||
class="text-2xl text-error!"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{:else}
|
||||
<Icon icon="mdi:magnify" class="text-2xl text-emerald-400!" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="mdi:magnify"
|
||||
class="text-2xl text-wald-400!"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 -mr-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
|
||||
aria-label={menuOpen ? "Menü schließen" : "Menü öffnen"}
|
||||
class="p-2 -mr-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
|
||||
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
|
||||
aria-controls="mobile-nav"
|
||||
onclick={toggleMenu}
|
||||
>
|
||||
<span class="sr-only">{menuOpen ? "Menü schließen" : "Menü öffnen"}</span>
|
||||
<span class="sr-only"
|
||||
>{menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}</span
|
||||
>
|
||||
{#if menuOpen}
|
||||
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
|
||||
{:else}
|
||||
@@ -175,34 +197,43 @@
|
||||
{#if menuOpen}
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="lg:hidden border-t border-white/10 bg-zinc-950/95 backdrop-blur overflow-hidden"
|
||||
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
|
||||
role="dialog"
|
||||
aria-label="Navigation"
|
||||
in:slide={{ duration: 200 }}
|
||||
out:slide={{ duration: 150 }}
|
||||
>
|
||||
<nav class="container-custom py-4 flex flex-col gap-1" aria-label="Hauptnavigation">
|
||||
<nav
|
||||
class="container-custom py-4 flex flex-col gap-1"
|
||||
aria-label={t(T.header_nav_aria)}
|
||||
>
|
||||
{#each links as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="block py-3 px-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors text-sm"
|
||||
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if socialLinks.length > 0}
|
||||
<div class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-white/10">
|
||||
<div
|
||||
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
|
||||
>
|
||||
{#each socialLinks as social}
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex p-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors"
|
||||
aria-label={social.label || "Social Media"}
|
||||
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
|
||||
aria-label={social.label || t(T.header_social_aria)}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<Icon icon={social.icon} class="text-[1.5rem]" aria-hidden="true" />
|
||||
<Icon
|
||||
icon={social.icon}
|
||||
class="text-[1.5rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { overlayOpen, overlayClose } from "../lib/overlay-store";
|
||||
import { useTranslate } from "../lib/translations";
|
||||
import { T } from "../lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
function handleClick() {
|
||||
get(overlayClose)?.();
|
||||
@@ -11,7 +15,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[15] bg-black/50 cursor-default"
|
||||
aria-label="Menü bzw. Suche schließen"
|
||||
aria-label={t(T.header_overlay_close)}
|
||||
onclick={handleClick}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } from "../lib/translations";
|
||||
import { T } from "../lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
let {
|
||||
currentPage = 1,
|
||||
@@ -48,7 +52,7 @@
|
||||
<div class="inline-flex py-4">
|
||||
<div class="flex">
|
||||
{#if hasPrev}
|
||||
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
|
||||
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}>
|
||||
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
@@ -62,7 +66,7 @@
|
||||
</a>
|
||||
{/each}
|
||||
{#if hasNext}
|
||||
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
|
||||
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}>
|
||||
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { Translations, TranslationReplacements } from "../lib/translations";
|
||||
import { T_CONTEXT_KEY, replacePlaceholders } from "../lib/translations";
|
||||
|
||||
let {
|
||||
translations = {},
|
||||
children,
|
||||
}: {
|
||||
translations?: Translations;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const t = (key: string, replacements?: TranslationReplacements): string => {
|
||||
const raw = translations?.[key] ?? key;
|
||||
return replacePlaceholders(raw, replacements);
|
||||
};
|
||||
|
||||
setContext(T_CONTEXT_KEY, t);
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type {
|
||||
CalendarBlockData,
|
||||
CalendarItemData,
|
||||
} from "../../lib/block-types";
|
||||
import { t as tStatic, T } from "../../lib/translations";
|
||||
import type { Translations } from "../../lib/translations";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
type EventCardItem = CalendarItemData & {
|
||||
date: Date | null;
|
||||
timeStr: string;
|
||||
};
|
||||
|
||||
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const raw = block.items ?? [];
|
||||
return raw
|
||||
.filter(
|
||||
(x): x is CalendarItemData =>
|
||||
typeof x === "object" &&
|
||||
x !== null &&
|
||||
"terminZeit" in x &&
|
||||
"title" in x,
|
||||
)
|
||||
.map((x) => ({
|
||||
...x,
|
||||
date: parseDate(x.terminZeit),
|
||||
timeStr: formatTime(x.terminZeit),
|
||||
}))
|
||||
.filter((x) => x.date)
|
||||
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
|
||||
});
|
||||
|
||||
function parseDate(iso: string): Date | null {
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
|
||||
function getEventLinkHref(link: unknown): string {
|
||||
if (typeof link === "string" && link) return link;
|
||||
if (link && typeof link === "object") {
|
||||
const o = link as { url?: string; _slug?: string };
|
||||
if (typeof o.url === "string" && o.url) return o.url;
|
||||
if (typeof o._slug === "string" && o._slug)
|
||||
return `/post/${encodeURIComponent(o._slug)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
if (h === 0 && m === 0) return "";
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
|
||||
}
|
||||
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const eventsByDay = $derived.by(() => {
|
||||
const map = new Map<string, typeof items>();
|
||||
for (const item of items) {
|
||||
if (!item.date) continue;
|
||||
const key = dayKey(item.date);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(item);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let currentMonth = $state(new Date());
|
||||
let selectedDay = $state<string | null>(null);
|
||||
|
||||
const year = $derived(currentMonth.getFullYear());
|
||||
const month = $derived(currentMonth.getMonth());
|
||||
const monthLabel = $derived(
|
||||
currentMonth.toLocaleDateString("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const daysInMonth = $derived.by(() => {
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const count = last.getDate();
|
||||
const startWeekday = (first.getDay() + 6) % 7;
|
||||
return { count, startWeekday, last };
|
||||
});
|
||||
|
||||
const calendarDays = $derived.by(() => {
|
||||
const { count, startWeekday } = daysInMonth;
|
||||
const empty = Array(startWeekday).fill(null);
|
||||
const days = Array.from(
|
||||
{ length: count },
|
||||
(_, i) => new Date(year, month, i + 1),
|
||||
);
|
||||
return [...empty, ...days];
|
||||
});
|
||||
|
||||
const selectedDayEvents = $derived(
|
||||
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
|
||||
);
|
||||
|
||||
/** Nächste 3 Termine ab heute – zur Laufzeit gefiltert (statische App). */
|
||||
const nextUpcomingEvents = $derived.by(() => {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const todayMs = startOfToday.getTime();
|
||||
return items
|
||||
.filter((item) => item.date && item.date.getTime() >= todayMs)
|
||||
.slice(0, 3);
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth = new Date(year, month - 1);
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth = new Date(year, month + 1);
|
||||
selectedDay = null;
|
||||
}
|
||||
|
||||
function selectDay(key: string) {
|
||||
selectedDay = selectedDay === key ? null : key;
|
||||
}
|
||||
|
||||
const todayKey = $derived(dayKey(new Date()));
|
||||
|
||||
const weekdays = $derived([
|
||||
t(T.calendar_weekday_mo),
|
||||
t(T.calendar_weekday_di),
|
||||
t(T.calendar_weekday_mi),
|
||||
t(T.calendar_weekday_do),
|
||||
t(T.calendar_weekday_fr),
|
||||
t(T.calendar_weekday_sa),
|
||||
t(T.calendar_weekday_so),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
|
||||
data-block-type="calendar"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#snippet eventCard(item: EventCardItem)}
|
||||
{@const eventHref = getEventLinkHref(item.link)}
|
||||
<li class="bg-himmel-100 p-2">
|
||||
<div class="flex gap-1">
|
||||
{#if item.date}
|
||||
<div class="text-xs text-stein-500 mb-0.5">
|
||||
{item.date.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</div>
|
||||
{#if item.timeStr}
|
||||
<span class="text-xs text-stein-600">/</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item.timeStr}
|
||||
<div class="text-xs text-stein-600">
|
||||
{item.timeStr}
|
||||
{t(T.calendar_time_suffix)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
{:else}
|
||||
<div class="text-base font-medium text-stein-900">{item.title}</div>
|
||||
{/if}
|
||||
|
||||
{#if item.description}
|
||||
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
|
||||
{item.description}
|
||||
</p>
|
||||
{#if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||||
>
|
||||
{t(T.calendar_more_info)}
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
{:else if eventHref}
|
||||
<a
|
||||
href={eventHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||||
>
|
||||
{t(T.calendar_more_info)}
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
{#if block.title}
|
||||
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
|
||||
{block.title}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
|
||||
<!-- Links: Kalender (max 600px) -->
|
||||
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||||
aria-label={t(T.calendar_prev_month)}
|
||||
onclick={prevMonth}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
|
||||
>{monthLabel}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||||
aria-label={t(T.calendar_next_month)}
|
||||
onclick={nextMonth}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-0.5 text-center text-sm">
|
||||
{#each weekdays as w}
|
||||
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
|
||||
{/each}
|
||||
{#each calendarDays as d}
|
||||
{#if d === null}
|
||||
<div class="aspect-square" aria-hidden="true"></div>
|
||||
{:else}
|
||||
{@const key = dayKey(d)}
|
||||
{@const isCurrentMonth = d.getMonth() === month}
|
||||
{@const events = eventsByDay.get(key) ?? []}
|
||||
{@const hasEvents = events.length > 0}
|
||||
{@const isSelected = selectedDay === key}
|
||||
{@const isFuture = key >= todayKey}
|
||||
{#if hasEvents}
|
||||
<button
|
||||
type="button"
|
||||
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
|
||||
? 'text-himmel-100 hover:bg-himmel-700'
|
||||
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
|
||||
? 'ring-2 ring-himmel-400 bg-himmel-600'
|
||||
: ''}"
|
||||
onclick={() => selectDay(key)}
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{events.length}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
|
||||
? 'text-himmel-100'
|
||||
: 'text-himmel-100 opacity-50'}"
|
||||
>
|
||||
<span class="text-xs">{d.getDate()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechts: Wrapper relativ → Eventblock absolut (nur bei 2 Spalten, siehe global.css), Höhe = Kalender -->
|
||||
<div class="calendar-events-wrapper min-h-0">
|
||||
<div
|
||||
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{#if items.length === 0}
|
||||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
|
||||
{:else if selectedDay}
|
||||
<p class="text-xs font-medium text-stein-500 mb-2">
|
||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
|
||||
{#each selectedDayEvents as item}
|
||||
{@render eventCard(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if nextUpcomingEvents.length > 0}
|
||||
<p class="text-xs uppercase font-medium text-stein-500">
|
||||
{t(T.calendar_next_events)}
|
||||
</p>
|
||||
<ul
|
||||
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
|
||||
>
|
||||
{#each nextUpcomingEvents as item}
|
||||
{@render eventCard(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,8 +2,14 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { IframeBlockData } from "../../lib/block-types";
|
||||
import { t as tStatic, T } from "../../lib/translations";
|
||||
import type { Translations } from "../../lib/translations";
|
||||
|
||||
let { block }: { block: IframeBlockData } = $props();
|
||||
let { block, translations = {} }: { block: IframeBlockData; translations?: Translations | null } = $props();
|
||||
|
||||
function t(key: string) {
|
||||
return tStatic(translations ?? null, key);
|
||||
}
|
||||
|
||||
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen (client:load nötig). */
|
||||
let overlayActive = $state(true);
|
||||
@@ -40,7 +46,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 z-10 cursor-pointer rounded-sm bg-black/50 flex items-center justify-center"
|
||||
aria-label="Klicken zum Aktivieren der Karte"
|
||||
aria-label={t(T.iframe_activate_aria)}
|
||||
onclick={activateMap}
|
||||
onkeydown={(e) => e.key === "Enter" && activateMap()}
|
||||
>
|
||||
@@ -55,6 +61,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">Iframe-URL fehlt oder ist ungültig.</p>
|
||||
<p class="text-sm text-zinc-500">{t(T.iframe_missing)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,20 +4,33 @@
|
||||
|
||||
let { block }: { block: ImageBlockData } = $props();
|
||||
|
||||
function getUrl(b: ImageBlockData): string | null {
|
||||
if (typeof b.resolvedImageSrc === "string" && b.resolvedImageSrc) return b.resolvedImageSrc;
|
||||
const img = b.img ?? b.image;
|
||||
if (typeof img === "string" && img.startsWith("http")) return img;
|
||||
if (img && typeof img === "object") {
|
||||
const src = (img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
|
||||
if (typeof src === "string" && src) return src.startsWith("//") ? `https:${src}` : src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const imageSource = $derived(block.image ?? block.img);
|
||||
const imageUrl = $derived(
|
||||
block.resolvedImageSrc ??
|
||||
(typeof imageSource === "string"
|
||||
? imageSource.startsWith("http")
|
||||
? imageSource
|
||||
: null
|
||||
: imageSource && typeof imageSource === "object"
|
||||
? "src" in imageSource && typeof imageSource.src === "string"
|
||||
? imageSource.src
|
||||
: imageSource.file?.url ?? null
|
||||
: null),
|
||||
);
|
||||
const imageUrl = $derived(getUrl(block));
|
||||
$effect(() => {
|
||||
if (!imageUrl && block._slug) {
|
||||
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
|
||||
resolvedImageSrc: block.resolvedImageSrc,
|
||||
hasImage: !!block.image,
|
||||
hasImg: !!block.img,
|
||||
imageType: typeof block.image,
|
||||
imgType: typeof block.img,
|
||||
imgSrc: block.img && typeof block.img === "object" ? (block.img as { src?: string }).src : undefined,
|
||||
imageSrc: block.image && typeof block.image === "object" ? (block.image as { src?: string }).src : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
const title = $derived(
|
||||
typeof imageSource === "object" && imageSource
|
||||
? ("description" in imageSource && imageSource.description
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } from "../../lib/translations";
|
||||
import { T } from "../../lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
@@ -105,7 +108,7 @@
|
||||
{/if}
|
||||
|
||||
{#if withUrl.length === 0}
|
||||
<p class="text-sm text-stein-500">Keine Bilder in der Galerie.</p>
|
||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
||||
{:else if withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<img
|
||||
@@ -123,7 +126,7 @@
|
||||
<div
|
||||
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
|
||||
role="region"
|
||||
aria-label="Galerie: wischen zum Wechseln"
|
||||
aria-label={t(T.image_gallery_swipe_aria)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
@@ -159,7 +162,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label="Vorheriges Bild"
|
||||
aria-label={t(T.image_gallery_prev_aria)}
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
||||
@@ -167,18 +170,18 @@
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label="Nächstes Bild"
|
||||
aria-label={t(T.image_gallery_next_aria)}
|
||||
onclick={next}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label="Galerieposition">
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
|
||||
{#each withUrl as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label="Bild {i + 1}"
|
||||
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
|
||||
aria-selected={i === currentIndex}
|
||||
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
||||
? "bg-wald-600"
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
const html = $derived(
|
||||
block.content && typeof block.content === "string"
|
||||
block.resolvedContent ??
|
||||
(block.content && typeof block.content === "string"
|
||||
? (marked.parse(block.content) as string)
|
||||
: "",
|
||||
: ""),
|
||||
);
|
||||
|
||||
const alignClass = $derived(
|
||||
|
||||
@@ -10,21 +10,23 @@
|
||||
import Tag from "../Tag.svelte";
|
||||
import Tags from "../Tags.svelte";
|
||||
import Tooltip from "../../lib/ui/Tooltip.svelte";
|
||||
|
||||
const DEFAULT_HELP_TOOLTIP =
|
||||
"Hier könnt ihr in Titeln und Inhalten nach Stichwörtern suchen. Über die Schlagwörter unter dem Suchfeld lassen sich die Einträge eingrenzen. Öffnet einen Eintrag per Klick auf die Zeile; mit „Inhalt kopieren“ könnt ihr den Text in die Zwischenablage übernehmen (z. B. für Stellungnahmen).";
|
||||
import { t as tStatic, T } from "../../lib/translations";
|
||||
import type { Translations } from "../../lib/translations";
|
||||
|
||||
let {
|
||||
block,
|
||||
class: className = "",
|
||||
helpTooltip,
|
||||
translations = {},
|
||||
}: {
|
||||
block: SearchableTextBlockData;
|
||||
class?: string;
|
||||
helpTooltip?: string;
|
||||
translations?: Translations | null;
|
||||
} = $props();
|
||||
|
||||
const helpTooltipText = $derived(helpTooltip ?? DEFAULT_HELP_TOOLTIP);
|
||||
function t(key: string, replacements?: Record<string, string | number>) {
|
||||
return tStatic(translations ?? null, key, replacements);
|
||||
}
|
||||
const helpTooltipText = $derived(t(T.searchable_text_help));
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
@@ -33,11 +35,16 @@
|
||||
const fragments = $derived.by(() => {
|
||||
const raw = block.textFragments ?? [];
|
||||
return raw
|
||||
.filter((f): f is SearchableTextFragment => typeof f === "object" && f !== null && ("title" in f || "text" in f))
|
||||
.filter(
|
||||
(f): f is SearchableTextFragment =>
|
||||
typeof f === "object" && f !== null && ("title" in f || "text" in f),
|
||||
)
|
||||
.map((f) => {
|
||||
const tagsRaw = f.tags ?? [];
|
||||
const tags = tagsRaw
|
||||
.map((t) => (typeof t === "string" ? t : (t as { name?: string })?.name ?? ""))
|
||||
.map((t) =>
|
||||
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
title: (f.title ?? "").trim(),
|
||||
@@ -51,14 +58,19 @@
|
||||
});
|
||||
|
||||
const allTags = $derived(
|
||||
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) => a.localeCompare(b)),
|
||||
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
|
||||
let searchQuery = $state("");
|
||||
let selectedTag = $state("all");
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
|
||||
async function copyFragmentContent(fragment: { title: string; text: string }, index: number) {
|
||||
async function copyFragmentContent(
|
||||
fragment: { title: string; text: string },
|
||||
index: number,
|
||||
) {
|
||||
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(plain);
|
||||
@@ -110,7 +122,10 @@
|
||||
if (!term.trim()) return escapeHtml(text);
|
||||
const escaped = escapeRegex(term);
|
||||
const re = new RegExp(escaped, "gi");
|
||||
return escapeHtml(text).replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
|
||||
return escapeHtml(text).replace(
|
||||
re,
|
||||
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Suchbegriff in HTML-Content hervorheben (nur in Textknoten, nicht in Tags). */
|
||||
@@ -122,7 +137,10 @@
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith("<")) return part;
|
||||
return part.replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
|
||||
return part.replace(
|
||||
re,
|
||||
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -153,50 +171,64 @@
|
||||
>
|
||||
<div class="p-4 md:p-6">
|
||||
{#if block.title}
|
||||
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">{block.title}</h2>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">
|
||||
{block.title}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if descriptionHtml}
|
||||
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">{@html descriptionHtml}</div>
|
||||
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
|
||||
{@html descriptionHtml}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none">
|
||||
<header
|
||||
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Tooltip content={helpTooltipText} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
|
||||
aria-label="Hilfe zur Suche anzeigen"
|
||||
aria-label={t(T.searchable_text_help_aria)}
|
||||
>
|
||||
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="mdi:help-circle-outline"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="In Titeln und Inhalten suchen…"
|
||||
placeholder={t(T.searchable_text_placeholder)}
|
||||
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
||||
aria-label="Suche in Titeln und Inhalten"
|
||||
aria-label={t(T.searchable_text_search_aria)}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Suche leeren"
|
||||
aria-label={t(T.searchable_text_clear_search)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5" aria-hidden="true" />
|
||||
<Icon
|
||||
icon="mdi:magnify"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{#if allTags.length > 0}
|
||||
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
|
||||
<div class="flex gap-2 flex-nowrap min-w-max md:flex-wrap md:w-auto">
|
||||
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
|
||||
<Tag
|
||||
label="Alle"
|
||||
label={t(T.searchable_text_tag_all)}
|
||||
variant={selectedTag === "all" ? "green" : "white"}
|
||||
active={selectedTag === "all"}
|
||||
onclick={() => (selectedTag = "all")}
|
||||
@@ -215,14 +247,20 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200" aria-live="polite">
|
||||
<div
|
||||
class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if visibleFragments.length === 0}
|
||||
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
|
||||
<span>{t(T.searchable_text_no_results)}</span>
|
||||
{:else}
|
||||
<span>
|
||||
{visibleFragments.length === fragments.length
|
||||
? `${visibleFragments.length} Einträge`
|
||||
: `${visibleFragments.length} von ${fragments.length} Einträgen`}
|
||||
? t(T.searchable_text_count_all, { count: visibleFragments.length })
|
||||
: t(T.searchable_text_count_filtered, {
|
||||
visible: visibleFragments.length,
|
||||
total: fragments.length,
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -230,10 +268,21 @@
|
||||
<div class="flex flex-col gap-0 bg-stein-100">
|
||||
{#each visibleFragments as fragment, i}
|
||||
<details class="group border-b border-stein-200 last:border-b-0">
|
||||
<summary class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors">
|
||||
<summary
|
||||
class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<div class="grow min-w-0">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
|
||||
<Icon icon="mdi:chevron-down" class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform" aria-hidden="true" />
|
||||
<div class="grow min-w-0">
|
||||
{@html highlightInText(
|
||||
fragment.title || t(T.searchable_text_untitled),
|
||||
searchQuery.trim(),
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{#if fragment.tags.length > 0}
|
||||
<span class="flex flex-wrap gap-1">
|
||||
@@ -241,20 +290,33 @@
|
||||
</span>
|
||||
{/if}
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs">
|
||||
<div
|
||||
class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs"
|
||||
>
|
||||
<div class="flex justify-end mb-3 -mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
|
||||
onclick={(e) => { e.preventDefault(); copyFragmentContent(fragment, i); }}
|
||||
aria-label="Inhalt in Zwischenablage kopieren"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
copyFragmentContent(fragment, i);
|
||||
}}
|
||||
aria-label={t(T.searchable_text_copy_aria)}
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
|
||||
<span>Kopiert!</span>
|
||||
<Icon
|
||||
icon="mdi:check"
|
||||
class="size-4 text-wald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(T.searchable_text_copied)}</span>
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
|
||||
<span>Inhalt kopieren</span>
|
||||
<Icon
|
||||
icon="mdi:content-copy"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t(T.searchable_text_copy_button)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { YoutubeVideoBlockData } from "../../lib/block-types";
|
||||
import { useTranslate } from "../../lib/translations";
|
||||
import { T } from "../../lib/translations";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: YoutubeVideoBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
@@ -16,7 +19,7 @@
|
||||
{#if block.youtubeId}
|
||||
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
|
||||
<iframe
|
||||
title={block.title ?? "YouTube-Video"}
|
||||
title={block.title ?? t(T.youtube_title_fallback)}
|
||||
src={embedUrl}
|
||||
class="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
@@ -28,6 +31,6 @@
|
||||
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm text-stein-500">YouTube-Video-ID fehlt.</div>
|
||||
<div class="text-sm text-stein-500">{t(T.youtube_missing)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Vendored
+6
@@ -9,3 +9,9 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
translations?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
+60
-10
@@ -23,6 +23,10 @@ import {
|
||||
SITE_NAME,
|
||||
SOCIAL_IMAGE_TRANSFORM,
|
||||
} from "../lib/constants";
|
||||
import TranslationProvider from "../components/TranslationProvider.svelte";
|
||||
import { t, T } from "../lib/translations";
|
||||
import { getCmsFromCache } from "../lib/cms-cache-state";
|
||||
import type { TranslationKey } from "../lib/translations";
|
||||
import { marked } from "marked";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
@@ -47,8 +51,8 @@ interface Props {
|
||||
showBannerInLayout?: boolean;
|
||||
/** Optional: absoluter oder relativer URL für og:image / twitter:image (z. B. Post-Bild). */
|
||||
image?: string | null;
|
||||
/** Optional: Breadcrumb-Items. Letzter Eintrag ohne href = aktuelle Seite. */
|
||||
breadcrumbItems?: { href?: string; label: string }[];
|
||||
/** Optional: Breadcrumb-Items. Letzter Eintrag ohne href = aktuelle Seite. translationKey überschreibt label (übersetzt). */
|
||||
breadcrumbItems?: { href?: string; label?: string; translationKey?: TranslationKey }[];
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -62,6 +66,21 @@ const {
|
||||
breadcrumbItems = undefined,
|
||||
} = Astro.props;
|
||||
|
||||
// Übersetzungen aus Middleware (Astro.locals.translations) – für TranslationProvider und Breadcrumbs
|
||||
const translations: Record<string, string> = Astro.locals.translations ?? {};
|
||||
/** JSON für script type="application/json": </ escapen, damit das Tag nicht geschlossen wird. */
|
||||
function safeJsonScriptContent(obj: Record<string, string>): string {
|
||||
return JSON.stringify(obj).replace(/<\//g, "\\u003c/");
|
||||
}
|
||||
const resolvedBreadcrumbItems =
|
||||
breadcrumbItems?.map((item) => ({
|
||||
href: item.href,
|
||||
label:
|
||||
item.translationKey != null
|
||||
? t(translations, item.translationKey)
|
||||
: (item.label ?? ""),
|
||||
})) ?? [];
|
||||
|
||||
// Header: Navigation laden und Links bauen
|
||||
interface NavLink {
|
||||
href: string;
|
||||
@@ -251,7 +270,8 @@ const baseUrl = isLocalhost
|
||||
const pathname = Astro.url.pathname || "/";
|
||||
const canonical = `${baseUrl}${pathname}`;
|
||||
|
||||
// Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen)
|
||||
// Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen).
|
||||
// Fallback: Wenn kein Bild/Logo und Transform fehlschlägt → DEFAULT_SOCIAL_IMAGE_URL (absolut), damit WhatsApp/og immer ein Vorschaubild hat.
|
||||
const socialImageSource = imageProp ?? logoUrl ?? null;
|
||||
const needsTransform =
|
||||
!socialImageSource ||
|
||||
@@ -275,7 +295,9 @@ if (needsTransform) {
|
||||
// Bereits lokaler Pfad (z. B. von Post), nur baseUrl davor
|
||||
socialImageResolved = `${baseUrl}${urlToTransform.startsWith("/") ? "" : "/"}${urlToTransform}`;
|
||||
}
|
||||
const socialImage = socialImageResolved ?? "";
|
||||
const socialImage =
|
||||
socialImageResolved ??
|
||||
(urlToTransform.startsWith("http") ? urlToTransform : "");
|
||||
const siteName =
|
||||
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
|
||||
|
||||
@@ -342,15 +364,19 @@ const titleSubheadlineHtml =
|
||||
showTitleInContent && subheadline?.trim()
|
||||
? (marked.parseInline(subheadline) as string)
|
||||
: "";
|
||||
const cmsFromCache = getCmsFromCache();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="theme-color" content="#2d7a45" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
|
||||
<meta name="msapplication-TileColor" content="#2d7a45" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{seoTitle}</title>
|
||||
{seoDescription && <meta name="description" content={seoDescription} />}
|
||||
@@ -411,12 +437,34 @@ const titleSubheadlineHtml =
|
||||
/>
|
||||
)
|
||||
}
|
||||
<script
|
||||
src="https://analytics.pm86.de/api/script.js"
|
||||
data-site-id="ad2858cf2db1"
|
||||
defer
|
||||
/>
|
||||
</head>
|
||||
<body class="flex min-h-screen flex-col text-zinc-900">
|
||||
{
|
||||
import.meta.env.DEV && cmsFromCache && (
|
||||
<div
|
||||
class="sticky top-0 z-50 bg-amber-500 text-amber-950 text-center py-1.5 px-2 text-sm font-medium"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
CMS nicht erreichbar – Daten werden aus dem Cache (.cache/cms) geladen.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<script id="astro-translations" type="application/json" set:html={safeJsonScriptContent(translations)}></script>
|
||||
<script define:vars={{ translations }}>
|
||||
try { window.__TRANSLATIONS__ = typeof translations !== "undefined" && translations != null ? translations : {}; } catch (e) {}
|
||||
</script>
|
||||
<TranslationProvider translations={translations}>
|
||||
<Header
|
||||
links={headerLinks}
|
||||
socialLinks={socialLinks}
|
||||
logoUrl={logoUrl}
|
||||
translations={translations}
|
||||
client:load
|
||||
/>
|
||||
<HeaderOverlay client:load />
|
||||
@@ -433,11 +481,12 @@ const titleSubheadlineHtml =
|
||||
}
|
||||
<main class="flex-1 min-h-[60em]">
|
||||
<div class="container-custom pb-12">
|
||||
{breadcrumbItems && breadcrumbItems.length > 0 && (
|
||||
<Breadcrumbs items={breadcrumbItems} client:load />
|
||||
{resolvedBreadcrumbItems.length > 0 && (
|
||||
<Breadcrumbs items={resolvedBreadcrumbItems} client:load />
|
||||
)}
|
||||
{
|
||||
showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && (
|
||||
showTitleInContent &&
|
||||
(titleHeadlineHtml || titleSubheadlineHtml) && (
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
{titleHeadlineHtml && (
|
||||
<h1 class="pageTitle" set:html={titleHeadlineHtml} />
|
||||
@@ -451,7 +500,8 @@ const titleSubheadlineHtml =
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<Footer footer={footerData} />
|
||||
<Footer footer={footerData} translations={translations} />
|
||||
</TranslationProvider>
|
||||
<script src="/pagefind/pagefind-ui.js" is:inline></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+37
-3
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
|
||||
* Wird von Astro-Seiten und Svelte-Komponenten genutzt.
|
||||
* Kalender-Typen basieren auf der OpenAPI-Spec (cms-api.generated).
|
||||
*/
|
||||
|
||||
import type { BlockLayout } from "./block-layout";
|
||||
import type { components } from "./cms-api.generated";
|
||||
|
||||
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
|
||||
export type ResolvedBlock = {
|
||||
@@ -25,12 +27,14 @@ export interface RowContentLayout {
|
||||
row3AlignItems?: string;
|
||||
}
|
||||
|
||||
/** Resolved Markdown-Block vom CMS (_type: "markdown"). */
|
||||
/** Resolved Markdown-Block vom CMS (_type: "markdown"). resolvedContent = HTML mit transformierten Bildern (von resolveContentImages). */
|
||||
export interface MarkdownBlockData {
|
||||
_type?: "markdown";
|
||||
_slug?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
/** Gesetzt von resolveContentImages: HTML (Bilder transformiert, in Links eingewickelt). */
|
||||
resolvedContent?: string;
|
||||
alignment?: "left" | "center" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
@@ -68,11 +72,25 @@ export interface ImageBlockData {
|
||||
_slug?: string;
|
||||
image?:
|
||||
| string
|
||||
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
|
||||
| {
|
||||
_type?: "img";
|
||||
_slug?: string;
|
||||
src?: string;
|
||||
description?: string;
|
||||
file?: { url?: string };
|
||||
title?: string;
|
||||
};
|
||||
/** Alternative zu image (manche CMS-Responses liefern img). */
|
||||
img?:
|
||||
| string
|
||||
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
|
||||
| {
|
||||
_type?: "img";
|
||||
_slug?: string;
|
||||
src?: string;
|
||||
description?: string;
|
||||
file?: { url?: string };
|
||||
title?: string;
|
||||
};
|
||||
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
|
||||
resolvedImageSrc?: string;
|
||||
caption?: string;
|
||||
@@ -176,3 +194,19 @@ export interface SearchableTextBlockData {
|
||||
textFragments?: (string | SearchableTextFragment)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time. */
|
||||
export type CalendarItemData = components["schemas"]["calendar_item"];
|
||||
|
||||
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
|
||||
export type CalendarBlockData = Omit<
|
||||
components["schemas"]["calendar"],
|
||||
"items"
|
||||
> & {
|
||||
_type?: "calendar";
|
||||
/** Optionaler Titel über dem Kalender. */
|
||||
title?: string;
|
||||
/** Slugs oder aufgelöste calendar_item-Einträge. */
|
||||
items?: (string | CalendarItemData)[];
|
||||
layout?: BlockLayout;
|
||||
};
|
||||
|
||||
+69
-1
@@ -1,6 +1,14 @@
|
||||
import type { PostEntry } from "./cms";
|
||||
import { getPosts, getPostBySlug, getTags, getTextFragmentBySlug } from "./cms";
|
||||
import {
|
||||
getPosts,
|
||||
getPostBySlug,
|
||||
getTags,
|
||||
getTextFragmentBySlug,
|
||||
getCalendarBySlug,
|
||||
getCalendarItemBySlug,
|
||||
} from "./cms";
|
||||
import type { RowContentLayout } from "./block-types";
|
||||
import type { CalendarItemData } from "./block-types";
|
||||
/** rusty-image nur dynamisch importieren, damit client-seitige Importe (z. B. PostOverviewBlock) nicht node:crypto in den Browser ziehen. */
|
||||
|
||||
/** Slug → Anzeigename (name) aus der Tag-API. Für Auflösung von post.postTag in Lesbarkeit. */
|
||||
@@ -253,6 +261,66 @@ export async function resolvePostOverviewBlocks(
|
||||
return tagsMap;
|
||||
}
|
||||
|
||||
function isCalendarBlock(
|
||||
item: unknown,
|
||||
): item is { _type?: string; _slug?: string; items?: unknown[] } {
|
||||
return (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
(item as { _type?: string })._type === "calendar"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items
|
||||
* mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link).
|
||||
*/
|
||||
export async function resolveCalendarBlocks(
|
||||
layout: RowContentLayout,
|
||||
): Promise<void> {
|
||||
const rows = [
|
||||
layout.row1Content ?? [],
|
||||
layout.row2Content ?? [],
|
||||
layout.row3Content ?? [],
|
||||
];
|
||||
|
||||
for (const content of rows) {
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const item of content) {
|
||||
if (!isCalendarBlock(item)) continue;
|
||||
const slug = item._slug;
|
||||
if (!slug) continue;
|
||||
const rawItems = item.items ?? [];
|
||||
const first = rawItems[0];
|
||||
const alreadyResolved =
|
||||
typeof first === "object" &&
|
||||
first !== null &&
|
||||
"terminZeit" in first &&
|
||||
"title" in first;
|
||||
if (alreadyResolved) continue;
|
||||
|
||||
const calendar = await getCalendarBySlug(slug, {
|
||||
locale: "de",
|
||||
resolve: "items",
|
||||
});
|
||||
const raw = calendar?.items ?? rawItems;
|
||||
const resolved: CalendarItemData[] = [];
|
||||
for (const x of raw) {
|
||||
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
|
||||
resolved.push(x as CalendarItemData);
|
||||
} else {
|
||||
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
|
||||
if (islug) {
|
||||
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
|
||||
if (entry) resolved.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
(item as { items?: CalendarItemData[] }).items = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSearchableTextBlock(
|
||||
item: unknown,
|
||||
): item is { _type?: string; textFragments?: unknown[] } {
|
||||
|
||||
+3383
-353
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Nur das Cache-Flag (ob in diesem Request aus Cache gelesen wurde).
|
||||
* Keine Node-Abhängigkeiten – kann von Layout/Middleware importiert werden.
|
||||
* Die eigentliche Cache-Logik (Node fs/crypto) lebt in cms-cache.ts und wird nur per dynamic import geladen.
|
||||
*/
|
||||
|
||||
let usedCacheThisRequest = false;
|
||||
|
||||
export function resetCmsFromCache(): void {
|
||||
usedCacheThisRequest = false;
|
||||
}
|
||||
|
||||
export function getCmsFromCache(): boolean {
|
||||
return usedCacheThisRequest;
|
||||
}
|
||||
|
||||
export function setCmsFromCacheUsed(): void {
|
||||
usedCacheThisRequest = true;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Cache für CMS-API-Responses (nur serverseitig, Node fs/crypto).
|
||||
* Wird ausschließlich per dynamic import aus cms.ts geladen – nie von Layout/Middleware,
|
||||
* damit es nicht ins Client-Bundle gelangt. Flag/State: cms-cache-state.ts.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { setCmsFromCacheUsed } from "./cms-cache-state";
|
||||
|
||||
const CACHE_DIR = `${process.cwd()}/.cache/cms`;
|
||||
|
||||
function cacheKeyFromUrl(url: string): string {
|
||||
return createHash("sha256").update(url).digest("hex");
|
||||
}
|
||||
|
||||
async function ensureCacheDir(): Promise<void> {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
async function getCached(key: string): Promise<CachedResponse | null> {
|
||||
try {
|
||||
const path = `${CACHE_DIR}/${key}.json`;
|
||||
if (!existsSync(path)) return null;
|
||||
const raw = await readFile(path, "utf-8");
|
||||
const data = JSON.parse(raw) as CachedResponse;
|
||||
return data?.body != null ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setCached(key: string, data: CachedResponse): Promise<void> {
|
||||
try {
|
||||
await ensureCacheDir();
|
||||
const path = `${CACHE_DIR}/${key}.json`;
|
||||
await writeFile(path, JSON.stringify(data), "utf-8");
|
||||
} catch {
|
||||
// Cache schreiben optional
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch mit Cache: bei Erfolg wird die Response in .cache/cms gespeichert (überschreibt vorhanden).
|
||||
* Bei Fehler wird versucht, aus dem Cache zu lesen; wenn gefunden, wird usedCacheThisRequest gesetzt.
|
||||
*/
|
||||
export async function cachedFetch(url: string): Promise<Response> {
|
||||
const key = cacheKeyFromUrl(url);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const body = await res.text();
|
||||
await setCached(key, { status: res.status, body });
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
});
|
||||
} catch {
|
||||
const cached = await getCached(key);
|
||||
if (cached) {
|
||||
setCmsFromCacheUsed();
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`CMS nicht erreichbar (${url}) und kein Cache für diese Anfrage.`);
|
||||
}
|
||||
}
|
||||
+111
-46
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen.
|
||||
* Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000).
|
||||
* API-Responses werden in .cache/cms gespeichert; bei nicht erreichbarem CMS wird aus dem Cache gelesen.
|
||||
*
|
||||
* Konvention: slug = URL (für Links/Pfade), _slug = API/interne ID (für GET-Requests).
|
||||
* Typen für Page und API-Antworten kommen aus der generierten OpenAPI-Spec.
|
||||
@@ -9,6 +10,25 @@
|
||||
|
||||
import type { components, operations } from "./cms-api.generated";
|
||||
|
||||
/** Lazy-Import nur bei SSR – cms-cache (Node-only) darf nicht ins Client-Bundle. */
|
||||
let cachedFetchFn: ((url: string) => Promise<Response>) | null = null;
|
||||
async function getCachedFetch(): Promise<(url: string) => Promise<Response>> {
|
||||
if (!cachedFetchFn) {
|
||||
if (import.meta.env.SSR) {
|
||||
// In Dev immer direkt zur API, damit sofort sichtbar ist, was die API liefert (kein Disk-Cache).
|
||||
if (import.meta.env.DEV) {
|
||||
cachedFetchFn = (url: string) => fetch(url);
|
||||
} else {
|
||||
const m = await import("./cms-cache");
|
||||
cachedFetchFn = m.cachedFetch;
|
||||
}
|
||||
} else {
|
||||
cachedFetchFn = (url: string) => fetch(url);
|
||||
}
|
||||
}
|
||||
return cachedFetchFn;
|
||||
}
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
const url = import.meta.env.PUBLIC_CMS_URL;
|
||||
const base = (
|
||||
@@ -43,6 +63,8 @@ let openApiCache: OpenApiSpec | null = null;
|
||||
|
||||
/** Einfacher Request-Cache: gleiche Aufrufe (pro Prozess) nur einmal ausführen. Reduziert doppelte CMS-Requests bei Layout + Page. */
|
||||
const listCache = new Map<string, Promise<unknown>>();
|
||||
/** In Dev keinen List-Cache, damit neue CMS-Inhalte sofort erscheinen. */
|
||||
const skipListCache = typeof import.meta.env !== "undefined" && import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
|
||||
@@ -51,7 +73,7 @@ const listCache = new Map<string, Promise<unknown>>();
|
||||
export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
if (openApiCache) return openApiCache;
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api-docs/openapi.json`);
|
||||
const res = await (await getCachedFetch())(`${base}/api-docs/openapi.json`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
||||
@@ -67,11 +89,13 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
*/
|
||||
export async function getPages(): Promise<PageEntry[]> {
|
||||
const key = "getPages";
|
||||
let p = listCache.get(key) as Promise<PageEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
if (!skipListCache) {
|
||||
const p = listCache.get(key) as Promise<PageEntry[]> | undefined;
|
||||
if (p) return p;
|
||||
}
|
||||
const p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
const res = await (await getCachedFetch())(`${base}/api/content/page`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -80,8 +104,7 @@ export async function getPages(): Promise<PageEntry[]> {
|
||||
const data = (await res.json()) as PageListResponse;
|
||||
return data.items ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
if (!skipListCache) listCache.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -97,7 +120,7 @@ export async function getPageConfigs(options?: {
|
||||
const url = new URL(`${base}/api/content/page_config`);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
url.searchParams.set("_per_page", String(options?.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -116,16 +139,18 @@ export async function getPageConfigBySlug(
|
||||
): Promise<PageConfigEntry | null> {
|
||||
const opts = options ?? {};
|
||||
const key = `getPageConfigBySlug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
|
||||
let p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
if (!skipListCache) {
|
||||
const p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
|
||||
if (p) return p;
|
||||
}
|
||||
const p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/page_config/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -134,8 +159,7 @@ export async function getPageConfigBySlug(
|
||||
}
|
||||
return (await res.json()) as PageConfigEntry;
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
if (!skipListCache) listCache.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -181,12 +205,12 @@ export async function getPageBySlug(
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PageEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const trySlug = (s: string): Promise<Response> => {
|
||||
const trySlug = async (s: string): Promise<Response> => {
|
||||
const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`);
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
u.searchParams.set("_resolve", options.resolve.join(","));
|
||||
return fetch(u.toString());
|
||||
return (await getCachedFetch())(u.toString());
|
||||
};
|
||||
let res = await trySlug(slug);
|
||||
if (res.status === 404 && !slug.startsWith("/")) {
|
||||
@@ -250,24 +274,24 @@ export async function getNavigations(options?: {
|
||||
}> {
|
||||
const opts = options ?? {};
|
||||
const key = `getNavigations:${opts.locale ?? ""}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
|
||||
let p = listCache.get(key) as
|
||||
| Promise<{
|
||||
if (!skipListCache) {
|
||||
const p = listCache.get(key) as Promise<{
|
||||
items: NavigationEntry[];
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}>
|
||||
| undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
}> | undefined;
|
||||
if (p) return p;
|
||||
}
|
||||
const p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/navigation`);
|
||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||
url.searchParams.set("_page", String(opts.page ?? 1));
|
||||
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
@@ -282,8 +306,7 @@ export async function getNavigations(options?: {
|
||||
total_pages: data.total_pages ?? 1,
|
||||
};
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
if (!skipListCache) listCache.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -332,7 +355,7 @@ export async function getNavigationBySlug(
|
||||
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as NavigationEntry;
|
||||
@@ -354,7 +377,7 @@ export async function getFooterBySlug(
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
url.searchParams.set("_resolve", options.resolve.join(","));
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -384,12 +407,12 @@ export async function getPostBySlug(
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PostEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const trySlug = (s: string): Promise<Response> => {
|
||||
const trySlug = async (s: string): Promise<Response> => {
|
||||
const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`);
|
||||
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve?.length)
|
||||
u.searchParams.set("_resolve", options.resolve.join(","));
|
||||
return fetch(u.toString());
|
||||
return (await getCachedFetch())(u.toString());
|
||||
};
|
||||
let res = await trySlug(slug);
|
||||
if (res.status === 404 && !slug.startsWith("/")) {
|
||||
@@ -441,21 +464,22 @@ export async function getPosts(
|
||||
? opts.resolve.join(",")
|
||||
: (opts.resolve ?? "");
|
||||
const key = `getPosts:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
|
||||
let p = listCache.get(key) as Promise<PostEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
if (!skipListCache) {
|
||||
const p = listCache.get(key) as Promise<PostEntry[]> | undefined;
|
||||
if (p) return p;
|
||||
}
|
||||
const p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/post`);
|
||||
if (opts._sort) url.searchParams.set("_sort", opts._sort);
|
||||
if (opts._order) url.searchParams.set("_order", opts._order);
|
||||
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (!res.ok) throw new Error("RustyCMS list post failed");
|
||||
const data = (await res.json()) as { items?: PostEntry[] };
|
||||
return data.items ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
if (!skipListCache) listCache.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -465,17 +489,18 @@ export type TagEntry = components["schemas"]["tag"];
|
||||
/** Alle Tags (GET /api/content/tag). Gecacht pro Prozess. */
|
||||
export async function getTags(): Promise<TagEntry[]> {
|
||||
const key = "getTags";
|
||||
let p = listCache.get(key) as Promise<TagEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
if (!skipListCache) {
|
||||
const p = listCache.get(key) as Promise<TagEntry[]> | undefined;
|
||||
if (p) return p;
|
||||
}
|
||||
const p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/tag`);
|
||||
const res = await (await getCachedFetch())(`${base}/api/content/tag`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items?: TagEntry[] };
|
||||
return data.items ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
if (!skipListCache) listCache.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -492,7 +517,7 @@ export async function getTextFragmentBySlug(
|
||||
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -515,7 +540,7 @@ export async function getFullwidthBannerBySlug(
|
||||
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -541,7 +566,7 @@ export async function getTranslationBySlug(
|
||||
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
@@ -558,7 +583,7 @@ export type TranslationBundleEntry = {
|
||||
/**
|
||||
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
|
||||
* Liefert ein Objekt mit allen UI-Strings (z. B. strings.searchable_text_help).
|
||||
* Slug z. B. "de" für content/de/translation_bundle/de.json5.
|
||||
* Mit _resolve=all werden Referenzen in strings aufgelöst (z. B. blog_tag_all).
|
||||
*/
|
||||
export async function getTranslationBundleBySlug(
|
||||
slug: string,
|
||||
@@ -568,11 +593,51 @@ export async function getTranslationBundleBySlug(
|
||||
const url = new URL(
|
||||
`${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
url.searchParams.set("_resolve", "all");
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as TranslationBundleEntry;
|
||||
}
|
||||
|
||||
/** Kalender-Eintrag (calendar) – aus OpenAPI-Spec. */
|
||||
export type CalendarEntry = components["schemas"]["calendar"];
|
||||
|
||||
/** Kalender-Item (calendar_item) – aus OpenAPI-Spec. */
|
||||
export type CalendarItemEntry = components["schemas"]["calendar_item"];
|
||||
|
||||
/** Kalender anhand Slug (GET /api/content/calendar/:slug). */
|
||||
export async function getCalendarBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string; resolve?: string },
|
||||
): Promise<CalendarEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/calendar/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarEntry;
|
||||
}
|
||||
|
||||
/** Einzelnes calendar_item anhand Slug (GET /api/content/calendar_item/:slug). */
|
||||
export async function getCalendarItemBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<CalendarItemEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await (await getCachedFetch())(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CalendarItemEntry;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,14 @@ export const ROW_RESOLVE: string[] = [
|
||||
/** Resolve für Page-Requests: "all" = alle Referenzen inkl. verschachtelt (z. B. image_gallery.images). */
|
||||
export const PAGE_RESOLVE = ["all"];
|
||||
|
||||
/** Resolve für Post-Requests (Rows + Post-Bild, Tags). */
|
||||
export const POST_RESOLVE = [...ROW_RESOLVE, "postImage", "postTag"];
|
||||
/** Resolve für Post-Requests: "all" damit auch img in row1Content/Image-Blöcken aufgelöst wird (src), sonst nur Referenz. */
|
||||
export const POST_RESOLVE = ["all"];
|
||||
|
||||
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
|
||||
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
|
||||
|
||||
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
|
||||
export const SITE_NAME = "RustyAstro";
|
||||
|
||||
/** Slug des Translation-Bundles im CMS (content/de/translation_bundle/{slug}.json5). Alle UI-Texte aus diesem Bundle. */
|
||||
export const TRANSLATION_BUNDLE_SLUG = "app";
|
||||
|
||||
+96
-3
@@ -24,6 +24,8 @@ export interface RustyImageTransformParams {
|
||||
|
||||
const DEFAULT_FORMAT = "jpeg";
|
||||
const SUBDIR = "images/transformed";
|
||||
/** Persistenter Cache für Builds – überlebt "astro build" (dist wird geleert). */
|
||||
const TRANSFORM_CACHE_DIR = ".cache/transformed-images";
|
||||
|
||||
function getCmsBaseUrl(): string {
|
||||
const url = import.meta.env.PUBLIC_CMS_URL;
|
||||
@@ -105,12 +107,26 @@ export async function ensureTransformedImage(
|
||||
const filePath = path.join(dir, filename);
|
||||
const publicPath = `/${SUBDIR}/${filename}`;
|
||||
|
||||
// Build: zuerst persistenten Cache prüfen (dist wird bei jedem Build geleert).
|
||||
const cacheDir = path.join(projectRoot, TRANSFORM_CACHE_DIR);
|
||||
const cacheFilePath = path.join(cacheDir, filename);
|
||||
if (isBuild) {
|
||||
try {
|
||||
await fs.access(cacheFilePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.copyFile(cacheFilePath, filePath);
|
||||
return publicPath;
|
||||
} catch {
|
||||
/* nicht im Cache, laden und in Cache + dist schreiben */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return publicPath;
|
||||
} catch {
|
||||
/* Datei existiert nicht, laden und schreiben */
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = getCmsBaseUrl();
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -135,6 +151,10 @@ export async function ensureTransformedImage(
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
if (isBuild) {
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
await fs.writeFile(cacheFilePath, buffer);
|
||||
}
|
||||
|
||||
return publicPath;
|
||||
}
|
||||
@@ -203,14 +223,73 @@ export interface RowContentLayoutLike {
|
||||
row3Content?: unknown[];
|
||||
}
|
||||
|
||||
/** Parameter für resolveContentImages (optional baseUrl für Markdown-Bild-Links). */
|
||||
export interface ResolveContentImagesOptions {
|
||||
transformParams?: RustyImageTransformParams;
|
||||
/** Basis-URL für Links auf Bilder (z. B. Astro.url.origin oder SITE). Fehlt → aus import.meta.env.SITE. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/** Markdown-Inline-Bilder: 1200px Breite, WebP, Link öffnet dasselbe Bild in neuem Tab. */
|
||||
const MARKDOWN_IMAGE_PARAMS: RustyImageTransformParams = {
|
||||
width: 1200,
|
||||
fit: "contain",
|
||||
format: "webp",
|
||||
quality: 85,
|
||||
};
|
||||
|
||||
const IMG_SRC_REGEX =
|
||||
/<img\s+([^>]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
|
||||
|
||||
/**
|
||||
* Geht alle Content-Rows durch und setzt bei Image-Blöcken resolvedImageSrc
|
||||
* (transformiert über RustyCMS und speichert in public).
|
||||
* Ersetzt in HTML alle <img src="...">: Bild-URL wird transformiert (RustyImage),
|
||||
* Bild wird in <a href="..." target="_blank"> eingewickelt (öffnet Bild in neuem Tab).
|
||||
* href ist relativ (z. B. /images/transformed/…), damit auf localhost und Production dieselbe Seite funktioniert.
|
||||
*/
|
||||
export async function processMarkdownHtmlImages(
|
||||
html: string,
|
||||
_baseUrl?: string,
|
||||
transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS,
|
||||
): Promise<string> {
|
||||
const matches = [...html.matchAll(IMG_SRC_REGEX)];
|
||||
const replacements: { index: number; length: number; newBlock: string }[] = [];
|
||||
for (const m of matches) {
|
||||
const rawSrc = m[2];
|
||||
const url = rawSrc.startsWith("//") ? `https:${rawSrc}` : rawSrc;
|
||||
if (!url.startsWith("http")) continue;
|
||||
try {
|
||||
const transformedPath = await ensureTransformedImage(url, transformParams);
|
||||
const href = transformedPath.startsWith("/") ? transformedPath : `/${transformedPath}`;
|
||||
const newImg = m[0].replace(rawSrc, transformedPath);
|
||||
const newBlock = `<a href="${href}" target="_blank" rel="noopener noreferrer" class="markdown-image-link">${newImg}</a>`;
|
||||
replacements.push({ index: m.index ?? 0, length: m[0].length, newBlock });
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] markdown img resolve failed for", url, e);
|
||||
}
|
||||
}
|
||||
if (replacements.length === 0) return html;
|
||||
replacements.sort((a, b) => b.index - a.index);
|
||||
let out = html;
|
||||
for (const r of replacements) {
|
||||
out = out.slice(0, r.index) + r.newBlock + out.slice(r.index + r.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geht alle Content-Rows durch: Image-Blöcke → resolvedImageSrc;
|
||||
* Markdown-Blöcke → resolvedContent (HTML mit transformierten Bildern + Link-Wrapper).
|
||||
*/
|
||||
export async function resolveContentImages(
|
||||
layout: RowContentLayoutLike,
|
||||
transformParams: RustyImageTransformParams = {},
|
||||
transformParamsOrOptions: RustyImageTransformParams | ResolveContentImagesOptions = {},
|
||||
): Promise<void> {
|
||||
const options: ResolveContentImagesOptions =
|
||||
transformParamsOrOptions && "baseUrl" in transformParamsOrOptions
|
||||
? (transformParamsOrOptions as ResolveContentImagesOptions)
|
||||
: { transformParams: transformParamsOrOptions as RustyImageTransformParams };
|
||||
const transformParams = options.transformParams ?? {};
|
||||
|
||||
const rows = [
|
||||
layout.row1Content,
|
||||
layout.row2Content,
|
||||
@@ -227,16 +306,30 @@ export async function resolveContentImages(
|
||||
...transformParams,
|
||||
};
|
||||
|
||||
const { marked } = await import("marked");
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
for (const content of rows) {
|
||||
for (const item of content) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const block = item as {
|
||||
_type?: string;
|
||||
content?: string;
|
||||
image?: unknown;
|
||||
img?: unknown;
|
||||
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
|
||||
resolvedImageSrc?: string;
|
||||
resolvedContent?: string;
|
||||
};
|
||||
if (block._type === "markdown" && typeof block.content === "string" && block.content.trim()) {
|
||||
try {
|
||||
const html = marked.parse(block.content) as string;
|
||||
block.resolvedContent = await processMarkdownHtmlImages(html);
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] markdown block resolve failed", e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block._type === "image") {
|
||||
const url = getImageBlockUrl(
|
||||
block as {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Zentrale Übersetzungen (i18n-ähnlich).
|
||||
* Quelle: CMS translation_bundle (API, Middleware setzt Astro.locals.translations).
|
||||
* Wird in Layout geladen und über TranslationProvider/Svelte-Context bereitgestellt.
|
||||
*
|
||||
* Wichtig für client:load-Inseln: useTranslate() hat dort keinen Context und beim SSR kein window,
|
||||
* daher fehlen Übersetzungen (es erscheint der Key). Lösung: Übersetzungen als Prop von der
|
||||
* Astro-Seite übergeben und t(translations, key, replacements) nutzen. Siehe .cursor/rules/translations-i18n.mdc.
|
||||
*/
|
||||
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export type Translations = Record<string, string>;
|
||||
|
||||
/** Context-Key für die t-Funktion (von TranslationProvider gesetzt). */
|
||||
export const T_CONTEXT_KEY = Symbol("translations.t");
|
||||
|
||||
/** Platzhalter für Ersetzung: z. B. { name: "Max" } ersetzt {{name}} im Übersetzungstext. */
|
||||
export type TranslationReplacements = Record<string, string | number>;
|
||||
|
||||
/** t(key) oder t(key, { placeholder: value }) – mit optionalen Ersetzungen für {{placeholder}}. */
|
||||
export type TFunction = (
|
||||
key: string,
|
||||
replacements?: TranslationReplacements,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Ersetzt {{name}} im Text durch replacements[name]; fehlende Keys bleiben als {{name}}.
|
||||
*/
|
||||
export function replacePlaceholders(
|
||||
text: string,
|
||||
replacements?: TranslationReplacements | null,
|
||||
): string {
|
||||
if (!replacements || typeof text !== "string") return text;
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
|
||||
const val = replacements[name];
|
||||
return val !== undefined && val !== null ? String(val) : `{{${name}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Liest Übersetzungen aus window oder aus script#astro-translations (Fallback für Inseln). */
|
||||
function getTranslationsFromWindow(): Translations | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as unknown as { __TRANSLATIONS__?: Translations };
|
||||
if (w.__TRANSLATIONS__ && typeof w.__TRANSLATIONS__ === "object") return w.__TRANSLATIONS__;
|
||||
try {
|
||||
const el = document.getElementById("astro-translations");
|
||||
const json =
|
||||
el?.getAttribute("data-translations") ??
|
||||
(el as HTMLScriptElement | null)?.textContent?.trim();
|
||||
if (json) {
|
||||
const parsed = JSON.parse(json) as Translations;
|
||||
w.__TRANSLATIONS__ = parsed;
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* t-Funktion aus Svelte-Context oder aus window.__TRANSLATIONS__.
|
||||
* Nur zuverlässig innerhalb des TranslationProvider-Baums. Für client:load-Inseln:
|
||||
* Übersetzungen als Prop übergeben und statische t(translations, key) nutzen (siehe Regel translations-i18n).
|
||||
*/
|
||||
export function useTranslate(): TFunction {
|
||||
const fromContext = getContext<TFunction | undefined>(T_CONTEXT_KEY);
|
||||
if (fromContext) return fromContext;
|
||||
const warnedKeys = new Set<string>();
|
||||
return (key: string, replacements?: TranslationReplacements): string => {
|
||||
const translations = getTranslationsFromWindow();
|
||||
const raw = translations?.[key] ?? key;
|
||||
if (
|
||||
typeof import.meta.env !== "undefined" &&
|
||||
import.meta.env.DEV &&
|
||||
raw === key &&
|
||||
key.length > 0 &&
|
||||
!warnedKeys.has(key)
|
||||
) {
|
||||
warnedKeys.add(key);
|
||||
console.warn(
|
||||
`[translations] Key "${key}" in Insel nicht gefunden (kein Context/window). ` +
|
||||
`Übersetzungen als Prop von der Astro-Seite übergeben und t(translations, key) nutzen. Siehe .cursor/rules/translations-i18n.mdc`,
|
||||
);
|
||||
}
|
||||
return replacePlaceholders(raw, replacements);
|
||||
};
|
||||
}
|
||||
|
||||
/** Bekannte Übersetzungs-Keys – nur hier eintragen, Nutzung über T.xy (Autocomplete, keine Tippfehler). */
|
||||
const TRANSLATION_KEYS = [
|
||||
"searchable_text_help",
|
||||
"searchable_text_help_aria",
|
||||
"searchable_text_placeholder",
|
||||
"searchable_text_search_aria",
|
||||
"searchable_text_clear_search",
|
||||
"searchable_text_tag_all",
|
||||
"searchable_text_no_results",
|
||||
"searchable_text_count_all",
|
||||
"searchable_text_count_filtered",
|
||||
"searchable_text_copy_aria",
|
||||
"searchable_text_copy_button",
|
||||
"searchable_text_copied",
|
||||
"searchable_text_untitled",
|
||||
"footer_copyright",
|
||||
"nav_start",
|
||||
"nav_posts",
|
||||
"nav_home",
|
||||
"header_search_open",
|
||||
"header_search_close",
|
||||
"header_menu_open",
|
||||
"header_menu_close",
|
||||
"header_nav_aria",
|
||||
"header_overlay_close",
|
||||
"header_social_aria",
|
||||
"site_name_fallback",
|
||||
"pagination_prev",
|
||||
"pagination_next",
|
||||
"blog_tag_all",
|
||||
"blog_filter_aria",
|
||||
"blog_count",
|
||||
"page_404_title",
|
||||
"page_404_text",
|
||||
"page_404_back",
|
||||
"posts_title",
|
||||
"posts_subtitle",
|
||||
"posts_tag_title",
|
||||
"posts_tag_subtitle",
|
||||
"posts_cms_error",
|
||||
"iframe_activate_aria",
|
||||
"iframe_missing",
|
||||
"image_gallery_swipe_aria",
|
||||
"image_gallery_prev_aria",
|
||||
"image_gallery_next_aria",
|
||||
"image_gallery_position_aria",
|
||||
"image_gallery_slide_aria",
|
||||
"image_gallery_empty",
|
||||
"youtube_missing",
|
||||
"youtube_title_fallback",
|
||||
"calendar_prev_month",
|
||||
"calendar_next_month",
|
||||
"calendar_weekday_mo",
|
||||
"calendar_weekday_di",
|
||||
"calendar_weekday_mi",
|
||||
"calendar_weekday_do",
|
||||
"calendar_weekday_fr",
|
||||
"calendar_weekday_sa",
|
||||
"calendar_weekday_so",
|
||||
"calendar_more_info",
|
||||
"calendar_no_events",
|
||||
"calendar_time_suffix",
|
||||
"calendar_select_day",
|
||||
"calendar_next_events",
|
||||
"calendar_show_more",
|
||||
"calendar_show_less",
|
||||
] as const;
|
||||
|
||||
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
||||
|
||||
/** Keys als Objekt: T.searchable_text_help → "searchable_text_help" (für t(T.xy)). */
|
||||
export const T: { [K in TranslationKey]: K } = Object.fromEntries(
|
||||
TRANSLATION_KEYS.map((k) => [k, k]),
|
||||
) as { [K in TranslationKey]: K };
|
||||
|
||||
/**
|
||||
* Platzhalter im CMS-Text: {{name}} wird durch t(key, { name: "Wert" }) ersetzt.
|
||||
* Beispiel im CMS: "Hallo {{user}}, du hast {{count}} Einträge."
|
||||
* Aufruf: t(T.xyz, { user: "Max", count: 5 })
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gibt den übersetzten Text für key zurück, optional mit Ersetzung von {{placeholder}}.
|
||||
* Fallback: key selbst (damit fehlende Einträge im CMS erkennbar sind).
|
||||
* Nutzbar in Astro und in Svelte-Inseln: Übersetzungen von der Astro-Seite als Prop übergeben,
|
||||
* dann in der Komponente z. B. `t(translations ?? null, T.xy, { count: 5 })` (siehe BlogOverview.svelte).
|
||||
*/
|
||||
export function t(
|
||||
translations: Translations | null | undefined,
|
||||
key: string,
|
||||
replacements?: TranslationReplacements,
|
||||
): string {
|
||||
if (!key) return key;
|
||||
const raw = translations?.[key] ?? key;
|
||||
return replacePlaceholders(raw, replacements);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Middleware: lädt Übersetzungen aus der CMS-API (translation_bundle) und setzt Astro.locals.translations.
|
||||
* Single source of truth: API des Servers.
|
||||
*/
|
||||
import { defineMiddleware } from "astro:middleware";
|
||||
import { getTranslationBundleBySlug } from "./lib/cms";
|
||||
import { TRANSLATION_BUNDLE_SLUG } from "./lib/constants";
|
||||
import { resetCmsFromCache } from "./lib/cms-cache-state";
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
resetCmsFromCache();
|
||||
let translations: Record<string, string> = {};
|
||||
try {
|
||||
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, {
|
||||
locale: "de",
|
||||
});
|
||||
if (bundle?.strings && typeof bundle.strings === "object") {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(bundle.strings)) {
|
||||
const v = val as unknown;
|
||||
const str =
|
||||
typeof v === "string"
|
||||
? v
|
||||
: typeof v === "object" && v !== null && "value" in v
|
||||
? (v as { value?: unknown }).value
|
||||
: (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label;
|
||||
if (typeof str === "string") normalized[key] = str;
|
||||
}
|
||||
translations = { ...normalized };
|
||||
}
|
||||
} catch {
|
||||
/* CMS nicht erreichbar */
|
||||
}
|
||||
context.locals.translations = translations;
|
||||
return next();
|
||||
});
|
||||
+6
-3
@@ -1,13 +1,16 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { t, T } from '../lib/translations';
|
||||
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout title="Seite nicht gefunden">
|
||||
<Layout title={t(translations, T.page_404_title)}>
|
||||
<main class="mx-auto max-w-3xl px-4 py-12 text-center">
|
||||
<h1 class="text-3xl font-bold text-zinc-900">404</h1>
|
||||
<p class="mt-2 text-zinc-600">Seite nicht gefunden.</p>
|
||||
<p class="mt-2 text-zinc-600">{t(translations, T.page_404_text)}</p>
|
||||
<a href="/" class="mt-6 inline-block rounded bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800">
|
||||
Zur Startseite
|
||||
{t(translations, T.page_404_back)}
|
||||
</a>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
+6
-12
@@ -4,9 +4,9 @@ import ContentRows from "../components/ContentRows.svelte";
|
||||
import { getPageSlugs, getPageBySlug } from "../lib/cms";
|
||||
import type { PageEntry } from "../lib/cms";
|
||||
import { resolveContentImages } from "../lib/rusty-image";
|
||||
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks } from "../lib/blog-utils";
|
||||
import { getTranslationBundleBySlug } from "../lib/cms";
|
||||
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks } from "../lib/blog-utils";
|
||||
import { PAGE_RESOLVE } from "../lib/constants";
|
||||
import { T } from "../lib/translations";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -41,9 +41,8 @@ if (!page) {
|
||||
await resolveContentImages(page);
|
||||
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||
await resolveSearchableTextBlocks(page, tagsMap);
|
||||
const translationBundle = await getTranslationBundleBySlug("de", {
|
||||
locale: "de",
|
||||
});
|
||||
await resolveCalendarBlocks(page);
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout
|
||||
@@ -54,16 +53,11 @@ const translationBundle = await getTranslationBundleBySlug("de", {
|
||||
topFullwidthBanner={page.topFullwidthBanner}
|
||||
showBannerInLayout={!!page.topFullwidthBanner}
|
||||
breadcrumbItems={[
|
||||
{ href: "/", label: "Start" },
|
||||
{ href: "/", translationKey: T.nav_start },
|
||||
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
|
||||
]}
|
||||
>
|
||||
<article>
|
||||
<ContentRows
|
||||
client:load
|
||||
layout={page}
|
||||
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
|
||||
class="mt-8"
|
||||
/>
|
||||
<ContentRows client:load layout={page} class="mt-8" translations={translations} />
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
+8
-14
@@ -12,14 +12,14 @@ import { resolveContentImages } from "../lib/rusty-image";
|
||||
import {
|
||||
resolvePostOverviewBlocks,
|
||||
resolveSearchableTextBlocks,
|
||||
resolveCalendarBlocks,
|
||||
} from "../lib/blog-utils";
|
||||
import { getTranslationBundleBySlug } from "../lib/cms";
|
||||
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE } from "../lib/constants";
|
||||
import {
|
||||
DEFAULT_HOME_PAGE_SLUG,
|
||||
PAGE_RESOLVE,
|
||||
} from "../lib/constants";
|
||||
|
||||
let page: PageEntry | null = null;
|
||||
let translationBundle: Awaited<
|
||||
ReturnType<typeof getTranslationBundleBySlug>
|
||||
> = null;
|
||||
let cmsError: string | null = null;
|
||||
let openApiTitle = "RustyCMS API";
|
||||
let pages: PageEntry[] = [];
|
||||
@@ -40,13 +40,12 @@ try {
|
||||
await resolveContentImages(page);
|
||||
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||
await resolveSearchableTextBlocks(page, tagsMap);
|
||||
translationBundle = await getTranslationBundleBySlug("de", {
|
||||
locale: "de",
|
||||
});
|
||||
await resolveCalendarBlocks(page);
|
||||
}
|
||||
} catch (e) {
|
||||
cmsError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
{
|
||||
@@ -60,12 +59,7 @@ try {
|
||||
showBannerInLayout={!!page.topFullwidthBanner}
|
||||
>
|
||||
<article>
|
||||
<ContentRows
|
||||
client:load
|
||||
layout={page}
|
||||
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
|
||||
class="mt-8"
|
||||
/>
|
||||
<ContentRows client:load layout={page} class="mt-8" translations={translations} />
|
||||
</article>
|
||||
</Layout>
|
||||
) : (
|
||||
|
||||
+18
-17
@@ -11,6 +11,7 @@ import type { PostEntry } from "../../lib/cms";
|
||||
import {
|
||||
resolveContentImages,
|
||||
ensureTransformedImage,
|
||||
processMarkdownHtmlImages,
|
||||
} from "../../lib/rusty-image";
|
||||
import {
|
||||
resolvePostTagsInPost,
|
||||
@@ -19,8 +20,8 @@ import {
|
||||
getPostImageUrl,
|
||||
formatPostDate,
|
||||
} from "../../lib/blog-utils";
|
||||
import { getTranslationBundleBySlug } from "../../lib/cms";
|
||||
import { POST_RESOLVE } from "../../lib/constants";
|
||||
import { T } from "../../lib/translations";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -53,9 +54,6 @@ await resolveContentImages(post);
|
||||
const tagsMap = await resolvePostOverviewBlocks(post);
|
||||
await resolveSearchableTextBlocks(post, tagsMap);
|
||||
resolvePostTagsInPost(post, tagsMap);
|
||||
const translationBundle = await getTranslationBundleBySlug("de", {
|
||||
locale: "de",
|
||||
});
|
||||
|
||||
const rawPostImageUrl = getPostImageUrl(post.postImage);
|
||||
const postImageUrl = rawPostImageUrl
|
||||
@@ -69,10 +67,18 @@ const postImageUrl = rawPostImageUrl
|
||||
const postDate = formatPostDate(post.created ?? post.date);
|
||||
|
||||
const postContent = (post as { content?: string }).content;
|
||||
const contentHtml =
|
||||
let contentHtml =
|
||||
typeof postContent === "string" && postContent.trim()
|
||||
? (marked.parse(postContent) as string)
|
||||
: "";
|
||||
if (contentHtml) {
|
||||
contentHtml = await processMarkdownHtmlImages(contentHtml);
|
||||
}
|
||||
const hasRowContent =
|
||||
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
|
||||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
|
||||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout
|
||||
@@ -84,8 +90,8 @@ const contentHtml =
|
||||
showBannerInLayout={false}
|
||||
image={postImageUrl ?? undefined}
|
||||
breadcrumbItems={[
|
||||
{ href: "/", label: "Start" },
|
||||
{ href: "/posts/", label: "Beiträge" },
|
||||
{ href: "/", translationKey: T.nav_start },
|
||||
{ href: "/posts/", translationKey: T.nav_posts },
|
||||
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
|
||||
]}
|
||||
>
|
||||
@@ -121,19 +127,14 @@ const contentHtml =
|
||||
</div>
|
||||
</div>
|
||||
<article class="mt-8">
|
||||
{
|
||||
contentHtml ? (
|
||||
{contentHtml && (
|
||||
<div
|
||||
class="content markdown max-w-none prose prose-zinc"
|
||||
set:html={contentHtml}
|
||||
/>
|
||||
) : (
|
||||
<ContentRows
|
||||
client:load
|
||||
layout={post}
|
||||
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
{hasRowContent && (
|
||||
<ContentRows client:load layout={post} translations={translations} />
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getTagsMap,
|
||||
resolvePostTagsInPost,
|
||||
} from '../../lib/blog-utils';
|
||||
import { t, T } from '../../lib/translations';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -34,24 +35,26 @@ const perPage = getPostsPerPage();
|
||||
const filtered = filterPostsByTag(posts, null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pagePosts = paginate(filtered, 1, perPage);
|
||||
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Beiträge – Aktuelles"
|
||||
title={`${t(translations, T.posts_title)} – Aktuelles`}
|
||||
description="Übersicht aller Beiträge und Meldungen."
|
||||
breadcrumbItems={[
|
||||
{ href: "/", label: "Start" },
|
||||
{ label: "Beiträge" },
|
||||
{ href: "/", translationKey: T.nav_start },
|
||||
{ translationKey: T.nav_posts },
|
||||
]}
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Aktuelles und Meldungen</h2>
|
||||
<h1>{t(translations, T.posts_title)}</h1>
|
||||
<h2>{t(translations, T.posts_subtitle)}</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
@@ -62,6 +65,7 @@ const pagePosts = paginate(filtered, 1, perPage);
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
translations={translations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
paginate,
|
||||
getPostsPerPage,
|
||||
} from '../../../lib/blog-utils';
|
||||
import { t, T } from '../../../lib/translations';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -46,24 +47,26 @@ const filtered = filterPostsByTag(posts, null);
|
||||
const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pageNum = Math.min(currentPage, totalPages);
|
||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Beiträge – Seite ${pageNum}`}
|
||||
title={`${t(translations, T.posts_title)} – Seite ${pageNum}`}
|
||||
description="Übersicht aller Beiträge und Meldungen."
|
||||
breadcrumbItems={[
|
||||
{ href: "/", label: "Start" },
|
||||
{ label: "Beiträge" },
|
||||
{ href: "/", translationKey: T.nav_start },
|
||||
{ translationKey: T.nav_posts },
|
||||
]}
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Aktuelles und Meldungen</h2>
|
||||
<h1>{t(translations, T.posts_title)}</h1>
|
||||
<h2>{t(translations, T.posts_subtitle)}</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
@@ -74,6 +77,7 @@ const pagePosts = paginate(filtered, pageNum, perPage);
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
translations={translations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getTagsMap,
|
||||
resolvePostTagsInPost,
|
||||
} from '../../../lib/blog-utils';
|
||||
import { t, T } from '../../../lib/translations';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -58,25 +59,27 @@ const totalPages = getTotalPages(filtered.length, perPage);
|
||||
const pagePosts = paginate(filtered, 1, perPage);
|
||||
|
||||
const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
|
||||
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Beiträge – ${tagName}`}
|
||||
title={`${t(translations, T.posts_title)} – ${tagName}`}
|
||||
description={`Beiträge zum Thema ${tagName}.`}
|
||||
breadcrumbItems={[
|
||||
{ href: "/", label: "Start" },
|
||||
{ href: "/posts/", label: "Beiträge" },
|
||||
{ href: "/", translationKey: T.nav_start },
|
||||
{ href: "/posts/", translationKey: T.nav_posts },
|
||||
{ label: tagName },
|
||||
]}
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Beiträge: {tagName}</h2>
|
||||
<h1>{t(translations, T.posts_title)}</h1>
|
||||
<h2>{t(translations, T.posts_tag_subtitle, { tag: tagName })}</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
@@ -87,6 +90,7 @@ const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
translations={translations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getTagsMap,
|
||||
resolvePostTagsInPost,
|
||||
} from '../../../../../lib/blog-utils';
|
||||
import { t, T } from '../../../../../lib/translations';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -71,25 +72,27 @@ const pageNum = Math.min(currentPage, totalPages);
|
||||
const pagePosts = paginate(filtered, pageNum, perPage);
|
||||
|
||||
const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
|
||||
|
||||
const translations = Astro.locals.translations ?? {};
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`Beiträge – ${tagName} (Seite ${pageNum})`}
|
||||
title={`${t(translations, T.posts_title)} – ${tagName} (Seite ${pageNum})`}
|
||||
description={`Beiträge zum Thema ${tagName}.`}
|
||||
breadcrumbItems={[
|
||||
{ href: "/", label: "Start" },
|
||||
{ href: "/posts/", label: "Beiträge" },
|
||||
{ href: "/", translationKey: T.nav_start },
|
||||
{ href: "/posts/", translationKey: T.nav_posts },
|
||||
{ label: tagName },
|
||||
]}
|
||||
>
|
||||
<div class="mb-6 pt-10 pageTitle">
|
||||
<h1>Beiträge</h1>
|
||||
<h2>Beiträge: {tagName}</h2>
|
||||
<h1>{t(translations, T.posts_title)}</h1>
|
||||
<h2>{t(translations, T.posts_tag_subtitle, { tag: tagName })}</h2>
|
||||
</div>
|
||||
<div class="content mt-8">
|
||||
{cmsError ? (
|
||||
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
|
||||
<strong>CMS nicht erreichbar:</strong> {cmsError}
|
||||
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
|
||||
</div>
|
||||
) : (
|
||||
<BlogOverview
|
||||
@@ -100,6 +103,7 @@ const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
|
||||
totalPages={totalPages}
|
||||
totalPosts={filtered.length}
|
||||
basePath="/posts"
|
||||
translations={translations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+63
-60
@@ -73,6 +73,14 @@
|
||||
--color-error-subtle: #fdf2f1;
|
||||
--color-info: var(--color-himmel-500);
|
||||
--color-info-subtle: var(--color-himmel-50);
|
||||
|
||||
/* Button (Primary = Wald) – für @apply bg-btn-bg, text-btn-txt, bg-btn-hover-bg */
|
||||
--color-btn-bg: var(--color-wald-500);
|
||||
--color-btn-hover-bg: var(--color-wald-600);
|
||||
--color-btn-txt: #fff;
|
||||
|
||||
/* Link (Himmel) – für @apply text-link */
|
||||
--color-link: var(--color-himmel-500);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -294,39 +302,28 @@ main ol li {
|
||||
|
||||
/* Buttons – Wald (Primary) */
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease;
|
||||
font-weight: 300;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
background: var(--color-btn-bg);
|
||||
color: var(--color-btn-txt);
|
||||
@apply inline-block no-underline transition-colors duration-200 font-light px-4 py-2 rounded-md shadow-sm bg-btn-bg text-btn-txt;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-btn-hover-bg);
|
||||
@apply bg-btn-hover-bg;
|
||||
}
|
||||
|
||||
/* Content-Blöcke */
|
||||
.content p {
|
||||
margin-bottom: 1rem;
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.content h1,
|
||||
.content h2,
|
||||
.content h3,
|
||||
.content h4 {
|
||||
margin-bottom: 0.5em;
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
/* Markdown: Abstände, Listen, Bilder, Tabellen (Stein-Palette) */
|
||||
.markdown {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
@apply min-w-0 overflow-x-auto;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
@@ -334,68 +331,63 @@ main ol li {
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown hr {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
@apply mt-4 mb-3;
|
||||
}
|
||||
|
||||
.markdown p,
|
||||
.markdown li {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
.markdown li,
|
||||
.markdown li p {
|
||||
@apply mt-0 mb-0;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
@apply mt-0 mb-0;
|
||||
}
|
||||
|
||||
/* Markdown-Bilder: RustyImage-Transform, max-height begrenzt Höhe; Link öffnet Bild in neuem Tab */
|
||||
.markdown img {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
@apply max-w-full w-full my-2 border border-stein-200 rounded-md bg-stein-200 shadow-sm inline-block;
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.markdown .markdown-image-link {
|
||||
@apply inline-block;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
@apply w-full min-w-max border-collapse text-left text-sm mb-4;
|
||||
}
|
||||
|
||||
.markdown thead {
|
||||
background: var(--color-stein-100);
|
||||
@apply bg-stein-100;
|
||||
}
|
||||
|
||||
.markdown th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-stein-200);
|
||||
@apply px-3 py-2 font-medium border border-stein-200;
|
||||
}
|
||||
|
||||
.markdown td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-stein-200);
|
||||
@apply px-3 py-2 border border-stein-200;
|
||||
}
|
||||
|
||||
.markdown tbody tr:nth-child(even) {
|
||||
background: var(--color-stein-50);
|
||||
@apply bg-stein-50;
|
||||
}
|
||||
|
||||
.markdown tbody tr:hover {
|
||||
background: var(--color-stein-100);
|
||||
@apply bg-stein-100;
|
||||
}
|
||||
|
||||
.markdown table a {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-all;
|
||||
@apply text-link underline underline-offset-2 break-all;
|
||||
}
|
||||
|
||||
.markdown table a:hover {
|
||||
color: var(--color-himmel-600);
|
||||
@apply text-himmel-600;
|
||||
}
|
||||
|
||||
/* Code: Inline und Block (pre/code) – lesbare Größe, Stein-Palette */
|
||||
@@ -403,37 +395,24 @@ main code,
|
||||
.content code,
|
||||
.markdown code {
|
||||
@apply font-mono bg-stein-100 text-stein-800 border border-stein-200;
|
||||
font-size: 0.875em;
|
||||
padding: 0.15em 0.4em;
|
||||
@apply text-sm px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
main pre,
|
||||
.content pre,
|
||||
.markdown pre {
|
||||
@apply mt-3 mb-4 overflow-x-auto rounded-md border border-stein-200 bg-stein-100 px-4 py-3 text-stein-800 leading-normal;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
main pre code,
|
||||
.content pre code,
|
||||
.markdown pre code {
|
||||
@apply border-0 bg-transparent p-0 text-inherit;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container-custom {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container-custom {
|
||||
max-width: 640px;
|
||||
}
|
||||
@apply w-full mx-auto px-4 md:px-6;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -481,3 +460,27 @@ footer a:hover,
|
||||
margin-right: calc(50% - 50vw + 0.01rem);
|
||||
background: var(--color-container-breakout);
|
||||
}
|
||||
|
||||
/* Kalender: Container Query – links Kalender, rechts Eventblock ab ~500px Containerbreite */
|
||||
.calendar-block {
|
||||
container-type: inline-size;
|
||||
container-name: calendar;
|
||||
}
|
||||
@container calendar (min-width: 600px) {
|
||||
.calendar-block .calendar-grid-wrapper {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
.calendar-block .calendar-column {
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
}
|
||||
.calendar-block .calendar-events-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.calendar-block .calendar-events-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-top: none;
|
||||
border-left: 1px solid var(--color-stein-200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2891,6 +2891,11 @@ json-schema-traverse@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
|
||||
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
|
||||
|
||||
json5@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||
|
||||
kleur@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||
|
||||
Reference in New Issue
Block a user