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,16 +103,22 @@ 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">
<Flex
direction={{ base: "column", md: "row" }}
gap="xl"
align="center"
>
<Box style={{ flex: 7 }}>
<Badge mb="sm">Für Pfarrbüros und Kirchengemeinden</Badge> <Badge mb="sm">Für Pfarrbüros und Kirchengemeinden</Badge>
<Title order={1} mb="sm"> <Title order={1} mb="sm">
Weniger Verwaltung. Mehr Zeit für Menschen. Weniger Verwaltung. Mehr Zeit für Menschen.
</Title> </Title>
<Text size="lg" c="dimmed" mb="lg" maw={640}> <Text size="lg" c="dimmed" mb="lg">
Kirche Einfach führt Mitarbeitende in eigenen Worten durch Vorgänge, Kirche Einfach führt Mitarbeitende in eigenen Worten durch
prüft Regeln automatisch und fragt Menschen nur, wo Urteil nötig Vorgänge, prüft Regeln automatisch und fragt Menschen nur, wo
ist — lückenlos protokolliert. Urteil nötig ist — lückenlos protokolliert.
</Text> </Text>
<Group> <Group>
<Button component={Link} to="/access" size="lg"> <Button component={Link} to="/access" size="lg">
@ -120,6 +133,18 @@ export default function StartPage() {
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}). Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}).
</Text> </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
src={b.photo}
alt={b.alt}
radius="lg"
className="photo"
loading="lazy"
h={300}
fit="cover"
/>
</Box>
);
const text = (
<Box style={{ flex: 7 }}>
<ThemeIcon size={48} radius="md" variant="light" mb="sm"> <ThemeIcon size={48} radius="md" variant="light" mb="sm">
{b.icon} {b.icon}
</ThemeIcon> </ThemeIcon>
<Title order={4} mb="xs"> <Title order={3} mb="xs">
{b.title} {b.title}
</Title> </Title>
<Text size="sm" c="dimmed"> <Text c="dimmed">{b.text}</Text>
{b.text} </Box>
</Text> );
</Card> return (
</Grid.Col> <Flex
))} key={b.title}
</Grid> 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);
}