feat: Mantine design system with dark mode
This commit is contained in:
parent
6e12720ad6
commit
38d4899b07
18 changed files with 1170 additions and 322 deletions
|
|
@ -1,5 +1,17 @@
|
|||
import { Link, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Anchor,
|
||||
AppShell,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
import SchemeToggle from "./components/SchemeToggle";
|
||||
import StartPage from "./pages/StartPage";
|
||||
import AccessPage from "./pages/AccessPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
|
|
@ -7,15 +19,8 @@ import WorkflowsPage from "./pages/WorkflowsPage";
|
|||
import TasksPage from "./pages/TasksPage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
|
||||
const navStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
function Shell() {
|
||||
const { me, loading, logout } = useAuth();
|
||||
function Nav() {
|
||||
const { me, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function onLogout() {
|
||||
|
|
@ -24,36 +29,89 @@ function Shell() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
|
||||
<h1>Kirche Einfach</h1>
|
||||
<p>KI-gestützte Verwaltungsplattform für Kirche</p>
|
||||
<nav style={navStyle}>
|
||||
<Link to="/">Start</Link>
|
||||
{!me && <Link to="/access">Zugang</Link>}
|
||||
{me && (
|
||||
<>
|
||||
<Link to="/app/workflows">Workflows</Link>
|
||||
<Link to="/app/tasks">Aufgaben</Link>
|
||||
<Link to="/app/admin">Verwaltung</Link>
|
||||
<span style={{ marginLeft: "auto" }}>{me.email}</span>
|
||||
<button onClick={() => void onLogout()}>Abmelden</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
{loading ? (
|
||||
<p>Wird geladen …</p>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path="/" element={<StartPage />} />
|
||||
<Route path="/access" element={<AccessPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/app/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/app/tasks" element={<TasksPage />} />
|
||||
<Route path="/app/admin" element={<AdminPage />} />
|
||||
<Route path="*" element={<StartPage />} />
|
||||
</Routes>
|
||||
<Group gap="md">
|
||||
<Anchor component={Link} to="/">
|
||||
Start
|
||||
</Anchor>
|
||||
{!me && (
|
||||
<Anchor component={Link} to="/access">
|
||||
Zugang
|
||||
</Anchor>
|
||||
)}
|
||||
</div>
|
||||
{me && (
|
||||
<>
|
||||
<Anchor component={Link} to="/app/workflows">
|
||||
Workflows
|
||||
</Anchor>
|
||||
<Anchor component={Link} to="/app/tasks">
|
||||
Aufgaben
|
||||
</Anchor>
|
||||
<Anchor component={Link} to="/app/admin">
|
||||
Verwaltung
|
||||
</Anchor>
|
||||
</>
|
||||
)}
|
||||
<SchemeToggle />
|
||||
{me && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{me.email}
|
||||
</Text>
|
||||
<Button variant="subtle" size="compact-md" onClick={() => void onLogout()}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { loading } = useAuth();
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 64 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Container
|
||||
h="100%"
|
||||
style={{ display: "flex", alignItems: "center", gap: 16 }}
|
||||
>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/"
|
||||
underline="never"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
<Title order={3}>Kirche Einfach</Title>
|
||||
</Anchor>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Nav />
|
||||
</div>
|
||||
</Container>
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
<Container>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
KI-gestützte Verwaltungsplattform für Kirche
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Center mt="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path="/" element={<StartPage />} />
|
||||
<Route path="/access" element={<AccessPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/app/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/app/tasks" element={<TasksPage />} />
|
||||
<Route path="/app/admin" element={<AdminPage />} />
|
||||
<Route path="*" element={<StartPage />} />
|
||||
</Routes>
|
||||
)}
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { ApiError, api, type AccessRequest } from "../api";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function AccessQueue() {
|
||||
const [requests, setRequests] = useState<AccessRequest[]>([]);
|
||||
|
|
@ -43,46 +57,72 @@ export default function AccessQueue() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h3>Zugangsanfragen</h3>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
{info && <p style={{ color: "green" }}>{info}</p>}
|
||||
{pending.length === 0 && <p>Keine offenen Anfragen.</p>}
|
||||
<ul>
|
||||
<Title order={3} mb="xs">
|
||||
Zugangsanfragen
|
||||
</Title>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{info && (
|
||||
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
|
||||
{info}
|
||||
</Alert>
|
||||
)}
|
||||
{pending.length === 0 && (
|
||||
<Text c="dimmed" mb="md">
|
||||
Keine offenen Anfragen.
|
||||
</Text>
|
||||
)}
|
||||
<Stack mb="md">
|
||||
{pending.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{r.name}</strong> · {r.email}
|
||||
<Card withBorder padding="md" key={r.id}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<StatusBadge status={r.status} />
|
||||
<strong>{r.name}</strong>
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.email}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.reason && (
|
||||
<>
|
||||
<br />
|
||||
<Text size="sm" mb="sm">
|
||||
{r.reason}
|
||||
</>
|
||||
</Text>
|
||||
)}
|
||||
<br />
|
||||
<button
|
||||
onClick={() => void decide(r.id, true)}
|
||||
disabled={busyId === r.id}
|
||||
>
|
||||
Genehmigen
|
||||
</button>{" "}
|
||||
<button
|
||||
onClick={() => void decide(r.id, false)}
|
||||
disabled={busyId === r.id}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</li>
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => void decide(r.id, true)}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => void decide(r.id, false)}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
</Stack>
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
<h4>Entschieden ({done.length})</h4>
|
||||
<ul>
|
||||
{done.map((r) => (
|
||||
<li key={r.id}>
|
||||
<strong>{r.status}</strong> · {r.name} · {r.email}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Title order={4} mb="xs">
|
||||
Entschieden ({done.length})
|
||||
</Title>
|
||||
{done.map((r) => (
|
||||
<Group key={r.id} gap="xs" mb={4}>
|
||||
<StatusBadge status={r.status} />
|
||||
<Text size="sm">
|
||||
{r.name} · {r.email}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ApiError,
|
||||
LANGFLOW_URL,
|
||||
|
|
@ -6,6 +24,7 @@ import {
|
|||
type FlowDefinition,
|
||||
type FlowVersion,
|
||||
} from "../api";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function AdminTab() {
|
||||
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||
|
|
@ -86,61 +105,110 @@ export default function AdminTab() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2>Administration</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
{info && <p style={{ color: "green" }}>{info}</p>}
|
||||
<p>
|
||||
Flows werden visuell in Langflow entworfen:{" "}
|
||||
<a href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
||||
Langflow öffnen
|
||||
</a>
|
||||
</p>
|
||||
<h3>Import aus Langflow</h3>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
value={langflowId}
|
||||
onChange={(e) => setLangflowId(e.target.value)}
|
||||
placeholder="Langflow Flow-ID (UUID)"
|
||||
style={{ flex: 1, fontFamily: "monospace" }}
|
||||
/>
|
||||
<input
|
||||
value={importName}
|
||||
onChange={(e) => setImportName(e.target.value)}
|
||||
placeholder="Name (optional)"
|
||||
/>
|
||||
<button onClick={() => void onImport()} disabled={!langflowId.trim()}>
|
||||
Importieren + Deployen
|
||||
</button>
|
||||
</div>
|
||||
<h3>Flows + Versionen</h3>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => void onSync()}>
|
||||
Aus backend/flows synchronisieren
|
||||
</button>
|
||||
</div>
|
||||
{flows.length === 0 && <p>Keine Flows vorhanden.</p>}
|
||||
<ul>
|
||||
{flows.map((f) => (
|
||||
<li key={f.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{f.name}</strong>{" "}
|
||||
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>
|
||||
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||
</span>
|
||||
<ul>
|
||||
{(versions[f.id] ?? []).map((v) => (
|
||||
<li key={v.id}>
|
||||
v{v.version_no} ·{" "}
|
||||
{v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "}
|
||||
{v.author || "–"}{" "}
|
||||
<button onClick={() => void onDeploy(f.id, v.version_no)}>
|
||||
Deployen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Title order={3} mb="xs">
|
||||
Flows & Versionen
|
||||
</Title>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{info && (
|
||||
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
|
||||
{info}
|
||||
</Alert>
|
||||
)}
|
||||
<Card withBorder padding="md" mb="md">
|
||||
<Text mb="xs">
|
||||
Flows werden visuell in Langflow entworfen:{" "}
|
||||
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
||||
Langflow öffnen <IconExternalLink size={14} />
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Group align="flex-end">
|
||||
<TextInput
|
||||
label="Langflow Flow-ID (UUID)"
|
||||
value={langflowId}
|
||||
onChange={(e) => setLangflowId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (optional)"
|
||||
value={importName}
|
||||
onChange={(e) => setImportName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => void onImport()}
|
||||
disabled={!langflowId.trim()}
|
||||
>
|
||||
Importieren + Deployen
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="md" mb="md">
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void onSync()}
|
||||
>
|
||||
Aus backend/flows synchronisieren
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
{flows.length === 0 ? (
|
||||
<Text c="dimmed">Keine Flows vorhanden.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Flow</Table.Th>
|
||||
<Table.Th>Version</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Autor</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{flows.map((f) =>
|
||||
(versions[f.id] ?? []).map((v, i) => (
|
||||
<Table.Tr key={v.id}>
|
||||
<Table.Td>
|
||||
{i === 0 && (
|
||||
<>
|
||||
<strong>{f.name}</strong>{" "}
|
||||
<Text size="xs" c="dimmed" component="span">
|
||||
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>v{v.version_no}</Table.Td>
|
||||
<Table.Td>
|
||||
{v.deployed_at ? (
|
||||
<StatusBadge status="deployed" />
|
||||
) : (
|
||||
<StatusBadge status="pending" />
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{v.author || "–"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() => void onDeploy(f.id, v.version_no)}
|
||||
>
|
||||
Deployen
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)),
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
|
||||
import { ApiError, api, type ApprovalTask } from "../api";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function ApprovalsTab() {
|
||||
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
|
||||
|
|
@ -40,56 +53,89 @@ export default function ApprovalsTab() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2>Freigaben</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
<Title order={3} mb="xs">
|
||||
Freigaben
|
||||
</Title>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Group mb="md" align="flex-end">
|
||||
<TextInput
|
||||
label="Name (Bearbeiter)"
|
||||
value={approver}
|
||||
onChange={(e) => setApprover(e.target.value)}
|
||||
placeholder="Name (Bearbeiter)"
|
||||
/>
|
||||
<input
|
||||
<TextInput
|
||||
label="Notiz"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Notiz"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button onClick={() => void load()}>Aktualisieren</button>
|
||||
</div>
|
||||
<h3>Offen ({open.length})</h3>
|
||||
{open.length === 0 && <p>Keine offenen Freigaben.</p>}
|
||||
<ul>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Group>
|
||||
<Title order={4} mb="xs">
|
||||
Offen ({open.length})
|
||||
</Title>
|
||||
{open.length === 0 && (
|
||||
<Text c="dimmed" mb="md">
|
||||
Keine offenen Freigaben.
|
||||
</Text>
|
||||
)}
|
||||
<Stack mb="md">
|
||||
{open.map((t) => (
|
||||
<li key={t.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{t.title || t.id}</strong> · {t.tenant_id}
|
||||
<br />
|
||||
<code>{JSON.stringify(t.payload)}</code>
|
||||
<br />
|
||||
<button
|
||||
onClick={() => void decide(t.id, "approve")}
|
||||
disabled={busyId === t.id}
|
||||
>
|
||||
Freigeben
|
||||
</button>{" "}
|
||||
<button
|
||||
onClick={() => void decide(t.id, "reject")}
|
||||
disabled={busyId === t.id}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</li>
|
||||
<Card withBorder padding="md" key={t.id}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<StatusBadge status={t.status} />
|
||||
<strong>{t.title || t.id}</strong>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t.tenant_id}
|
||||
</Text>
|
||||
</Group>
|
||||
<Code block mb="sm">
|
||||
{JSON.stringify(t.payload)}
|
||||
</Code>
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
loading={busyId === t.id}
|
||||
onClick={() => void decide(t.id, "approve")}
|
||||
>
|
||||
Freigeben
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={16} />}
|
||||
loading={busyId === t.id}
|
||||
onClick={() => void decide(t.id, "reject")}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Entschieden ({done.length})</h3>
|
||||
<ul>
|
||||
{done.map((t) => (
|
||||
<li key={t.id}>
|
||||
<strong>{t.status}</strong> · {t.title || t.id} ·{" "}
|
||||
{t.approver ?? "–"}
|
||||
</Stack>
|
||||
<Title order={4} mb="xs">
|
||||
Entschieden ({done.length})
|
||||
</Title>
|
||||
{done.map((t) => (
|
||||
<Group key={t.id} gap="xs" mb={4}>
|
||||
<StatusBadge status={t.status} />
|
||||
<Text size="sm">
|
||||
{t.title || t.id} · {t.approver ?? "–"}
|
||||
{t.decision_note ? ` · „${t.decision_note}“` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
frontend/src/components/SchemeToggle.tsx
Normal file
18
frontend/src/components/SchemeToggle.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { ActionIcon, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
export default function SchemeToggle() {
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={() => toggleColorScheme()}
|
||||
aria-label={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
|
||||
title={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
|
||||
>
|
||||
{dark ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/StatusBadge.tsx
Normal file
21
frontend/src/components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Badge } from "@mantine/core";
|
||||
|
||||
/* Single color language for every status in the app. */
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
completed: "green",
|
||||
approved: "green",
|
||||
genehmigt: "green",
|
||||
deployed: "green",
|
||||
running: "blue",
|
||||
open: "yellow",
|
||||
pending: "yellow",
|
||||
pending_approval: "yellow",
|
||||
failed: "red",
|
||||
rejected: "red",
|
||||
abgelehnt: "red",
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>;
|
||||
}
|
||||
|
|
@ -1,12 +1,21 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import App from "./App";
|
||||
import { colorSchemeManager, theme } from "./theme";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
defaultColorScheme="light"
|
||||
>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
|
||||
import { ApiError, api } from "../api";
|
||||
|
||||
export default function AccessPage() {
|
||||
|
|
@ -29,60 +40,54 @@ export default function AccessPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Zugang</h2>
|
||||
<p>
|
||||
return (
|
||||
<Card withBorder padding="xl" maw={560}>
|
||||
<Title order={2} mb="md">
|
||||
Zugang anfragen
|
||||
</Title>
|
||||
{done ? (
|
||||
<Alert color="green" icon={<IconCheck size={18} />} title="Angefragt">
|
||||
Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, sobald
|
||||
sie freigegeben wurde.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Zugang anfragen</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<form
|
||||
onSubmit={(e) => void onSubmit(e)}
|
||||
style={{ display: "grid", gap: 8, maxWidth: 480 }}
|
||||
>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={200}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
maxLength={320}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wofür brauchen Sie Zugang?
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={4}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? "Wird gesendet …" : "Anfragen"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Alert>
|
||||
) : (
|
||||
<form onSubmit={(e) => void onSubmit(e)}>
|
||||
<Stack>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
maxLength={320}
|
||||
/>
|
||||
<Textarea
|
||||
label="Wofür brauchen Sie Zugang?"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Button type="submit" loading={busy}>
|
||||
Anfragen
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
Ihre Angaben werden ausschließlich zur Zugangsfreigabe verwendet.
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import AdminTab from "../components/AdminTab";
|
||||
import ApprovalsTab from "../components/ApprovalsTab";
|
||||
import AccessQueue from "../components/AccessQueue";
|
||||
|
|
@ -8,25 +10,37 @@ export default function AdminPage() {
|
|||
if (!me) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Verwaltung</h2>
|
||||
<p>Bitte melden Sie sich an, um die Verwaltung zu nutzen.</p>
|
||||
<Title order={2} mb="md">
|
||||
Verwaltung
|
||||
</Title>
|
||||
<Alert color="blue">
|
||||
Bitte melden Sie sich an, um die Verwaltung zu nutzen.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (me.role !== "admin") {
|
||||
return (
|
||||
<div>
|
||||
<h2>Verwaltung</h2>
|
||||
<p>Nur für Administratoren. Ihr Konto hat die Rolle „{me.role}“.</p>
|
||||
<Title order={2} mb="md">
|
||||
Verwaltung
|
||||
</Title>
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||
Nur für Administratoren. Ihr Konto hat die Rolle „{me.role}“.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h2>Verwaltung</h2>
|
||||
<AccessQueue />
|
||||
<ApprovalsTab />
|
||||
<AdminTab />
|
||||
<Title order={2} mb="md">
|
||||
Verwaltung
|
||||
</Title>
|
||||
<Stack gap="xl">
|
||||
<AccessQueue />
|
||||
<ApprovalsTab />
|
||||
<AdminTab />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link, Navigate, useSearchParams } from "react-router-dom";
|
||||
import { Alert, Anchor, Card, Center, Loader, Title } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { ApiError, api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
|
|
@ -29,16 +31,30 @@ export default function LoginPage() {
|
|||
if (me) return <Navigate to="/app/workflows" replace />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Login</h2>
|
||||
{busy && <p>Link wird geprüft …</p>}
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
{!token && !error && (
|
||||
<p>
|
||||
Kein Link vorhanden. <Link to="/access">Zugang anfragen</Link> — der
|
||||
Login-Link kommt per E-Mail.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Center>
|
||||
<Card withBorder padding="xl" maw={480} w="100%">
|
||||
<Title order={2} mb="md">
|
||||
Login
|
||||
</Title>
|
||||
{busy && (
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{!token && !error && (
|
||||
<Alert color="blue" title="Kein Link vorhanden">
|
||||
<Anchor component={Link} to="/access">
|
||||
Zugang anfragen
|
||||
</Anchor>{" "}
|
||||
— der Login-Link kommt per E-Mail.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,71 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { Button, Card, Grid, Group, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
IconClipboardCheck,
|
||||
IconInbox,
|
||||
IconKey,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
const actions = [
|
||||
{
|
||||
to: "/access",
|
||||
icon: <IconKey size={28} />,
|
||||
title: "Zugang anfragen",
|
||||
text: "Neu hier? Fordern Sie Ihren persönlichen Zugang an.",
|
||||
},
|
||||
{
|
||||
to: "/app/workflows",
|
||||
icon: <IconSparkles size={28} />,
|
||||
title: "Workflows",
|
||||
text: "Vorgänge über Formulare starten und verfolgen.",
|
||||
},
|
||||
{
|
||||
to: "/app/tasks",
|
||||
icon: <IconInbox size={28} />,
|
||||
title: "Aufgaben",
|
||||
text: "Eigene Läufe und offene Freigaben im Blick.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function StartPage() {
|
||||
const { me } = useAuth();
|
||||
return (
|
||||
<div>
|
||||
<h2>Willkommen</h2>
|
||||
<p>
|
||||
Kirche Einfach unterstützt die Verwaltung Ihrer Gemeinde: Vorgänge
|
||||
starten, Freigaben erteilen, Zugänge verwalten.
|
||||
</p>
|
||||
{me ? (
|
||||
<p>
|
||||
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}). Weiter zu{" "}
|
||||
<Link to="/app/workflows">Workflows</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Link to="/access">Zugang anfragen</Link>, um loszulegen. Mit
|
||||
Einladungslink geht es direkt zum <Link to="/login">Login</Link>.
|
||||
</p>
|
||||
)}
|
||||
<Card withBorder padding="xl" mb="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<IconClipboardCheck size={32} />
|
||||
<Title order={2}>Willkommen</Title>
|
||||
</Group>
|
||||
<Text>
|
||||
Kirche Einfach unterstützt die Verwaltung Ihrer Gemeinde: Vorgänge
|
||||
starten, Freigaben erteilen, Zugänge verwalten.
|
||||
</Text>
|
||||
{me && (
|
||||
<Text mt="sm">
|
||||
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}).
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Grid>
|
||||
{actions.map((a) => (
|
||||
<Grid.Col key={a.to} span={{ base: 12, sm: 4 }}>
|
||||
<Card withBorder padding="lg" h="100%">
|
||||
{a.icon}
|
||||
<Title order={4} mt="sm">
|
||||
{a.title}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt="xs" mb="md">
|
||||
{a.text}
|
||||
</Text>
|
||||
<Button component={Link} to={a.to} variant="light" fullWidth>
|
||||
Öffnen
|
||||
</Button>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Code, Table, Text, Title } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { ApiError, api, type Run } from "../api";
|
||||
import ApprovalsTab from "../components/ApprovalsTab";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
|
||||
export default function TasksPage() {
|
||||
const [runs, setRuns] = useState<{ flow: string; run: Run }[]>([]);
|
||||
|
|
@ -27,18 +30,45 @@ export default function TasksPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2>Aufgaben</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<h3>Eigene Läufe</h3>
|
||||
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
||||
<ul>
|
||||
{runs.map(({ flow, run }) => (
|
||||
<li key={run.id}>
|
||||
<strong>{run.status}</strong> · {flow} ·{" "}
|
||||
<code>{run.output?.result ?? run.output?.error ?? "–"}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Title order={2} mb="md">
|
||||
Aufgaben
|
||||
</Title>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Title order={3} mb="xs">
|
||||
Eigene Läufe
|
||||
</Title>
|
||||
{runs.length === 0 ? (
|
||||
<Text c="dimmed" mb="md">
|
||||
Noch keine Läufe.
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover mb="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Flow</Table.Th>
|
||||
<Table.Th>Ergebnis</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{runs.map(({ flow, run }) => (
|
||||
<Table.Tr key={run.id}>
|
||||
<Table.Td>
|
||||
<StatusBadge status={run.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>{flow}</Table.Td>
|
||||
<Table.Td>
|
||||
<Code>{run.output?.result ?? run.output?.error ?? "–"}</Code>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<ApprovalsTab />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import Form from "@rjsf/core";
|
||||
import Form from "@rjsf/mantine";
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const { me } = useAuth();
|
||||
|
|
@ -70,68 +83,107 @@ export default function WorkflowsPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2>Workflows</h2>
|
||||
{!me && <p>Sie sind nicht angemeldet — Läufe nutzen den Standard-Mandanten.</p>}
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}>
|
||||
<option value="">— Flow wählen —</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Title order={2} mb="md">
|
||||
Workflows
|
||||
</Title>
|
||||
{!me && (
|
||||
<Alert color="blue" mb="md">
|
||||
Sie sind nicht angemeldet — Läufe nutzen den Standard-Mandanten.
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
label="Flow"
|
||||
placeholder="— Flow wählen —"
|
||||
value={flowId}
|
||||
onChange={(v) => setFlowId(v ?? "")}
|
||||
data={flows.map((f) => ({ value: f.id, label: f.name }))}
|
||||
mb="md"
|
||||
maw={480}
|
||||
/>
|
||||
{schema && schema.fallback && (
|
||||
<div>
|
||||
<textarea
|
||||
<Card withBorder padding="lg" mb="md">
|
||||
<Textarea
|
||||
label="Freitext-Eingabe"
|
||||
rows={6}
|
||||
style={{ width: "100%", fontFamily: "monospace" }}
|
||||
value={freeText}
|
||||
onChange={(e) => setFreeText(e.target.value)}
|
||||
placeholder="Freitext-Eingabe"
|
||||
mb="sm"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={busy}
|
||||
disabled={!flowId}
|
||||
onClick={() => void onRun({ text: freeText })}
|
||||
disabled={!flowId || busy}
|
||||
>
|
||||
{busy ? "Läuft …" : "Starten"}
|
||||
</button>
|
||||
</div>
|
||||
Starten
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
{schema && !schema.fallback && (
|
||||
<Form
|
||||
schema={schema.json_schema as RJSFSchema}
|
||||
uiSchema={schema.ui_schema as UiSchema}
|
||||
validator={validator}
|
||||
formData={formData}
|
||||
onChange={(e) =>
|
||||
setFormData((e.formData as Record<string, unknown>) ?? {})
|
||||
}
|
||||
onSubmit={(e) => void onRun(e.formData)}
|
||||
>
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? "Läuft …" : "Starten"}
|
||||
</button>
|
||||
</Form>
|
||||
<Card withBorder padding="lg" mb="md">
|
||||
<Form
|
||||
schema={schema.json_schema as RJSFSchema}
|
||||
uiSchema={schema.ui_schema as UiSchema}
|
||||
validator={validator}
|
||||
formData={formData}
|
||||
onChange={(e) =>
|
||||
setFormData((e.formData as Record<string, unknown>) ?? {})
|
||||
}
|
||||
onSubmit={(e) => void onRun(e.formData)}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={busy}
|
||||
mt="sm"
|
||||
>
|
||||
Starten
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
)}
|
||||
{result && (
|
||||
<div>
|
||||
<h3>Ergebnis</h3>
|
||||
<pre style={{ whiteSpace: "pre-wrap" }}>{result}</pre>
|
||||
</div>
|
||||
<Card withBorder padding="lg" mb="md">
|
||||
<Title order={3} mb="xs">
|
||||
Ergebnis
|
||||
</Title>
|
||||
<Code block>{result}</Code>
|
||||
</Card>
|
||||
)}
|
||||
<Title order={3} mb="xs">
|
||||
Letzte Läufe
|
||||
</Title>
|
||||
{runs.length === 0 ? (
|
||||
<Text c="dimmed">Noch keine Läufe.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Mandant</Table.Th>
|
||||
<Table.Th>Ergebnis</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{runs.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<StatusBadge status={r.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>{r.tenant_id}</Table.Td>
|
||||
<Table.Td>
|
||||
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<h3>Letzte Läufe</h3>
|
||||
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
||||
<ul>
|
||||
{runs.map((r) => (
|
||||
<li key={r.id}>
|
||||
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
|
||||
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
54
frontend/src/theme.ts
Normal file
54
frontend/src/theme.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
createTheme,
|
||||
localStorageColorSchemeManager,
|
||||
type MantineColorSchemeManager,
|
||||
} from "@mantine/core";
|
||||
|
||||
/* Calm-traditional direction: deep kirchenblau primary, warm paper
|
||||
* neutrals in light mode, deep slate in dark mode. System font stacks
|
||||
* only — no external requests (privacy). Serif display for headings. */
|
||||
|
||||
const kirchenblau: [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
] = [
|
||||
"#eef4fb",
|
||||
"#d9e6f5",
|
||||
"#b3cbe9",
|
||||
"#84add9",
|
||||
"#548bc6",
|
||||
"#356fae",
|
||||
"#245a93",
|
||||
"#1d4a79",
|
||||
"#1a3f65",
|
||||
"#162f4c",
|
||||
];
|
||||
|
||||
export const colorSchemeManager: MantineColorSchemeManager =
|
||||
localStorageColorSchemeManager({ key: "kirche-color-scheme" });
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: "kirchenblau",
|
||||
colors: { kirchenblau },
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
headings: {
|
||||
fontFamily:
|
||||
'Georgia, "Times New Roman", "Noto Serif", serif',
|
||||
},
|
||||
defaultRadius: "md",
|
||||
focusRing: "auto",
|
||||
components: {
|
||||
Container: {
|
||||
defaultProps: { size: 960 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue