feat: photo landing page with favicon set
|
|
@ -9,6 +9,10 @@
|
|||
content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche"
|
||||
/>
|
||||
<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>
|
||||
try {
|
||||
var s = localStorage.getItem("kirche-color-scheme");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"screenshots": "node scripts/capture.mjs"
|
||||
"screenshots": "node scripts/capture.mjs",
|
||||
"icons": "node scripts/icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.6.1",
|
||||
|
|
|
|||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 661 B |
22
frontend/public/favicon.svg
Normal 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 |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
19
frontend/public/photos/SOURCES.md
Normal 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/ |
|
||||
BIN
frontend/public/photos/church-pews.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
frontend/public/photos/hero-church.webp
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
frontend/public/photos/paperwork-desk.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
frontend/public/photos/team-laptop.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 898 KiB |
22
frontend/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
78
frontend/scripts/fetch-photos.py
Normal 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()
|
||||
44
frontend/scripts/icons.mjs
Normal 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();
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { BrowserRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import "./photos.css";
|
||||
import App from "./App";
|
||||
import { colorSchemeManager, theme } from "./theme";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import {
|
|||
Accordion,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
List,
|
||||
|
|
@ -40,17 +41,23 @@ const benefits = [
|
|||
{
|
||||
icon: <IconFileText size={28} />,
|
||||
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} />,
|
||||
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.",
|
||||
photo: "/photos/team-laptop.webp",
|
||||
alt: "Team arbeitet gemeinsam am Laptop",
|
||||
},
|
||||
{
|
||||
icon: <IconUserCheck size={28} />,
|
||||
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();
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* Hero */}
|
||||
{/* Hero: text left, parish photo right */}
|
||||
<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>
|
||||
<Title order={1} mb="sm">
|
||||
Weniger Verwaltung. Mehr Zeit für Menschen.
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed" mb="lg" maw={640}>
|
||||
Kirche Einfach führt Mitarbeitende in eigenen Worten durch Vorgänge,
|
||||
prüft Regeln automatisch und fragt Menschen nur, wo Urteil nötig
|
||||
ist — lückenlos protokolliert.
|
||||
<Text size="lg" c="dimmed" mb="lg">
|
||||
Kirche Einfach führt Mitarbeitende in eigenen Worten durch
|
||||
Vorgänge, prüft Regeln automatisch und fragt Menschen nur, wo
|
||||
Urteil nötig ist — lückenlos protokolliert.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button component={Link} to="/access" size="lg">
|
||||
|
|
@ -120,6 +133,18 @@ export default function StartPage() {
|
|||
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>
|
||||
|
||||
{/* Facts */}
|
||||
|
|
@ -136,24 +161,53 @@ export default function StartPage() {
|
|||
))}
|
||||
</Group>
|
||||
|
||||
{/* Benefits */}
|
||||
<Grid>
|
||||
{benefits.map((b) => (
|
||||
<Grid.Col key={b.title} span={{ base: 12, sm: 4 }}>
|
||||
<Card withBorder padding="lg" h="100%">
|
||||
{/* Benefits: alternating photo/text rows */}
|
||||
{benefits.map((b, i) => {
|
||||
const photo = (
|
||||
<Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
|
||||
<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">
|
||||
{b.icon}
|
||||
</ThemeIcon>
|
||||
<Title order={4} mb="xs">
|
||||
<Title order={3} mb="xs">
|
||||
{b.title}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.text}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
<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 */}
|
||||
<Card withBorder padding="lg" radius="lg">
|
||||
|
|
@ -168,6 +222,8 @@ export default function StartPage() {
|
|||
src="/screenshots/workflows-light.png"
|
||||
alt="Workflow-Formular in Kirche Einfach"
|
||||
radius="md"
|
||||
className="photo"
|
||||
loading="lazy"
|
||||
bd="1px solid var(--mantine-color-default-border)"
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
|||
5
frontend/src/photos.css
Normal 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);
|
||||
}
|
||||