diff --git a/artifacts/toolrate/package.json b/artifacts/toolrate/package.json index d583eb9..02ff8ae 100644 --- a/artifacts/toolrate/package.json +++ b/artifacts/toolrate/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "node ../../scripts/src/sync-release-docs.mjs && vite --config vite.config.ts --host 0.0.0.0", - "build": "node ../../scripts/src/sync-release-docs.mjs && vite build --config vite.config.ts", + "dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0", + "build": "node ../../scripts/src/generate-docs.mjs && vite build --config vite.config.ts", "serve": "vite preview --config vite.config.ts --host 0.0.0.0", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index 6094cf9..f3420ba 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -47,8 +47,7 @@ function Router() { - - + ); diff --git a/artifacts/toolrate/src/components/breadcrumbs.tsx b/artifacts/toolrate/src/components/breadcrumbs.tsx index 46a05a3..12f686c 100644 --- a/artifacts/toolrate/src/components/breadcrumbs.tsx +++ b/artifacts/toolrate/src/components/breadcrumbs.tsx @@ -38,9 +38,30 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] { } else if (location.startsWith("/analytics")) { crumbs.push({ label: t("nav.analytics") }); } else if (location.startsWith("/docs")) { - crumbs.push({ href: "/docs", label: t("nav.docs") }); - const match = location.match(/^\/docs\/(.+)$/); - if (match) crumbs.push({ label: match[1] }); + 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") }); } diff --git a/artifacts/toolrate/src/components/field-help.tsx b/artifacts/toolrate/src/components/field-help.tsx new file mode 100644 index 0000000..cb9654f --- /dev/null +++ b/artifacts/toolrate/src/components/field-help.tsx @@ -0,0 +1,32 @@ +import { HelpCircle } from "lucide-react"; +import { Link } from "wouter"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export function FieldHelp({ + schema, + field, + children, +}: { + schema: string; + field: string; + children?: React.ReactNode; +}) { + const label = children ?? field; + return ( + + + + + + + {label} — Details in der Dokumentation + + ); +} diff --git a/artifacts/toolrate/src/i18n/locales/de.json b/artifacts/toolrate/src/i18n/locales/de.json index e0c040b..5bc3fae 100644 --- a/artifacts/toolrate/src/i18n/locales/de.json +++ b/artifacts/toolrate/src/i18n/locales/de.json @@ -183,10 +183,22 @@ "backHome": "Zurück zur Startseite" }, "docs": { - "title": "Versionsdokumentation", - "subtitle": "Version-gebundene Dokumentation je Release — was ist neu, was hat sich geändert und was beim Upgrade zu beachten ist.", + "title": "Dokumentation", + "subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.", "backToIndex": "Alle Releases", - "noDocs": "Noch keine Release-Dokumentation verfügbar." + "noDocs": "Keine Dokumentation für diesen Pfad verfügbar.", + "version": "Version", + "latest": "Aktuell", + "repo": "Repository", + "nav": "Dokumentation", + "guides": "Handbuch", + "endpoints": "Endpunkte", + "schemas": "Datenmodelle", + "releases": "Release-Notes", + "reference": "API-Referenz", + "referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.", + "onThisPage": "Auf dieser Seite", + "searchPlaceholder": "Doku durchsuchen…" }, "command": { "navigate": "Navigation", diff --git a/artifacts/toolrate/src/i18n/locales/en.json b/artifacts/toolrate/src/i18n/locales/en.json index 03a3ffd..9085362 100644 --- a/artifacts/toolrate/src/i18n/locales/en.json +++ b/artifacts/toolrate/src/i18n/locales/en.json @@ -183,10 +183,22 @@ "backHome": "Back to Home" }, "docs": { - "title": "Release Documentation", - "subtitle": "Version-bound documentation for each release — what's new, what changed, and what to know when upgrading.", + "title": "Documentation", + "subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.", "backToIndex": "All releases", - "noDocs": "No release documentation available yet." + "noDocs": "No documentation available for this path.", + "version": "Version", + "latest": "Latest", + "repo": "Repository", + "nav": "Documentation", + "guides": "Guide", + "endpoints": "Endpoints", + "schemas": "Data models", + "releases": "Release notes", + "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…" }, "command": { "navigate": "Navigate", diff --git a/artifacts/toolrate/src/index.css b/artifacts/toolrate/src/index.css index 5e82628..154db01 100644 --- a/artifacts/toolrate/src/index.css +++ b/artifacts/toolrate/src/index.css @@ -265,6 +265,25 @@ */ @layer utilities { + /* Documentation heading anchors (mkdocs style "¶" links) */ + .docs-prose :is(h1, h2, h3, h4) { + scroll-margin-top: 6rem; + position: relative; + } + + .docs-prose .docs-anchor::after { + content: "¶"; + margin-left: 0.35rem; + font-size: 0.8em; + color: hsl(var(--muted-foreground)); + opacity: 0; + transition: opacity 0.15s ease; + } + + .docs-prose :is(h1, h2, h3, h4):hover .docs-anchor::after { + opacity: 0.8; + } + /* Hide ugly search cancel button in Chrome until we can style it properly */ input[type="search"]::-webkit-search-cancel-button { @apply hidden; diff --git a/artifacts/toolrate/src/pages/docs.tsx b/artifacts/toolrate/src/pages/docs.tsx index 85253b2..238bfb2 100644 --- a/artifacts/toolrate/src/pages/docs.tsx +++ b/artifacts/toolrate/src/pages/docs.tsx @@ -1,23 +1,95 @@ -import { useEffect, useState } from "react"; -import { Link, useLocation, useParams } from "wouter"; -import { marked } from "marked"; +import { useEffect, useMemo, useState } from "react"; +import { Link, useLocation } from "wouter"; +import { Marked } from "marked"; import DOMPurify from "dompurify"; import { useTranslation } from "react-i18next"; import { Layout } from "@/components/layout"; -import { Card, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; -import { FileText, CalendarDays, Tag, ArrowLeft, ExternalLink } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; +import { + BookOpen, + CalendarDays, + ExternalLink, + FileText, + GitBranch, + HelpCircle, + Library, + Search, + Server, + Tag, + 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"; + +// --------------------------------------------------------------------------- +// 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; title: string; date: string | null; + hasReference: boolean; }; -const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`; +type HandbookPage = { slug: string; file: string; title: string; order: number }; + +type SearchEntry = { title: string; href: string; kind: string; text: string }; + +type Heading = { id: string; text: string; level: number }; + +// --------------------------------------------------------------------------- +// Data hooks +// --------------------------------------------------------------------------- function fetchJson(url: string): Promise { return fetch(url).then((res) => { @@ -26,15 +98,22 @@ function fetchJson(url: string): Promise { }); } -function useReleases() { - const [releases, setReleases] = useState(null); - const [error, setError] = useState(false); +function useJson(url: string | null) { + const [data, setData] = useState(null); + const [error, setError] = useState(false); useEffect(() => { + if (!url) { + setData(null); + setError(false); + return; + } let cancelled = false; - fetchJson(`${DOCS_BASE}/index.json`) - .then((data) => { - if (!cancelled) setReleases(data); + setData(null); + setError(false); + fetchJson(url) + .then((d) => { + if (!cancelled) setData(d); }) .catch(() => { if (!cancelled) setError(true); @@ -42,27 +121,32 @@ function useReleases() { return () => { cancelled = true; }; - }, []); + }, [url]); - return { releases, error }; + return { data, error }; } -function useReleaseMarkdown(file: string) { - const [html, setHtml] = useState(null); - const [error, setError] = useState(false); +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; - setHtml(null); + setState(null); setError(false); - fetch(`${DOCS_BASE}/${encodeURIComponent(file)}`) + fetch(`${DOCS_BASE}/${file}`) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); }) - .then(async (md) => { - const rendered = await marked.parse(md, { async: false, gfm: true }); - if (!cancelled) setHtml(DOMPurify.sanitize(rendered)); + .then((md) => { + const rendered = renderMarkdown(md); + if (!cancelled) setState(rendered); }) .catch(() => { if (!cancelled) setError(true); @@ -72,106 +156,300 @@ function useReleaseMarkdown(file: string) { }; }, [file]); - return { html, error }; + return { ...state, error }; } -function DocsIndex() { +// --------------------------------------------------------------------------- +// 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(); + + 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 `${text}`; + }, + }; + + 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 { + if (t.kind === "ref") return `/docs/reference/schemas/${t.value}`; + if (t.kind === "array" && /^[A-Z]/.test(t.value)) return `/docs/reference/schemas/${t.value}`; + return null; +} + +const METHOD_STYLES: Record = { + 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(); - const { releases, error } = useReleases(); - const [, setLocation] = useLocation(); - return ( - -
-
-

{t("docs.title")}

-

{t("docs.subtitle")}

-
- - {error && ( -

{t("docs.noDocs")}

- )} - - {!releases && !error && ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- )} - - {releases && releases.length === 0 && ( -

{t("docs.noDocs")}

- )} - - {releases && releases.length > 0 && ( -
- {releases.map((release) => ( - - - - - - ))} -
- )} +
+
+ +

{t("docs.title")}

- +
+ {onSearchChange && ( +
+ + onSearchChange(e.target.value)} + className="pl-8 w-52" + data-testid="input-docs-search" + /> +
+ )} + {versions.length > 0 && ( + + )} + +
+
); } -function DocsDetail() { +function DocsNav({ + version, + handbook, + reference, + versions, +}: { + version: string | null; + handbook: HandbookPage[] | null; + reference: Reference | null; + versions: ReleaseDoc[]; +}) { + const [location] = useLocation(); const { t } = useTranslation(); - const params = useParams<{ version: string }>(); - const version = params.version; - const { releases, error: indexError } = useReleases(); - const release = releases?.find((r) => r.version === version); - const { html, error: mdError } = useReleaseMarkdown(release?.file ?? `${version}.md`); + + 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 && version === null) { + groups.push({ + label: t("docs.guides"), + icon: BookOpen, + items: handbook.map((p) => ({ + href: `/docs/handbook/${p.slug}`, + label: p.title, + active: navLink(`/docs/handbook/${p.slug}`), + })), + }); + } + + if (reference) { + groups.push({ + label: t("docs.endpoints"), + icon: Server, + items: reference.tags.map((tag) => ({ + href: `/docs/reference/endpoints/${tag.name}`, + label: tag.name, + active: navLink(`/docs/reference/endpoints/${tag.name}`), + })), + }); + groups.push({ + label: t("docs.schemas"), + icon: Library, + items: reference.schemas.map((s) => ({ + href: `/docs/reference/schemas/${s.name}`, + label: s.name, + active: navLink(`/docs/reference/schemas/${s.name}`), + })), + }); + } + + groups.push({ + label: t("docs.releases"), + icon: Tag, + items: versions.map((v) => ({ + href: v.version === (version ?? versions[0]?.version) && version !== null + ? `/docs/releases/${v.version}` + : `/docs/releases/${v.version}`, + label: v.version, + active: navLink(`/docs/releases/${v.version}`), + })), + }); return ( - -
-
- - {release && ( - - {release.version} - - )} + + ); +} - {mdError || (release && !html) ? ( +function Toc({ headings, title }: { headings: Heading[]; title?: string }) { + const [activeId, setActiveId] = useState(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 ( + + ); +} + +function MarkdownView({ file }: { file: string | null }) { + const { t } = useTranslation(); + const { html, headings, error } = useMarkdown(file); + + return ( +
+
+ {error ? (

{t("docs.noDocs")}

) : !html ? (
@@ -186,12 +464,490 @@ function DocsDetail() { /> )}
- + +
); } -export default function Docs() { - const [location] = useLocation(); - const match = location.match(/^\/docs\/(.+)$/); - return match ? : ; +function FieldTypeChip({ type }: { type: FieldType }) { + const href = resolveTypeHref(type); + const label = fieldTypeLabel(type); + if (href) { + return ( + + + {label} + + + ); + } + return {label}; +} + +function FieldTable({ fields }: { fields: Field[] }) { + return ( +
+ + + + + + + + + + + {fields.map((f) => ( + + + + + + + ))} + +
FeldTypPflichtBeschreibung
+ + {f.name} + + + + + {f.required ? ( + required + ) : ( + + )} + +
{f.description}
+ {f.constraints && ( +
{f.constraints}
+ )} +
+
+ ); +} + +function SchemaView({ schema }: { schema: SchemaModel }) { + const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 })); + return ( +
+
+
+

{schema.name}

+ {schema.description &&

{schema.description}

} +
+ +

+ + Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle. +

+
+ +
+ ); +} + +function EndpointTagView({ tag }: { tag: TagGroup }) { + const headings: Heading[] = tag.endpoints.map((e) => ({ + id: e.operationId, + text: `${e.method} ${e.path}`, + level: 2, + })); + return ( +
+
+
+

{tag.name}

+ {tag.description &&

{tag.description}

} +
+ {tag.endpoints.map((ep) => ( +
+
+ + {ep.method} + + {ep.path} + + + +
+

{ep.summary}

+ {ep.description &&

{ep.description}

} + + {ep.parameters.length > 0 && ( +
+

Parameter

+
+ + + + + + + + + + + + {ep.parameters.map((p) => ( + + + + + + + + ))} + +
NameInTypPflichtBeschreibung
{p.name}{p.in} + {p.required ? req : "–"} + + {p.description} + {p.constraints && {p.constraints}} +
+
+
+ )} + + {ep.requestBody && ( +
+

+ Request Body {ep.requestBody.required && required} +

+ +
+ )} + +
+ + + + + + + + + + {ep.responses.map((r) => ( + + + + + + ))} + +
StatusBeschreibungSchema
{r.status}{r.description}
+
+
+ ))} +
+ +
+ ); +} + +function ReleasesView({ versions }: { versions: ReleaseDoc[] }) { + const [, setLocation] = useLocation(); + const { t } = useTranslation(); + return ( +
+
+
+

{t("docs.releases")}

+

{t("docs.subtitle")}

+
+
+ {versions.map((v) => ( + + ))} +
+
+ +
+ ); +} + +function ReleaseNoteView({ version }: { version: string }) { + return ( +
+
+
+ + {version} + + + +
+ +
+
+ ); +} + +function HandbookView({ slug }: { slug: string }) { + const handbookFile = `${slug}.md`; + return ; +} + +// --------------------------------------------------------------------------- +// Search overlay +// --------------------------------------------------------------------------- + +function useDocsSearch(query: string) { + const { data, error } = useJson(query ? `${DOCS_BASE}/search.json` : 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 { results } = useDocsSearch(query); + if (!query.trim()) return null; + return ( +
+ {results.length === 0 ? ( +

Keine Treffer

+ ) : ( + results.map((r) => ( + + + {r.kind === "endpoint" && } + {r.kind === "field" && } + {r.kind === "guide" && } + {r.kind === "release" && } + + + {r.title} + {r.text.slice(0, 80)} + + + )) + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function Docs() { + const [location, setLocation] = useLocation(); + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + + const { version, path } = useMemo(() => parseDocsPath(location), [location]); + + const { data: versionInfo } = useGetVersion({ + query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false }, + }); + + const { data: releases, error: releasesError } = useJson(`${DOCS_BASE}/index.json`); + const { data: handbook, error: handbookError } = useJson( + version === null ? `${DOCS_BASE}/handbook/index.json` : null, + ); + + 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` + : releases?.find((r) => r.version === version)?.hasReference + ? `${DOCS_BASE}/versions/${version}.json` + : null; + + const { data: reference, error: refError } = useJson(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]); + + // Redirect old-style /docs/vX.Y.Z to /docs/releases/vX.Y.Z + useEffect(() => { + if (version !== null && path.length === 0) { + setLocation(`/docs/releases/${version}`, { replace: true }); + } + }, [version, path, setLocation]); + + const handleVersionChange = (v: string | null) => { + setSearchQuery(""); + if (v === null || v === currentVersion) { + setLocation("/docs"); + return; + } + setLocation(`/docs/releases/${v}`); + }; + + // ---- route resolution ---- + const section = path[0] ?? "home"; + const param = path[1]; + + let content: React.ReactNode = null; + + if (version !== null && path.length === 0) { + content = ; + } else if (section === "home") { + content = + handbook && handbook.length > 0 ? ( + + ) : releases && releases.length > 0 ? ( + + ) : ( +

{t("docs.noDocs")}

+ ); + } else if (section === "handbook" && param) { + content = ; + } else if (section === "reference" && param === "endpoints" && path[2]) { + const tag = reference?.tags.find((tg) => tg.name === path[2]); + content = tag ? ( + + ) : refError || (reference && !tag) ? ( +

{t("docs.noDocs")}

+ ) : ( + + ); + } else if (section === "reference" && param === "schemas" && path[2]) { + const schema = reference?.schemas.find((s) => s.name === path[2]); + content = schema ? ( + + ) : refError || (reference && !schema) ? ( +

{t("docs.noDocs")}

+ ) : ( + + ); + } else if (section === "reference") { + content = ( +
+
+

{t("docs.reference")}

+

{t("docs.referenceIntro")}

+ {!reference && !refError && } + {reference && ( + <> +
+

{t("docs.endpoints")}

+
+ {reference.tags.map((tg) => ( + + {tg.name} + + {tg.endpoints.length} {t("docs.endpoints")} + + + ))} +
+
+
+

{t("docs.schemas")}

+
+ {reference.schemas.map((s) => ( + + {s.name} + + ))} +
+
+ + )} +
+ +
+ ); + } else if (section === "releases" && param) { + content = ; + } else if (section === "releases") { + content = releases ? : ; + } else { + content =

{t("docs.noDocs")}

; + } + + const showSearch = version === null && section !== "releases"; + + return ( + +
+ + + {showSearch && setSearchQuery("")} />} + +
+ +
{content}
+
+
+
+ ); } diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index ada05eb..00dc42d 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -60,6 +60,7 @@ import { } from "@/components/ui/alert-dialog"; import { customFetch } from "@workspace/api-client-react"; import { recordRecentTool } from "@/lib/recent-tools"; +import { FieldHelp } from "@/components/field-help"; const ratingSchema = z.object({ usefulness: z.number().min(1).max(5), @@ -776,7 +777,10 @@ export default function ToolDetail() { name="usefulness" render={({ field }) => ( - {t("detail.usefulness")} + + {t("detail.usefulness")} + {t("detail.usefulness")} +
( - {t("detail.usability")} + + {t("detail.usability")} + {t("detail.usability")} +
( - Comment (Optional) + + Comment (Optional) + Comment +