feat: photo landing page with favicon set
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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 { 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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||