feat(docs): standalone docs page, Hilfe nav entry, rewritten user guide

- docs route renders without the app shell (own header, back-to-app link,
  theme toggle)
- nav entry renamed to Hilfe (de) / Help (en)
- reference links now case-insensitive (schema/tag slugs)
- handbook rewritten as 17-page user guide with links into the API reference
- release notes v0.8.1 added
This commit is contained in:
opencode
2026-08-03 22:52:14 +02:00
parent 9ceb11e7f9
commit fdf2d741a1
24 changed files with 833 additions and 323 deletions
@@ -37,31 +37,6 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
crumbs.push({ label: t("compare.title") });
} else if (location.startsWith("/analytics")) {
crumbs.push({ label: t("nav.analytics") });
} else if (location.startsWith("/docs")) {
crumbs.push({ href: "/docs", label: t("docs.title") });
const m = location.replace(/^\/docs\/?/, "");
if (m.startsWith("handbook/")) {
crumbs.push({ href: "/docs/handbook", label: t("docs.guides") });
const slug = m.replace(/^handbook\//, "").split("#")[0];
if (slug) crumbs.push({ label: slug });
} else if (m.startsWith("reference/")) {
crumbs.push({ href: "/docs/reference/endpoints", label: t("docs.reference") });
const rest = m.replace(/^reference\//, "").split("#")[0];
if (rest.startsWith("schemas/")) {
crumbs.push({ label: t("docs.schemas") });
const name = rest.replace(/^schemas\//, "");
if (name) crumbs.push({ label: name });
} else {
const tag = rest.replace(/^endpoints\//, "");
if (tag) crumbs.push({ label: tag });
}
} else if (m.startsWith("releases/")) {
crumbs.push({ href: "/docs/releases", label: t("docs.releases") });
const version = m.replace(/^releases\//, "").split("#")[0];
if (version) crumbs.push({ label: version });
} else if (m) {
crumbs.push({ label: m });
}
} else if (location.startsWith("/login")) {
crumbs.push({ label: t("auth.signIn") });
}
+13 -2
View File
@@ -12,7 +12,7 @@
"trash": "Papierkorb",
"admin": "Admin",
"redundancy": "Redundanz",
"docs": "Doku",
"docs": "Hilfe",
"search": "Tools suchen…"
},
"auth": {
@@ -198,7 +198,18 @@
"reference": "API-Referenz",
"referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.",
"onThisPage": "Auf dieser Seite",
"searchPlaceholder": "Doku durchsuchen…"
"searchPlaceholder": "Doku durchsuchen…",
"backToApp": "Zur App",
"field": "Feld",
"type": "Typ",
"required": "Pflicht",
"description": "Beschreibung",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "Keine Treffer",
"fields": "Felder",
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
},
"command": {
"navigate": "Navigation",
+13 -2
View File
@@ -12,7 +12,7 @@
"trash": "Trash",
"admin": "Admin",
"redundancy": "Redundancy",
"docs": "Docs",
"docs": "Help",
"search": "Search tools…"
},
"auth": {
@@ -198,7 +198,18 @@
"reference": "API reference",
"referenceIntro": "Generated automatically from the OpenAPI spec — all endpoints and data fields of the current version.",
"onThisPage": "On this page",
"searchPlaceholder": "Search docs…"
"searchPlaceholder": "Search docs…",
"backToApp": "Back to app",
"field": "Field",
"type": "Type",
"required": "Required",
"description": "Description",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "No results",
"fields": "Fields",
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
},
"command": {
"navigate": "Navigate",
+51 -21
View File
@@ -3,11 +3,11 @@ import { Link, useLocation } from "wouter";
import { Marked } from "marked";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
import {
Select,
SelectContent,
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/select";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
import {
ArrowLeft,
BookOpen,
CalendarDays,
ExternalLink,
@@ -27,6 +28,7 @@ import {
Search,
Server,
Tag,
Wrench,
type LucideIcon,
} from "lucide-react";
@@ -485,15 +487,16 @@ function FieldTypeChip({ type }: { type: FieldType }) {
}
function FieldTable({ fields }: { fields: Field[] }) {
const { t } = useTranslation();
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">Feld</th>
<th className="px-3 py-2 font-semibold">Typ</th>
<th className="px-3 py-2 font-semibold">Pflicht</th>
<th className="px-3 py-2 font-semibold">Beschreibung</th>
<th className="px-3 py-2 font-semibold">{t("docs.field")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
</tr>
</thead>
<tbody>
@@ -529,6 +532,7 @@ function FieldTable({ fields }: { fields: Field[] }) {
}
function SchemaView({ schema }: { schema: SchemaModel }) {
const { t } = useTranslation();
const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 }));
return (
<div className="flex flex-col lg:flex-row gap-8">
@@ -540,15 +544,16 @@ function SchemaView({ schema }: { schema: SchemaModel }) {
<FieldTable fields={schema.fields} />
<p className="text-xs text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.
{t("docs.fieldHelpHint")}
</p>
</div>
<Toc headings={headings} title="Felder" />
<Toc headings={headings} title={t("docs.fields")} />
</div>
);
}
function EndpointTagView({ tag }: { tag: TagGroup }) {
const { t } = useTranslation();
const headings: Heading[] = tag.endpoints.map((e) => ({
id: e.operationId,
text: `${e.method} ${e.path}`,
@@ -577,16 +582,16 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
{ep.parameters.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parameter</p>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("docs.parameter")}</p>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">Name</th>
<th className="px-3 py-2 font-semibold">In</th>
<th className="px-3 py-2 font-semibold">Typ</th>
<th className="px-3 py-2 font-semibold">Pflicht</th>
<th className="px-3 py-2 font-semibold">Beschreibung</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
</tr>
</thead>
<tbody>
@@ -623,9 +628,9 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Beschreibung</th>
<th className="px-3 py-2 font-semibold">Schema</th>
<th className="px-3 py-2 font-semibold">{t("docs.status")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.schema")}</th>
</tr>
</thead>
<tbody>
@@ -642,7 +647,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
</section>
))}
</div>
<Toc headings={headings} title="Endpunkte" />
<Toc headings={headings} title={t("docs.endpoints")} />
</div>
);
}
@@ -737,12 +742,13 @@ function useDocsSearch(query: string) {
}
function SearchOverlay({ query, onClose }: { query: string; onClose: () => void }) {
const { t } = useTranslation();
const { results } = useDocsSearch(query);
if (!query.trim()) return null;
return (
<div className="mt-3 rounded-lg border bg-card p-2 shadow-md max-h-96 overflow-auto">
{results.length === 0 ? (
<p className="px-3 py-2 text-sm text-muted-foreground">Keine Treffer</p>
<p className="px-3 py-2 text-sm text-muted-foreground">{t("docs.noResults")}</p>
) : (
results.map((r) => (
<Link
@@ -849,7 +855,9 @@ export default function Docs() {
} else if (section === "handbook" && param) {
content = <HandbookView slug={param} />;
} else if (section === "reference" && param === "endpoints" && path[2]) {
const tag = reference?.tags.find((tg) => tg.name === path[2]);
const tag = reference?.tags.find(
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
);
content = tag ? (
<EndpointTagView tag={tag} />
) : refError || (reference && !tag) ? (
@@ -858,7 +866,9 @@ export default function Docs() {
<Skeleton className="h-64 w-full" />
);
} else if (section === "reference" && param === "schemas" && path[2]) {
const schema = reference?.schemas.find((s) => s.name === path[2]);
const schema = reference?.schemas.find(
(s) => s.name.toLowerCase() === path[2].toLowerCase(),
);
content = schema ? (
<SchemaView schema={schema} />
) : refError || (reference && !schema) ? (
@@ -923,8 +933,28 @@ export default function Docs() {
const showSearch = version === null && section !== "releases";
return (
<Layout>
<div className="mx-auto max-w-6xl space-y-6 pb-10">
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b bg-card shrink-0">
<div className="mx-auto max-w-6xl px-4 md:px-6 h-14 flex items-center justify-between gap-3">
<Link
href="/"
className="inline-flex items-center gap-2 text-primary font-bold text-lg min-w-0"
data-testid="link-docs-back"
>
<Wrench className="w-5 h-5 shrink-0" />
<span className="truncate">toolr</span>
<span className="hidden md:inline-flex items-center gap-1 text-xs font-normal text-muted-foreground border-l pl-2 ml-1">
<ArrowLeft className="w-3.5 h-3.5" />
{t("docs.backToApp")}
</span>
</Link>
<div className="flex items-center gap-1.5 shrink-0">
<ThemeToggle />
</div>
</div>
</header>
<div className="mx-auto max-w-6xl w-full flex-1 space-y-6 px-4 md:px-6 py-6 pb-12">
<DocsHeader
versions={releases ?? []}
activeVersion={version}
@@ -948,6 +978,6 @@ export default function Docs() {
<div className="min-w-0">{content}</div>
</div>
</div>
</Layout>
</div>
);
}