3de59ccaaf
Build & Push Docker Image / build (push) Successful in 2m33s
- translate remaining pages/components (admin, analytics, redundancy, trash, compare, browse, watchlist, admin tools tab, category combobox, theme toggle, tool preview card, tool-new/edit) to t() calls - locales: 453 keys each, parity verified - docs: bilingual handbook + release notes via *.en.md variants, language-aware docs.tsx (markdown paths, nav titles, search index), LanguageSwitcher in docs header - generate-docs: emit per-locale handbook/release/search output, localized index.json fields (fileEn/titleEn), en-aware snapshots
1085 lines
38 KiB
TypeScript
1085 lines
38 KiB
TypeScript
import { useEffect, useMemo, useState, createContext, useContext } from "react";
|
||
import { Link, useLocation } from "wouter";
|
||
import { Marked } from "marked";
|
||
import DOMPurify from "dompurify";
|
||
import { useTranslation } from "react-i18next";
|
||
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 { LanguageSwitcher } from "@/components/language-switcher";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetTitle,
|
||
SheetTrigger,
|
||
} from "@/components/ui/sheet";
|
||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||
import {
|
||
ArrowLeft,
|
||
BookOpen,
|
||
CalendarDays,
|
||
ExternalLink,
|
||
FileText,
|
||
GitBranch,
|
||
HelpCircle,
|
||
Library,
|
||
Menu,
|
||
Search,
|
||
Server,
|
||
Tag,
|
||
Wrench,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
|
||
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
|
||
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
|
||
|
||
// Active docs version (null = current docs). Shared via context so every
|
||
// sub-view builds versioned links and file paths.
|
||
const DocsVersionContext = createContext<string | null>(null);
|
||
const useDocsVersion = () => useContext(DocsVersionContext);
|
||
|
||
// Full in-app URL for a docs path, prefixed with the active version.
|
||
function docsHref(version: string | null, path: string) {
|
||
return version === null ? `/docs/${path}` : `/docs/${version}/${path}`;
|
||
}
|
||
|
||
// Static file path under /docs, prefixed with the versioned snapshot dir.
|
||
function docsFile(version: string | null, relPath: string) {
|
||
return version === null ? relPath : `versions/${version}/${relPath}`;
|
||
}
|
||
|
||
// Prefer the English file/title variant when the active UI language is English.
|
||
function useDocsLocale() {
|
||
const { i18n } = useTranslation();
|
||
return (i18n.language ?? "en").toLowerCase().startsWith("en") ? "en" : "de";
|
||
}
|
||
|
||
function localizedFile(file: string, fileEn: string | null, locale: "en" | "de"): string {
|
||
return locale === "en" && fileEn ? fileEn : file;
|
||
}
|
||
|
||
function localizedTitle(title: string, titleEn: string | null, locale: "en" | "de"): string {
|
||
return locale === "en" && titleEn ? titleEn : title;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type FieldType = { kind: "ref" | "type" | "array"; value: string };
|
||
|
||
type Parameter = {
|
||
name: string;
|
||
in: string;
|
||
required: boolean;
|
||
type: FieldType;
|
||
description: string;
|
||
constraints: string;
|
||
};
|
||
|
||
type Endpoint = {
|
||
operationId: string;
|
||
method: string;
|
||
path: string;
|
||
summary: string;
|
||
description: string;
|
||
parameters: Parameter[];
|
||
requestBody: { required: boolean; schema: FieldType } | null;
|
||
responses: { status: string; description: string; schema: FieldType }[];
|
||
};
|
||
|
||
type TagGroup = { name: string; description: string; endpoints: Endpoint[] };
|
||
|
||
type Field = {
|
||
name: string;
|
||
type: FieldType;
|
||
required: boolean;
|
||
description: string;
|
||
constraints: string;
|
||
};
|
||
|
||
type SchemaModel = { name: string; description: string; fields: Field[] };
|
||
|
||
type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
|
||
|
||
type ReleaseDoc = {
|
||
version: string;
|
||
file: string;
|
||
fileEn: string | null;
|
||
title: string;
|
||
titleEn: string | null;
|
||
date: string | null;
|
||
hasReference: boolean;
|
||
hasHandbook: boolean;
|
||
};
|
||
|
||
type HandbookPage = {
|
||
slug: string;
|
||
file: string;
|
||
fileEn: string | null;
|
||
title: string;
|
||
titleEn: string | null;
|
||
order: number;
|
||
};
|
||
|
||
type SearchEntry = { title: string; href: string; kind: string; text: string };
|
||
|
||
type Heading = { id: string; text: string; level: number };
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Data hooks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function fetchJson<T>(url: string): Promise<T> {
|
||
return fetch(url).then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json() as Promise<T>;
|
||
});
|
||
}
|
||
|
||
function useJson<T>(url: string | null) {
|
||
const [data, setData] = useState<T | null>(null);
|
||
const [error, setError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!url) {
|
||
setData(null);
|
||
setError(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setData(null);
|
||
setError(false);
|
||
fetchJson<T>(url)
|
||
.then((d) => {
|
||
if (!cancelled) setData(d);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setError(true);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [url]);
|
||
|
||
return { data, error };
|
||
}
|
||
|
||
function useMarkdown(file: string | null) {
|
||
const [state, setState] = useState<{ html: string; headings: Heading[] } | null>(null);
|
||
const [error, setError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!file) {
|
||
setState(null);
|
||
setError(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setState(null);
|
||
setError(false);
|
||
fetch(`${DOCS_BASE}/${file}`)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.text();
|
||
})
|
||
.then((md) => {
|
||
const rendered = renderMarkdown(md);
|
||
if (!cancelled) setState(rendered);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setError(true);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [file]);
|
||
|
||
return { ...state, error };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Markdown rendering with heading anchors + TOC
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function slugify(text: string): string {
|
||
return text
|
||
.toLowerCase()
|
||
.trim()
|
||
.replace(/[^\w\s-]/g, "")
|
||
.replace(/[\s_]+/g, "-")
|
||
.replace(/-+/g, "-");
|
||
}
|
||
|
||
function renderMarkdown(md: string): { html: string; headings: Heading[] } {
|
||
const headings: Heading[] = [];
|
||
const seen = new Map<string, number>();
|
||
|
||
const renderer = {
|
||
heading({ tokens, depth }: { tokens: { raw: string; text?: string }[]; depth: number }) {
|
||
const text = tokens.map((t) => t.text ?? t.raw).join("");
|
||
let id = slugify(text);
|
||
const count = seen.get(id) ?? 0;
|
||
seen.set(id, count + 1);
|
||
if (count > 0) id = `${id}-${count}`;
|
||
headings.push({ id, text, level: depth });
|
||
return `<h${depth} id="${id}"><a href="#${id}" class="docs-anchor" aria-hidden="true"></a>${text}</h${depth}>`;
|
||
},
|
||
};
|
||
|
||
const marked = new Marked({ gfm: true, async: false, renderer });
|
||
const html = marked.parse(md) as string;
|
||
return { html: DOMPurify.sanitize(html), headings };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Version resolution
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function parseDocsPath(location: string) {
|
||
const rest = location.replace(/^\/docs\/?/, "");
|
||
const segments = rest.split("/").filter(Boolean);
|
||
const versionRe = /^v\d+\.\d+\.\d+$/;
|
||
if (segments.length > 0 && versionRe.test(segments[0])) {
|
||
return { version: segments[0], path: segments.slice(1) };
|
||
}
|
||
return { version: null, path: segments };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Field type helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function fieldTypeLabel(t: FieldType): string {
|
||
if (t.kind === "array") return `${t.value}[]`;
|
||
return t.value;
|
||
}
|
||
|
||
function isLinkableType(t: FieldType): boolean {
|
||
return t.kind === "ref" || (t.kind === "array" && /^[A-Z]/.test(t.value));
|
||
}
|
||
|
||
function resolveTypeHref(t: FieldType): string | null {
|
||
const version = useDocsVersion();
|
||
const href = (v: string | null) =>
|
||
v === null ? `/docs/reference/schemas/${t.value}` : `/docs/${v}/reference/schemas/${t.value}`;
|
||
if (t.kind === "ref") return href(version);
|
||
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return href(version);
|
||
return null;
|
||
}
|
||
|
||
const METHOD_STYLES: Record<string, string> = {
|
||
GET: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||
POST: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
||
PATCH: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
||
PUT: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-400",
|
||
DELETE: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sub-views
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function DocsHeader({
|
||
versions,
|
||
activeVersion,
|
||
onVersionChange,
|
||
onSearchChange,
|
||
}: {
|
||
versions: ReleaseDoc[];
|
||
activeVersion: string | null;
|
||
onVersionChange: (v: string | null) => void;
|
||
onSearchChange?: (q: string) => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<BookOpen className="h-5 w-5 text-primary shrink-0" />
|
||
<h1 className="text-xl font-bold tracking-tight truncate">{t("docs.title")}</h1>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{onSearchChange && (
|
||
<div className="relative hidden md:block">
|
||
<Search className="h-4 w-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
placeholder={t("docs.searchPlaceholder")}
|
||
onChange={(e) => onSearchChange(e.target.value)}
|
||
className="pl-8 w-52"
|
||
data-testid="input-docs-search"
|
||
/>
|
||
</div>
|
||
)}
|
||
{versions.length > 0 && (
|
||
<Select
|
||
value={activeVersion ?? "latest"}
|
||
onValueChange={(v) => onVersionChange(v === "latest" ? null : v)}
|
||
>
|
||
<SelectTrigger className="w-[140px]" data-testid="select-docs-version">
|
||
<SelectValue placeholder={t("docs.version")} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="latest">
|
||
{activeVersion === null ? `✓ ${t("docs.latest")}` : t("docs.latest")}
|
||
</SelectItem>
|
||
{versions.map((v) => (
|
||
<SelectItem key={v.version} value={v.version}>
|
||
{activeVersion === v.version ? `✓ ${v.version}` : v.version}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
<Button variant="ghost" size="icon" asChild data-testid="button-docs-repo" title={t("docs.repo")}>
|
||
<a href={REPO_URL} target="_blank" rel="noreferrer">
|
||
<GitBranch className="h-4 w-4" />
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DocsNav({
|
||
version,
|
||
handbook,
|
||
reference,
|
||
versions,
|
||
}: {
|
||
version: string | null;
|
||
handbook: HandbookPage[] | null;
|
||
reference: Reference | null;
|
||
versions: ReleaseDoc[];
|
||
}) {
|
||
const [location] = useLocation();
|
||
const { t } = useTranslation();
|
||
const locale = useDocsLocale();
|
||
|
||
const navLink = (href: string) => {
|
||
const active = location === href || (href !== "/docs" && location.startsWith(href));
|
||
return active;
|
||
};
|
||
|
||
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
|
||
|
||
if (handbook && handbook.length > 0) {
|
||
groups.push({
|
||
label: t("docs.guides"),
|
||
icon: BookOpen,
|
||
items: handbook.map((p) => ({
|
||
href: docsHref(version, `handbook/${p.slug}`),
|
||
label: localizedTitle(p.title, p.titleEn, locale),
|
||
active: navLink(docsHref(version, `handbook/${p.slug}`)),
|
||
})),
|
||
});
|
||
}
|
||
|
||
if (reference) {
|
||
groups.push({
|
||
label: t("docs.endpoints"),
|
||
icon: Server,
|
||
items: reference.tags.map((tag) => ({
|
||
href: docsHref(version, `reference/endpoints/${tag.name}`),
|
||
label: tag.name,
|
||
active: navLink(docsHref(version, `reference/endpoints/${tag.name}`)),
|
||
})),
|
||
});
|
||
groups.push({
|
||
label: t("docs.schemas"),
|
||
icon: Library,
|
||
items: reference.schemas.map((s) => ({
|
||
href: docsHref(version, `reference/schemas/${s.name}`),
|
||
label: s.name,
|
||
active: navLink(docsHref(version, `reference/schemas/${s.name}`)),
|
||
})),
|
||
});
|
||
}
|
||
|
||
groups.push({
|
||
label: t("docs.releases"),
|
||
icon: Tag,
|
||
items: versions.map((v) => ({
|
||
href: `/docs/releases/${v.version}`,
|
||
label: v.version,
|
||
active: navLink(`/docs/releases/${v.version}`),
|
||
})),
|
||
});
|
||
|
||
return (
|
||
<nav className="space-y-6" aria-label={t("docs.nav")}>
|
||
{groups.map((group) => (
|
||
<div key={group.label}>
|
||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
<group.icon className="h-3.5 w-3.5" />
|
||
{group.label}
|
||
</div>
|
||
<ul className="space-y-0.5">
|
||
{group.items.map((item) => (
|
||
<li key={item.href}>
|
||
<Link
|
||
href={item.href}
|
||
className={`block rounded-md px-2 py-1.5 text-sm transition-colors ${
|
||
item.active
|
||
? "bg-accent text-accent-foreground font-medium"
|
||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||
}`}
|
||
>
|
||
<span className="truncate block">{item.label}</span>
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function Toc({ headings, title }: { headings: Heading[]; title?: string }) {
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
const { t } = useTranslation();
|
||
|
||
useEffect(() => {
|
||
if (headings.length === 0) return;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) setActiveId(entry.target.id);
|
||
}
|
||
},
|
||
{ rootMargin: "-80px 0px -70% 0px" },
|
||
);
|
||
for (const h of headings) {
|
||
const el = document.getElementById(h.id);
|
||
if (el) observer.observe(el);
|
||
}
|
||
return () => observer.disconnect();
|
||
}, [headings]);
|
||
|
||
if (headings.length === 0) return null;
|
||
|
||
return (
|
||
<aside className="hidden xl:block" aria-label={t("docs.onThisPage")}>
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{title ?? t("docs.onThisPage")}
|
||
</p>
|
||
<ul className="space-y-1 border-l">
|
||
{headings.map((h) => (
|
||
<li key={h.id} style={{ paddingLeft: `${Math.min(h.level - 1, 2)}rem` }}>
|
||
<a
|
||
href={`#${h.id}`}
|
||
className={`block border-l -ml-px px-2 py-0.5 text-xs transition-colors ${
|
||
activeId === h.id
|
||
? "border-primary text-foreground font-medium"
|
||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
{h.text}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function MarkdownView({ file }: { file: string | null }) {
|
||
const { t } = useTranslation();
|
||
const { html, headings, error } = useMarkdown(file);
|
||
|
||
return (
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<div className="flex-1 min-w-0 max-w-3xl">
|
||
{error ? (
|
||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||
) : !html ? (
|
||
<div className="space-y-3">
|
||
<Skeleton className="h-8 w-64" />
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-3/4" />
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="docs-prose prose dark:prose-invert max-w-none"
|
||
dangerouslySetInnerHTML={{ __html: html }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<Toc headings={headings ?? []} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FieldTypeChip({ type }: { type: FieldType }) {
|
||
const href = resolveTypeHref(type);
|
||
const label = fieldTypeLabel(type);
|
||
if (href) {
|
||
return (
|
||
<Link href={href} className="inline-flex">
|
||
<Badge variant="secondary" className="font-mono hover:bg-accent">
|
||
{label}
|
||
</Badge>
|
||
</Link>
|
||
);
|
||
}
|
||
return <Badge variant="secondary" className="font-mono">{label}</Badge>;
|
||
}
|
||
|
||
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">{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>
|
||
{fields.map((f) => (
|
||
<tr key={f.name} id={f.name} className="border-b last:border-0 align-top">
|
||
<td className="px-3 py-2">
|
||
<a href={`#${f.name}`} className="font-mono text-primary hover:underline">
|
||
{f.name}
|
||
</a>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<FieldTypeChip type={f.type} />
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
{f.required ? (
|
||
<Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge>
|
||
) : (
|
||
<span className="text-muted-foreground">–</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<div className="text-muted-foreground">{f.description}</div>
|
||
{f.constraints && (
|
||
<div className="mt-0.5 text-xs text-muted-foreground/70 font-mono">{f.constraints}</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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">
|
||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||
<div className="space-y-1">
|
||
<h1 className="text-2xl font-bold tracking-tight font-mono">{schema.name}</h1>
|
||
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
|
||
</div>
|
||
<FieldTable fields={schema.fields} />
|
||
<p className="text-xs text-muted-foreground">
|
||
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
|
||
{t("docs.fieldHelpHint")}
|
||
</p>
|
||
</div>
|
||
<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}`,
|
||
level: 2,
|
||
}));
|
||
return (
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<div className="flex-1 min-w-0 max-w-3xl space-y-8">
|
||
<div className="space-y-1">
|
||
<h1 className="text-2xl font-bold tracking-tight">{tag.name}</h1>
|
||
{tag.description && <p className="text-muted-foreground">{tag.description}</p>}
|
||
</div>
|
||
{tag.endpoints.map((ep) => (
|
||
<section key={ep.operationId} id={ep.operationId} className="scroll-mt-20">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<Badge className={`font-mono ${METHOD_STYLES[ep.method] ?? "bg-muted text-muted-foreground"}`}>
|
||
{ep.method}
|
||
</Badge>
|
||
<code className="font-mono text-sm">{ep.path}</code>
|
||
<a href={`#${ep.operationId}`} className="ml-auto text-muted-foreground hover:text-foreground">
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
</a>
|
||
</div>
|
||
<h2 className="mb-1 text-lg font-semibold">{ep.summary}</h2>
|
||
{ep.description && <p className="mb-3 text-sm text-muted-foreground">{ep.description}</p>}
|
||
|
||
{ep.parameters.length > 0 && (
|
||
<div className="mb-3">
|
||
<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">{t("docs.name")}</th>
|
||
<th className="px-3 py-2 font-semibold">{t("docs.in")}</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>
|
||
{ep.parameters.map((p) => (
|
||
<tr key={`${p.name}-${p.in}`} className="border-b last:border-0">
|
||
<td className="px-3 py-1.5 font-mono">{p.name}</td>
|
||
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
|
||
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
|
||
<td className="px-3 py-1.5">
|
||
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge> : "–"}
|
||
</td>
|
||
<td className="px-3 py-1.5 text-muted-foreground">
|
||
{p.description}
|
||
{p.constraints && <span className="block font-mono text-xs">{p.constraints}</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{ep.requestBody && (
|
||
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||
{t("docs.requestBody")} {ep.requestBody.required && <Badge className="ml-1">{t("docs.required")}</Badge>}
|
||
</p>
|
||
<FieldTypeChip type={ep.requestBody.schema} />
|
||
</div>
|
||
)}
|
||
|
||
<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">{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>
|
||
{ep.responses.map((r) => (
|
||
<tr key={r.status} className="border-b last:border-0">
|
||
<td className="px-3 py-1.5 font-mono">{r.status}</td>
|
||
<td className="px-3 py-1.5 text-muted-foreground">{r.description}</td>
|
||
<td className="px-3 py-1.5"><FieldTypeChip type={r.schema} /></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
<Toc headings={headings} title={t("docs.endpoints")} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||
const [, setLocation] = useLocation();
|
||
const { t } = useTranslation();
|
||
const locale = useDocsLocale();
|
||
return (
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||
<div className="space-y-1">
|
||
<h1 className="text-2xl font-bold tracking-tight">{t("docs.releases")}</h1>
|
||
<p className="text-muted-foreground">{t("docs.subtitle")}</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{versions.map((v) => (
|
||
<button
|
||
key={v.version}
|
||
type="button"
|
||
onClick={() => setLocation(`/docs/releases/${v.version}`)}
|
||
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50"
|
||
>
|
||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold">{localizedTitle(v.title, v.titleEn, locale)}</span>
|
||
<Badge variant="secondary">{v.version}</Badge>
|
||
</div>
|
||
{v.date && (
|
||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<CalendarDays className="h-3 w-3" />
|
||
{new Date(`${v.date}T00:00:00`).toLocaleDateString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{v.hasReference && <Badge className="bg-primary/10 text-primary">{t("docs.reference")}</Badge>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Toc headings={[]} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReleaseNoteView({ version, doc }: { version: string; doc: ReleaseDoc | null }) {
|
||
const locale = useDocsLocale();
|
||
const file = doc ? `releases/${localizedFile(doc.file, doc.fileEn, locale)}` : `releases/${version}.md`;
|
||
return (
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<div className="flex-1 min-w-0 max-w-3xl">
|
||
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Tag className="h-4 w-4" />
|
||
<span className="font-mono">{version}</span>
|
||
<a
|
||
href={`${REPO_URL}/releases/tag/${version}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
</a>
|
||
</div>
|
||
<MarkdownView file={file} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) {
|
||
const version = useDocsVersion();
|
||
const locale = useDocsLocale();
|
||
const page = pages?.find((p) => p.slug === slug);
|
||
const file = page
|
||
? docsFile(version, `handbook/${localizedFile(page.file, page.fileEn, locale)}`)
|
||
: docsFile(version, `handbook/${slug}.md`);
|
||
return <MarkdownView file={file} />;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Search overlay
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function useDocsSearch(query: string) {
|
||
const locale = useDocsLocale();
|
||
const indexFile = locale === "en" ? "search.en.json" : "search.json";
|
||
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/${indexFile}` : null);
|
||
const results = useMemo(() => {
|
||
if (!query.trim() || !data) return [];
|
||
const q = query.trim().toLowerCase();
|
||
return data
|
||
.filter(
|
||
(e) =>
|
||
e.title.toLowerCase().includes(q) ||
|
||
e.text.toLowerCase().includes(q),
|
||
)
|
||
.slice(0, 25);
|
||
}, [query, data]);
|
||
|
||
return { results, error };
|
||
}
|
||
|
||
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">{t("docs.noResults")}</p>
|
||
) : (
|
||
results.map((r) => (
|
||
<Link
|
||
key={r.href}
|
||
href={r.href}
|
||
onClick={onClose}
|
||
className="flex items-start gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent"
|
||
>
|
||
<span className="shrink-0">
|
||
{r.kind === "endpoint" && <Server className="h-4 w-4 text-emerald-500" />}
|
||
{r.kind === "field" && <Library className="h-4 w-4 text-blue-500" />}
|
||
{r.kind === "guide" && <BookOpen className="h-4 w-4 text-amber-500" />}
|
||
{r.kind === "release" && <Tag className="h-4 w-4 text-primary" />}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block font-medium truncate">{r.title}</span>
|
||
<span className="block text-xs text-muted-foreground truncate">{r.text.slice(0, 80)}</span>
|
||
</span>
|
||
</Link>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export default function Docs() {
|
||
const [location, setLocation] = useLocation();
|
||
const { t } = useTranslation();
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [navOpen, setNavOpen] = useState(false);
|
||
|
||
// Close the mobile nav drawer after navigating.
|
||
useEffect(() => {
|
||
setNavOpen(false);
|
||
}, [location]);
|
||
|
||
const { version, path } = useMemo(() => parseDocsPath(location), [location]);
|
||
|
||
const { data: versionInfo } = useGetVersion({
|
||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||
});
|
||
|
||
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
|
||
|
||
const activeRelease = releases?.find((r) => r.version === version) ?? null;
|
||
const handbookAvailable = version === null || activeRelease?.hasHandbook;
|
||
const handbookUrl = handbookAvailable ? `${DOCS_BASE}/${docsFile(version, "handbook/index.json")}` : null;
|
||
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(handbookUrl);
|
||
|
||
const isCurrentVersion =
|
||
version === null ||
|
||
(versionInfo?.version && versionInfo.version !== "dev" && version === versionInfo.version) ||
|
||
(version !== null && (!versionInfo?.version || versionInfo.version === "dev"));
|
||
|
||
const refUrl = version === null
|
||
? `${DOCS_BASE}/reference.json`
|
||
: activeRelease?.hasReference
|
||
? `${DOCS_BASE}/versions/${version}.json`
|
||
: null;
|
||
|
||
const { data: reference, error: refError } = useJson<Reference>(refUrl);
|
||
|
||
// Current version detection: prefer running version, fallback newest documented
|
||
const currentVersion = useMemo(() => {
|
||
if (releases && releases.length > 0) {
|
||
if (versionInfo?.version && versionInfo.version !== "dev") {
|
||
const match = releases.find((r) => r.version === versionInfo.version);
|
||
if (match) return match.version;
|
||
}
|
||
return releases[0].version;
|
||
}
|
||
return null;
|
||
}, [releases, versionInfo]);
|
||
|
||
const handleVersionChange = (v: string | null) => {
|
||
setSearchQuery("");
|
||
if (v === null || v === currentVersion) {
|
||
setLocation("/docs");
|
||
return;
|
||
}
|
||
setLocation(`/docs/${v}`);
|
||
};
|
||
|
||
// ---- route resolution ----
|
||
const section = path[0] ?? "home";
|
||
const param = path[1];
|
||
|
||
// Version shown in the header dropdown: an explicit first-segment version
|
||
// (/docs/vX.Y.Z) or the releases route (/docs/releases/vX.Y.Z).
|
||
const headerVersion =
|
||
version !== null
|
||
? version
|
||
: section === "releases" && param
|
||
? param
|
||
: null;
|
||
|
||
let content: React.ReactNode = null;
|
||
|
||
if (version !== null && path.length === 0) {
|
||
content =
|
||
handbook && handbook.length > 0 ? (
|
||
<HandbookView slug={handbook[0].slug} pages={handbook} />
|
||
) : (
|
||
<ReleaseNoteView version={version} doc={activeRelease} />
|
||
);
|
||
} else if (section === "home") {
|
||
content =
|
||
handbook && handbook.length > 0 ? (
|
||
<HandbookView slug={handbook[0].slug} pages={handbook} />
|
||
) : releases && releases.length > 0 ? (
|
||
<ReleasesView versions={releases} />
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||
);
|
||
} else if (section === "handbook" && param) {
|
||
content = <HandbookView slug={param} pages={handbook} />;
|
||
} else if (section === "reference" && param === "endpoints" && path[2]) {
|
||
const tag = reference?.tags.find(
|
||
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
|
||
);
|
||
content = tag ? (
|
||
<EndpointTagView tag={tag} />
|
||
) : refError || (reference && !tag) ? (
|
||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||
) : (
|
||
<Skeleton className="h-64 w-full" />
|
||
);
|
||
} else if (section === "reference" && param === "schemas" && path[2]) {
|
||
const schema = reference?.schemas.find(
|
||
(s) => s.name.toLowerCase() === path[2].toLowerCase(),
|
||
);
|
||
content = schema ? (
|
||
<SchemaView schema={schema} />
|
||
) : refError || (reference && !schema) ? (
|
||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||
) : (
|
||
<Skeleton className="h-64 w-full" />
|
||
);
|
||
} else if (section === "reference") {
|
||
content = (
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||
<h1 className="text-2xl font-bold tracking-tight">{t("docs.reference")}</h1>
|
||
<p className="text-muted-foreground">{t("docs.referenceIntro")}</p>
|
||
{!reference && !refError && <Skeleton className="h-64 w-full" />}
|
||
{reference && (
|
||
<>
|
||
<div>
|
||
<h2 className="mb-1 text-lg font-semibold">{t("docs.endpoints")}</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
{reference.tags.map((tg) => (
|
||
<Link
|
||
key={tg.name}
|
||
href={docsHref(version, `reference/endpoints/${tg.name}`)}
|
||
className="rounded-lg border p-3 text-sm hover:bg-accent/50"
|
||
>
|
||
<span className="font-medium">{tg.name}</span>
|
||
<span className="block text-xs text-muted-foreground">
|
||
{tg.endpoints.length} {t("docs.endpoints")}
|
||
</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h2 className="mb-1 text-lg font-semibold">{t("docs.schemas")}</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
{reference.schemas.map((s) => (
|
||
<Link
|
||
key={s.name}
|
||
href={docsHref(version, `reference/schemas/${s.name}`)}
|
||
className="rounded-lg border p-3 text-sm font-mono hover:bg-accent/50"
|
||
>
|
||
{s.name}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<Toc headings={[]} />
|
||
</div>
|
||
);
|
||
} else if (section === "releases" && param) {
|
||
content = <ReleaseNoteView version={param} doc={releases?.find((r) => r.version === param) ?? null} />;
|
||
} else if (section === "releases") {
|
||
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
|
||
} else {
|
||
content = <p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>;
|
||
}
|
||
|
||
const showSearch = version === null && section !== "releases";
|
||
|
||
return (
|
||
<DocsVersionContext.Provider value={version}>
|
||
<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">
|
||
<div className="flex items-center gap-1 min-w-0">
|
||
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="lg:hidden -ml-1 shrink-0"
|
||
title={t("docs.nav")}
|
||
data-testid="button-docs-nav-mobile"
|
||
>
|
||
<Menu className="h-5 w-5" />
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="left" className="w-80 overflow-y-auto p-4">
|
||
<SheetTitle className="sr-only">{t("docs.nav")}</SheetTitle>
|
||
<DocsNav
|
||
version={version}
|
||
handbook={handbook}
|
||
reference={reference}
|
||
versions={releases ?? []}
|
||
/>
|
||
</SheetContent>
|
||
</Sheet>
|
||
<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>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<LanguageSwitcher />
|
||
<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={headerVersion}
|
||
onVersionChange={handleVersionChange}
|
||
onSearchChange={showSearch ? setSearchQuery : undefined}
|
||
/>
|
||
|
||
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
|
||
<aside className="hidden lg:block">
|
||
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
|
||
<DocsNav
|
||
version={version}
|
||
handbook={handbook}
|
||
reference={reference}
|
||
versions={releases ?? []}
|
||
/>
|
||
</div>
|
||
</aside>
|
||
<div className="min-w-0">{content}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DocsVersionContext.Provider>
|
||
);
|
||
}
|