feat(docs): version-based docs, correct Gitea tag links, form guide help

- each release now carries a full docs snapshot (reference + handbook)
  under docs/releases/<v>/; the docs frontend loads a version's snapshot
  for /docs/vX.Y.Z/* and shows the handbook/reference of that version
- version dropdown navigates to /docs/<version> (docs home) instead of
  the release notes page; old /docs/vX.Y.Z redirect removed
- generator: --snapshot writes handbook + reference; build copies
  versioned snapshots (last 7 versions) into /docs/versions/<v>/
- fix Gitea tag links: /admin/tool-evaluator/tags/<v> was 404, correct
  URL is /releases/tag/<v>
- add GuideHelp button to forms/pages linking to the matching handbook
  guide (rating, tool add/edit, costs, compare, watchlist, analytics)
This commit is contained in:
opencode
2026-08-04 07:31:47 +02:00
parent d23990e322
commit 3bb4598d04
53 changed files with 7545 additions and 117 deletions
@@ -0,0 +1,29 @@
import { BookOpen } from "lucide-react";
import { Link } from "wouter";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function GuideHelp({
guide,
label,
}: {
guide: string;
label: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/handbook/${guide}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`guide-${guide}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<BookOpen className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Anleitung in der Dokumentation</TooltipContent>
</Tooltip>
);
}
+5 -1
View File
@@ -6,6 +6,7 @@ import {
GetTopToolsMetric
} from "@workspace/api-client-react";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -48,7 +49,10 @@ export default function Analytics() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Platform Analytics</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
Platform Analytics
<GuideHelp guide="analytics" label="Platform Analytics" />
</h1>
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
</div>
+5 -1
View File
@@ -8,6 +8,7 @@ import { Link, useSearch } from "wouter";
import { Layout } from "@/components/layout";
import { useAuth } from "@/hooks/use-auth";
import { RatingStars } from "@/components/rating-stars";
import { GuideHelp } from "@/components/guide-help";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -101,7 +102,10 @@ export default function Compare() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
{t("compare.title")}
<GuideHelp guide="vergleichen" label={t("compare.title")} />
</h1>
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
</div>
+111 -91
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, createContext, useContext } from "react";
import { Link, useLocation } from "wouter";
import { Marked } from "marked";
import DOMPurify from "dompurify";
@@ -42,6 +42,21 @@ import {
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}`;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -88,6 +103,7 @@ type ReleaseDoc = {
title: string;
date: string | null;
hasReference: boolean;
hasHandbook: boolean;
};
type HandbookPage = { slug: string; file: string; title: string; order: number };
@@ -230,8 +246,11 @@ function isLinkableType(t: FieldType): boolean {
}
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}`;
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;
}
@@ -328,14 +347,14 @@ function DocsNav({
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
if (handbook && handbook.length > 0 && version === null) {
if (handbook && handbook.length > 0) {
groups.push({
label: t("docs.guides"),
icon: BookOpen,
items: handbook.map((p) => ({
href: `/docs/handbook/${p.slug}`,
href: docsHref(version, `handbook/${p.slug}`),
label: p.title,
active: navLink(`/docs/handbook/${p.slug}`),
active: navLink(docsHref(version, `handbook/${p.slug}`)),
})),
});
}
@@ -345,18 +364,18 @@ function DocsNav({
label: t("docs.endpoints"),
icon: Server,
items: reference.tags.map((tag) => ({
href: `/docs/reference/endpoints/${tag.name}`,
href: docsHref(version, `reference/endpoints/${tag.name}`),
label: tag.name,
active: navLink(`/docs/reference/endpoints/${tag.name}`),
active: navLink(docsHref(version, `reference/endpoints/${tag.name}`)),
})),
});
groups.push({
label: t("docs.schemas"),
icon: Library,
items: reference.schemas.map((s) => ({
href: `/docs/reference/schemas/${s.name}`,
href: docsHref(version, `reference/schemas/${s.name}`),
label: s.name,
active: navLink(`/docs/reference/schemas/${s.name}`),
active: navLink(docsHref(version, `reference/schemas/${s.name}`)),
})),
});
}
@@ -365,9 +384,7 @@ function DocsNav({
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}`,
href: `/docs/releases/${v.version}`,
label: v.version,
active: navLink(`/docs/releases/${v.version}`),
})),
@@ -708,7 +725,7 @@ function ReleaseNoteView({ version }: { version: string }) {
<Tag className="h-4 w-4" />
<span className="font-mono">{version}</span>
<a
href={`${REPO_URL}/tags/${version}`}
href={`${REPO_URL}/releases/tag/${version}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
@@ -723,8 +740,9 @@ function ReleaseNoteView({ version }: { version: string }) {
}
function HandbookView({ slug }: { slug: string }) {
const version = useDocsVersion();
const handbookFile = `${slug}.md`;
return <MarkdownView file={`handbook/${handbookFile}`} />;
return <MarkdownView file={docsFile(version, `handbook/${handbookFile}`)} />;
}
// ---------------------------------------------------------------------------
@@ -803,9 +821,11 @@ export default function Docs() {
});
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(
version === null ? `${DOCS_BASE}/handbook/index.json` : null,
);
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 ||
@@ -814,7 +834,7 @@ export default function Docs() {
const refUrl = version === null
? `${DOCS_BASE}/reference.json`
: releases?.find((r) => r.version === version)?.hasReference
: activeRelease?.hasReference
? `${DOCS_BASE}/versions/${version}.json`
: null;
@@ -832,20 +852,13 @@ export default function Docs() {
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}`);
setLocation(`/docs/${v}`);
};
// ---- route resolution ----
@@ -864,7 +877,12 @@ export default function Docs() {
let content: React.ReactNode = null;
if (version !== null && path.length === 0) {
content = <ReleaseNoteView version={version} />;
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
) : (
<ReleaseNoteView version={version} />
);
} else if (section === "home") {
content =
handbook && handbook.length > 0 ? (
@@ -913,7 +931,7 @@ export default function Docs() {
{reference.tags.map((tg) => (
<Link
key={tg.name}
href={`/docs/reference/endpoints/${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>
@@ -930,7 +948,7 @@ export default function Docs() {
{reference.schemas.map((s) => (
<Link
key={s.name}
href={`/docs/reference/schemas/${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}
@@ -955,75 +973,77 @@ export default function Docs() {
const showSearch = version === null && section !== "releases";
return (
<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>
<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">
<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 ?? []}
/>
</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>
</aside>
<div className="min-w-0">{content}</div>
</div>
<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={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>
);
}
+9 -2
View File
@@ -61,6 +61,7 @@ import {
import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const ratingSchema = z.object({
usefulness: z.number().min(1).max(5),
@@ -615,7 +616,10 @@ export default function ToolDetail() {
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}</DialogTitle>
<DialogTitle className="flex items-center gap-2">
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
<GuideHelp guide="kosten" label={t("detail.costs")} />
</DialogTitle>
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -765,7 +769,10 @@ export default function ToolDetail() {
{isReviewFormOpen && (
<Card className="border-primary shadow-sm">
<CardHeader>
<CardTitle>{t("detail.addReview")}</CardTitle>
<CardTitle className="flex items-center gap-2">
{t("detail.addReview")}
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardTitle>
<CardDescription>Share your experience with {tool.name}</CardDescription>
</CardHeader>
<CardContent>
@@ -31,6 +31,7 @@ import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -160,6 +161,7 @@ export default function ToolEdit() {
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-bearbeiten" label="Tool Details" />
</CardTitle>
<CardDescription>Modify the tool information below.</CardDescription>
</CardHeader>
@@ -19,6 +19,7 @@ import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -123,6 +124,7 @@ export default function ToolNew() {
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-anlegen" label="Tool Details" />
</CardTitle>
<CardDescription>Provide the basic information about the tool.</CardDescription>
</CardHeader>
+5 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist";
import { Layout } from "@/components/layout";
import { ToolCard } from "@/components/tool-card";
import { GuideHelp } from "@/components/guide-help";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ShieldAlert, Bookmark } from "lucide-react";
@@ -42,7 +43,10 @@ export default function Watchlist() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
{t("watchlist.title")}
<GuideHelp guide="watchlist" label={t("watchlist.title")} />
</h1>
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
</div>