feat: photo landing page with favicon set

This commit is contained in:
Jonas Miederer 2026-09-13 12:52:46 +02:00
parent b533549e45
commit 672faf3af8
19 changed files with 297 additions and 45 deletions

View file

@ -9,6 +9,10 @@
content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche" content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche"
/> />
<title>Kirche Einfach</title> <title>Kirche Einfach</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script> <script>
try { try {
var s = localStorage.getItem("kirche-color-scheme"); var s = localStorage.getItem("kirche-color-scheme");

View file

@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"screenshots": "node scripts/capture.mjs" "screenshots": "node scripts/capture.mjs",
"icons": "node scripts/icons.mjs"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^9.6.1", "@mantine/core": "^9.6.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<style>
.bg { fill: #1d4a79; }
.fg { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.bg { fill: #356fae; }
}
</style>
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
<!-- tower -->
<rect class="fg" x="26" y="22" width="12" height="28"/>
<!-- spire -->
<path class="fg" d="M32 8 L40 22 H24 Z"/>
<!-- cross -->
<rect class="fg" x="31.2" y="2.5" width="1.6" height="6"/>
<rect class="fg" x="29.4" y="4" width="5.2" height="1.6"/>
<!-- nave -->
<rect class="fg" x="14" y="36" width="12" height="14"/>
<path class="fg" d="M12 36 L20 28 L28 36 Z"/>
<!-- door knockout -->
<path class="bg" d="M29 50 v-5 a3 3 0 0 1 6 0 v5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -0,0 +1,19 @@
# Landing-page photo sources
All files in this directory are self-hosted (no hotlinking, no tracking).
Licensed for free use including commercial purposes, no attribution
required (Pexels License). Re-fetch any time:
docker compose exec api /opt/venv/bin/python \
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
Rejected during visual review (do NOT re-add without checking):
US-megachurch conference screens, dark baroque interior, pandemic masks,
Dutch-language documents.
| File | Motif | Source |
| ---------------------- | ------------------------------ | ------ |
| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ |
| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ |
| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ |
| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ |

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 898 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
{
"name": "Kirche Einfach",
"short_name": "Kirche",
"description": "KI-gestützte Verwaltungsplattform für Kirche",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1d4a79",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View file

@ -0,0 +1,78 @@
"""Fetch landing-page photos (one-time + re-runnable).
Downloads royalty-free originals (see SOURCES.md next to the photos),
resizes to max 1600px wide and stores them as WebP q80 in
frontend/public/photos/ (self-hosted: no hotlinking, no tracking).
Run in the api container (has network + Pillow):
docker compose exec api /opt/venv/bin/python \\
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
"""
from __future__ import annotations
import sys
import urllib.request
from io import BytesIO
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.exit("Pillow required (present in the api image)")
ROOT = Path(__file__).resolve().parent.parent.parent
OUT = ROOT / "frontend" / "public" / "photos"
def pexels(photo_id: int) -> str:
return (
f"https://images.pexels.com/photos/{photo_id}/"
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600"
)
PHOTOS = {
# target file -> (direct file URL, source page for SOURCES.md)
"hero-church.webp": (
pexels(31630127),
"https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/",
),
"church-pews.webp": (
pexels(7566207),
"https://www.pexels.com/photo/church-pews-7566207/",
),
"paperwork-desk.webp": (
pexels(18687845),
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
),
"team-laptop.webp": (
pexels(7793702),
"https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/",
),
}
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"})
with urllib.request.urlopen(req, timeout=120) as response:
return response.read()
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
for filename, (url, _page) in PHOTOS.items():
raw = fetch(url)
with Image.open(BytesIO(raw)) as img:
img = img.convert("RGB")
if img.width > 1600:
img = img.resize(
(1600, round(img.height * 1600 / img.width)), Image.LANCZOS
)
target = OUT / Path(filename).with_suffix(".webp").name
img.save(target, "WEBP", quality=80, method=6)
print(f"wrote {target} ({target.stat().st_size // 1024} KB)")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,44 @@
/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable).
*
* Usage (frontend container, Chromium already installed for screenshots):
* npm run icons
*
* Renders the SVG at each target size (crisp native rasterization) into
* public/. apple-touch-icon keeps the solid kirchenblau background (iOS
* fills transparency black). The 512 icon doubles as maskable: the glyph
* stays inside the inner 80% safe zone.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8");
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`;
const targets = [
["favicon-32x32.png", 32],
["apple-touch-icon.png", 180],
["icon-192.png", 192],
["icon-512.png", 512],
];
const browser = await chromium.launch();
try {
const page = await browser.newPage();
for (const [file, size] of targets) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<body style="margin:0"><img src="${dataUrl}" width="${size}" height="${size}"/></body>`,
);
await page.locator("img").screenshot({
path: join(root, "public", file),
omitBackground: true,
});
console.log(`wrote public/${file}`);
}
} finally {
await browser.close();
}

View file

@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "./photos.css";
import App from "./App"; import App from "./App";
import { colorSchemeManager, theme } from "./theme"; import { colorSchemeManager, theme } from "./theme";

View file

@ -3,9 +3,10 @@ import {
Accordion, Accordion,
Anchor, Anchor,
Badge, Badge,
Box,
Button, Button,
Card, Card,
Grid, Flex,
Group, Group,
Image, Image,
List, List,
@ -40,17 +41,23 @@ const benefits = [
{ {
icon: <IconFileText size={28} />, icon: <IconFileText size={28} />,
title: "In eigenen Worten", title: "In eigenen Worten",
text: "Mitarbeitende beschreiben Anliegen wie gewohnt — das System führt in verständlichen Schritten durch den Vorgang.", text: "Mitarbeitende beschreiben Anliegen wie gewohnt — das System führt in verständlichen Schritten durch den Vorgang. Schluss mit Stempeln und Durchschlägen.",
photo: "/photos/paperwork-desk.webp",
alt: "Schreibtisch mit Stempeln und Formularen — so sieht es heute oft aus",
}, },
{ {
icon: <IconSparkles size={28} />, icon: <IconSparkles size={28} />,
title: "Regeln automatisch geprüft", title: "Regeln automatisch geprüft",
text: "Schwellen, Zuständigkeiten und Abläufe prüft die Software selbst. Routine läuft durch, ohne dass jemand klicken muss.", text: "Schwellen, Zuständigkeiten und Abläufe prüft die Software selbst. Routine läuft durch, ohne dass jemand klicken muss.",
photo: "/photos/team-laptop.webp",
alt: "Team arbeitet gemeinsam am Laptop",
}, },
{ {
icon: <IconUserCheck size={28} />, icon: <IconUserCheck size={28} />,
title: "Mensch nur wo nötig", title: "Mensch nur wo nötig",
text: "Echte Entscheidungen landen mit allen Fakten bei der zuständigen Person — Freigabe oder Ablehnung inklusive Notiz.", text: "Echte Entscheidungen landen mit allen Fakten bei der zuständigen Person — Freigabe oder Ablehnung inklusive Notiz. Für alles andere bleibt Zeit.",
photo: "/photos/church-pews.webp",
alt: "Ruhiger Kirchenraum mit leeren Bänken",
}, },
]; ];
@ -96,30 +103,48 @@ export default function StartPage() {
const { me } = useAuth(); const { me } = useAuth();
return ( return (
<Stack gap="xl"> <Stack gap="xl">
{/* Hero */} {/* Hero: text left, parish photo right */}
<Card withBorder padding="xl" radius="lg"> <Card withBorder padding="xl" radius="lg">
<Badge mb="sm">Für Pfarrbüros und Kirchengemeinden</Badge> <Flex
<Title order={1} mb="sm"> direction={{ base: "column", md: "row" }}
Weniger Verwaltung. Mehr Zeit für Menschen. gap="xl"
</Title> align="center"
<Text size="lg" c="dimmed" mb="lg" maw={640}> >
Kirche Einfach führt Mitarbeitende in eigenen Worten durch Vorgänge, <Box style={{ flex: 7 }}>
prüft Regeln automatisch und fragt Menschen nur, wo Urteil nötig <Badge mb="sm">Für Pfarrbüros und Kirchengemeinden</Badge>
ist — lückenlos protokolliert. <Title order={1} mb="sm">
</Text> Weniger Verwaltung. Mehr Zeit für Menschen.
<Group> </Title>
<Button component={Link} to="/access" size="lg"> <Text size="lg" c="dimmed" mb="lg">
{CTA_LABEL} Kirche Einfach führt Mitarbeitende in eigenen Worten durch
</Button> Vorgänge, prüft Regeln automatisch und fragt Menschen nur, wo
<Button component={Link} to="/login" size="lg" variant="light"> Urteil nötig ist — lückenlos protokolliert.
Zum Login </Text>
</Button> <Group>
</Group> <Button component={Link} to="/access" size="lg">
{me && ( {CTA_LABEL}
<Text mt="md"> </Button>
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}). <Button component={Link} to="/login" size="lg" variant="light">
</Text> Zum Login
)} </Button>
</Group>
{me && (
<Text mt="md">
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}).
</Text>
)}
</Box>
<Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
<Image
src="/photos/hero-church.webp"
alt="Steinerner Kirchturm unter blauem Himmel"
radius="lg"
className="photo"
h={380}
fit="cover"
/>
</Box>
</Flex>
</Card> </Card>
{/* Facts */} {/* Facts */}
@ -136,24 +161,53 @@ export default function StartPage() {
))} ))}
</Group> </Group>
{/* Benefits */} {/* Benefits: alternating photo/text rows */}
<Grid> {benefits.map((b, i) => {
{benefits.map((b) => ( const photo = (
<Grid.Col key={b.title} span={{ base: 12, sm: 4 }}> <Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
<Card withBorder padding="lg" h="100%"> <Image
<ThemeIcon size={48} radius="md" variant="light" mb="sm"> src={b.photo}
{b.icon} alt={b.alt}
</ThemeIcon> radius="lg"
<Title order={4} mb="xs"> className="photo"
{b.title} loading="lazy"
</Title> h={300}
<Text size="sm" c="dimmed"> fit="cover"
{b.text} />
</Text> </Box>
</Card> );
</Grid.Col> const text = (
))} <Box style={{ flex: 7 }}>
</Grid> <ThemeIcon size={48} radius="md" variant="light" mb="sm">
{b.icon}
</ThemeIcon>
<Title order={3} mb="xs">
{b.title}
</Title>
<Text c="dimmed">{b.text}</Text>
</Box>
);
return (
<Flex
key={b.title}
direction={{ base: "column", md: "row" }}
gap="xl"
align="center"
>
{i % 2 === 0 ? (
<>
{photo}
{text}
</>
) : (
<>
{text}
{photo}
</>
)}
</Flex>
);
})}
{/* Product visual */} {/* Product visual */}
<Card withBorder padding="lg" radius="lg"> <Card withBorder padding="lg" radius="lg">
@ -168,6 +222,8 @@ export default function StartPage() {
src="/screenshots/workflows-light.png" src="/screenshots/workflows-light.png"
alt="Workflow-Formular in Kirche Einfach" alt="Workflow-Formular in Kirche Einfach"
radius="md" radius="md"
className="photo"
loading="lazy"
bd="1px solid var(--mantine-color-default-border)" bd="1px solid var(--mantine-color-default-border)"
/> />
</Card> </Card>

5
frontend/src/photos.css Normal file
View file

@ -0,0 +1,5 @@
/* Photo treatment: gentle dimming in dark mode so bright photos do not
* glare against the slate surfaces. Applied via className="photo". */
[data-mantine-color-scheme="dark"] .photo {
filter: brightness(0.9) saturate(0.95);
}