Compare commits

...

7 Commits

Author SHA1 Message Date
opencode 9ceb11e7f9 docs: link v0.8.0 release notes to commit 6c92b63
Build & Push Docker Image / build (push) Successful in 3m14s
2026-08-03 22:16:00 +02:00
opencode 6c92b6358d feat(docs): mkdocs-style documentation site served at /docs
Full documentation hub replacing the release-notes-only view:
- Handbook pages (docs/handbook) for all features and admin/betrieb
- API reference generated from lib/api-spec/openapi.yaml via
  scripts/src/generate-docs.mjs (replaces sync-release-docs.mjs):
  endpoints, schemas/fields, search index, per-release snapshots
- mkdocs layout: sidebar nav, right TOC with scrollspy, search overlay,
  version dropdown, repo link
- FieldHelp (?) buttons in forms linking to reference field docs
- v0.7.0 release notes backfilled, v0.8.0 release notes added
2026-08-03 22:15:54 +02:00
opencode 520f917723 feat(docs): version-bound release documentation served at /docs
Build & Push Docker Image / build (push) Successful in 2m35s
Adds a docs pipeline so each release has a version-bound Markdown
document (docs/releases/vX.Y.Z.md) rendered publicly in the app:

- sync-release-docs.mjs copies docs/releases/*.md into the toolrate
  public dir and generates index.json before every dev/build
- /docs lists all releases; /docs/:version renders the sanitized
  Markdown (marked + DOMPurify, typography styles)
- template + workflow documented in docs/README.md
- current release (v0.6.0) documented as the first entry
2026-08-03 16:41:08 +02:00
opencode d1dd77bc1e chore(deps): bump tsx to 4.23.4 (only remaining outdated package)
Build & Push Docker Image / build (push) Successful in 3m56s
2026-08-03 15:33:17 +02:00
opencode 0be45b6513 ci: drop nightly and sha image tags, deploy only on v* tags
Build & Push Docker Image / build (push) Successful in 2m18s
Branch pushes now build and push only 'latest'; tag pushes add the
v*-tag and update the k8s manifest. Removes the daily nightly-* and
per-commit sha-* tags that accumulated registry storage.
2026-08-03 14:10:03 +02:00
opencode 4b1274e34a fix(ci): use pnpm 11 allowBuilds instead of onlyBuiltDependencies
Build & Push Docker Image / build (push) Successful in 2m40s
pnpm 11 treats ignored build scripts as a hard error during fresh
installs (CI/Docker), while the old onlyBuiltDependencies list is
deprecated. The stray 'allowBuilds: esbuild: set this to true or false'
placeholder was invalid YAML semantics and blocked the esbuild
postinstall, failing 'pnpm install --frozen-lockfile' in the Docker
builder stage (ERR_PNPM_IGNORED_BUILDS).
2026-08-03 13:06:43 +02:00
opencode 2f66fff993 chore(deps): upgrade all dependencies to latest and pin exact
Build & Push Docker Image / build (push) Failing after 1m18s
Policy: every direct dependency is now an exact pin; lockfile + --frozen-lockfile keep builds reproducible; save-exact=true enforced.

Toolchain:
- pnpm 10.26 -> 11.18 (lockfile migrated; supportedArchitectures moved to pnpm-workspace.yaml)
- typescript 5.9 -> 7.0, vite 7 -> 8, @vitejs/plugin-react 5 -> 6
- esbuild override 0.27.3 -> 0.28.1 (closes GHSA-g7r4-m6w7-qqqr); added @esbuild/darwin-arm64 for local dev
- orval 8.9 -> 8.23, regenerated clients

Backend (prod image):
- openid-client 5.7 -> 6.8 (rewritten OIDC flow in routes/auth.ts: discovery + functional API, PKCE/state, fetchUserInfo, buildEndSessionUrl)
- pino 9 -> 10, pino-http 10 -> 11, thread-stream 3 -> 4
- zod 3.25 -> 4.4 (catalog; supported by drizzle-zod 0.8.3)
- pg 8.20 -> 8.22; removed deprecated @types/bcryptjs

Frontend:
- react/react-dom 19.1.0 -> 19.2.8 (catalog pin lifted; expo note removed)
- react-day-picker 9 -> 10 (table classname -> month_grid)
- recharts 2 -> 3 (TooltipContentProps + DefaultLegendContentProps typing; safe keys)
- react-resizable-panels 2 -> 4 (Group/Separator rename)
- date-fns 3 -> 4, @hookform/resolvers 3 -> 5, lucide-react 0.545 -> 1.28
- all @radix-ui/*, tailwind, types, and remaining patch/minor deps bumped to latest

Security/process:
- overrides for body-parser >=2.3.0 (GHSA-v422-hmwv-36x6) + markdown-it/linkify-it/brace-expansion/fast-uri (dev tooling)
- pnpm audit now reports 0 vulnerabilities (prod and full)
- CI audit gate added to build.yaml; docs/dependency-policy.md; renovate.json
- Dockerfile pins node:24.18.1-alpine and pnpm@11.18.0
2026-08-03 12:56:00 +02:00
104 changed files with 10890 additions and 2402 deletions
+16 -12
View File
@@ -14,6 +14,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install pnpm
run: npm install -g pnpm@11.18.0
- name: Security audit (fails on any prod or high/critical finding)
run: |
pnpm audit --prod
pnpm audit --audit-level high
- name: Install Docker CLI - name: Install Docker CLI
run: | run: |
apt-get update -qq apt-get update -qq
@@ -30,33 +38,29 @@ jobs:
FULL_SHA=$(git rev-parse HEAD) FULL_SHA=$(git rev-parse HEAD)
IMAGE="git.kubebase.de/${{ gitea.repository }}" IMAGE="git.kubebase.de/${{ gitea.repository }}"
DATE_STAMP=$(date -u +"%Y%m%d") DATE_STAMP=$(date -u +"%Y%m%d")
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
TAGS="-t ${IMAGE}:latest"
if [ "${{ gitea.ref_type }}" = "tag" ]; then if [ "${{ gitea.ref_type }}" = "tag" ]; then
VERSION="${{ gitea.ref_name }}" VERSION="${{ gitea.ref_name }}"
VERSION_TAG="${{ gitea.ref_name }}" TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
else
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
VERSION_TAG="nightly-${DATE_STAMP}"
fi fi
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
docker build --no-cache \ docker build --no-cache \
--build-arg COMMIT_SHA="$FULL_SHA" \ --build-arg COMMIT_SHA="$FULL_SHA" \
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--build-arg VERSION="$VERSION" \ --build-arg VERSION="$VERSION" \
$TAGS . $TAGS .
docker push "${IMAGE}:sha-${SHA}"
docker push "${IMAGE}:latest" docker push "${IMAGE}:latest"
docker push "${IMAGE}:${VERSION_TAG}" if [ "${{ gitea.ref_type }}" = "tag" ]; then
docker push "${IMAGE}:${VERSION}"
fi
- name: Update k8s manifest in admin/apps - name: Update k8s manifest in admin/apps
if: gitea.ref_type == 'tag'
env: env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
if [ "${{ gitea.ref_type }}" = "tag" ]; then NEWTAG="${{ gitea.ref_name }}"
NEWTAG="${{ gitea.ref_name }}"
else
NEWTAG="sha-${SHA}"
fi
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
cd /tmp/apps cd /tmp/apps
cd apps/system/toolrate/overlays/k3s cd apps/system/toolrate/overlays/k3s
+3
View File
@@ -47,3 +47,6 @@ Thumbs.db
# Replit # Replit
.cache/ .cache/
.local/ .local/
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
/artifacts/toolrate/public/docs/
+1
View File
@@ -1,2 +1,3 @@
auto-install-peers=false auto-install-peers=false
strict-peer-dependencies=false strict-peer-dependencies=false
save-exact=true
+4 -4
View File
@@ -1,7 +1,7 @@
FROM node:24-alpine AS builder FROM node:24.18.1-alpine AS builder
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@10.26.1 RUN npm install -g pnpm@11.18.0
# 1. Alle Projektdateien in den Container bringen # 1. Alle Projektdateien in den Container bringen
COPY . . COPY . .
@@ -17,10 +17,10 @@ ENV PORT=8080
RUN pnpm -r --if-present run build RUN pnpm -r --if-present run build
FROM node:24-alpine AS runner FROM node:24.18.1-alpine AS runner
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@10.26.1 RUN npm install -g pnpm@11.18.0
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY lib/db/package.json lib/db/ COPY lib/db/package.json lib/db/
+19 -20
View File
@@ -12,30 +12,29 @@
"dependencies": { "dependencies": {
"@workspace/api-zod": "workspace:*", "@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*", "@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3", "bcryptjs": "3.0.3",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "10.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "1.4.7",
"cors": "^2.8.6", "cors": "2.8.6",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"express": "^5.2.1", "express": "5.2.1",
"express-rate-limit": "^8.6.1", "express-rate-limit": "8.6.1",
"express-session": "^1.19.0", "express-session": "1.19.0",
"openid-client": "^5.7.1", "openid-client": "6.8.4",
"pino": "^9.14.0", "pino": "10.3.1",
"pino-http": "^10.5.0", "pino-http": "11.0.0",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0", "@types/connect-pg-simple": "7.0.3",
"@types/connect-pg-simple": "^7.0.3", "@types/cookie-parser": "1.4.10",
"@types/cookie-parser": "^1.4.10", "@types/cors": "2.8.19",
"@types/cors": "^2.8.19", "@types/express": "5.0.6",
"@types/express": "^5.0.6", "@types/express-session": "1.19.0",
"@types/express-session": "^1.19.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"esbuild": "0.27.3", "esbuild": "0.28.1",
"esbuild-plugin-pino": "^2.3.3", "esbuild-plugin-pino": "2.3.3",
"pino-pretty": "^13.1.3", "pino-pretty": "13.1.3",
"thread-stream": "3.1.0" "thread-stream": "4.2.0"
} }
} }
+33 -27
View File
@@ -1,5 +1,16 @@
import { Router, type IRouter, type Request } from "express"; import { Router, type IRouter, type Request } from "express";
import { Issuer, generators, type Client } from "openid-client"; import {
discovery,
randomPKCECodeVerifier,
calculatePKCECodeChallenge,
randomState,
buildAuthorizationUrl,
authorizationCodeGrant,
fetchUserInfo,
buildEndSessionUrl,
skipSubjectCheck,
type Configuration,
} from "openid-client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { eq, and, inArray, isNull } from "drizzle-orm"; import { eq, and, inArray, isNull } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
@@ -12,7 +23,7 @@ import { getCsrfToken } from "../middleware/csrf";
const router: IRouter = Router(); const router: IRouter = Router();
let cachedClient: Client | null = null; let cachedConfig: Configuration | null = null;
function isOidcConfigured(): boolean { function isOidcConfigured(): boolean {
return !!( return !!(
@@ -39,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
} }
} }
async function getClient(): Promise<Client | null> { async function getClient(): Promise<Configuration | null> {
if (cachedClient) return cachedClient; if (cachedConfig) return cachedConfig;
const keycloakUrl = process.env.KEYCLOAK_URL; const keycloakUrl = process.env.KEYCLOAK_URL;
const realm = process.env.KEYCLOAK_REALM; const realm = process.env.KEYCLOAK_REALM;
@@ -52,14 +63,9 @@ async function getClient(): Promise<Client | null> {
} }
try { try {
const issuerUrl = `${keycloakUrl}/realms/${realm}`; const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
const issuer = await Issuer.discover(issuerUrl); cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
cachedClient = new issuer.Client({ return cachedConfig;
client_id: clientId,
client_secret: clientSecret,
response_types: ["code"],
});
return cachedClient;
} catch (err) { } catch (err) {
logger.error({ err }, "Failed to discover Keycloak issuer"); logger.error({ err }, "Failed to discover Keycloak issuer");
return null; return null;
@@ -176,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
return; return;
} }
const codeVerifier = generators.codeVerifier(); const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier); const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const state = generators.state(); const state = randomState();
req.session.codeVerifier = codeVerifier; req.session.codeVerifier = codeVerifier;
req.session.oidcState = state; req.session.oidcState = state;
@@ -187,7 +193,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
} }
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`; const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
const url = client.authorizationUrl({ const url = buildAuthorizationUrl(client, {
scope: "openid email profile", scope: "openid email profile",
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: "S256", code_challenge_method: "S256",
@@ -195,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
state, state,
}); });
res.redirect(url); res.redirect(url.href);
}); });
router.get("/auth/callback", async (req, res): Promise<void> => { router.get("/auth/callback", async (req, res): Promise<void> => {
@@ -221,13 +227,13 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`; const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
try { try {
const params = client.callbackParams(req); const tokens = await authorizationCodeGrant(
const tokenSet = await client.callback(redirectUri, params, { client,
code_verifier: codeVerifier, new URL(req.originalUrl ?? "/", getBaseUrl(req)),
state, { pkceCodeVerifier: codeVerifier, expectedState: state },
}); );
const userinfo = await client.userinfo(tokenSet.access_token!); const userinfo = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
const dbUser = await upsertUserFromOidc(userinfo); const dbUser = await upsertUserFromOidc(userinfo);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -259,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
req.session.destroy(() => {}); req.session.destroy(() => {});
const client = await getClient(); const client = await getClient();
if (client && client.issuer.metadata.end_session_endpoint) { if (client && client.serverMetadata().end_session_endpoint) {
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) }); const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl); res.redirect(logoutUrl.href);
return; return;
} }
@@ -292,7 +298,7 @@ router.get("/auth/password-redirect", async (req, res): Promise<void> => {
res.json({ url: null }); res.json({ url: null });
return; return;
} }
const realm = client.issuer.metadata.issuer ?? ""; const realm = client.serverMetadata().issuer ?? "";
res.json({ url: `${realm}/account/password` }); res.json({ url: `${realm}/account/password` });
}); });
+44 -43
View File
@@ -10,34 +10,34 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "5.7.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "2.3.7",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "1.1.23",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "2.1.15",
"@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-menubar": "1.1.24",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "1.2.22",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "1.1.18",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "1.2.16",
"@replit/vite-plugin-cartographer": "catalog:", "@replit/vite-plugin-cartographer": "catalog:",
"@replit/vite-plugin-runtime-error-modal": "catalog:", "@replit/vite-plugin-runtime-error-modal": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
@@ -45,29 +45,30 @@
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:", "@vitejs/plugin-react": "catalog:",
"chokidar": "^4.0.3", "chokidar": "5.0.0",
"class-variance-authority": "catalog:", "class-variance-authority": "catalog:",
"clsx": "catalog:", "clsx": "catalog:",
"cmdk": "^1.1.1", "cmdk": "1.1.1",
"date-fns": "^3.6.0", "date-fns": "4.4.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "8.6.0",
"fast-glob": "^3.3.3", "fast-glob": "3.3.3",
"framer-motion": "catalog:", "framer-motion": "catalog:",
"input-otp": "^1.4.2", "input-otp": "1.4.2",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"next-themes": "^0.4.6", "next-themes": "0.4.6",
"react": "catalog:", "react": "catalog:",
"react-day-picker": "^9.14.0", "react-day-picker": "10.0.1",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-hook-form": "^7.75.0", "react-hook-form": "7.84.0",
"react-resizable-panels": "^2.1.9", "react-is": "19.2.8",
"recharts": "^2.15.4", "react-resizable-panels": "4.12.2",
"sonner": "^2.0.7", "recharts": "3.10.1",
"sonner": "2.0.7",
"tailwind-merge": "catalog:", "tailwind-merge": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "1.0.7",
"tw-animate-css": "^1.4.0", "tw-animate-css": "1.4.0",
"vaul": "^1.1.2", "vaul": "1.1.2",
"vite": "catalog:", "vite": "catalog:",
"zod": "catalog:" "zod": "catalog:"
} }
@@ -84,7 +84,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
table: "w-full border-collapse", month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
@@ -3,6 +3,7 @@ import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = { export type ChartConfig = {
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & RechartsPrimitive.TooltipContentProps &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean hideIndicator?: boolean
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
return ( return (
<div <div
key={item.dataKey} key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
className={cn( className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center" indicator === "dot" && "items-center"
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
) )
ChartLegendContent.displayName = "ChartLegend" ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(
config: ChartConfig, config: ChartConfig,
payload: unknown, payload: unknown,
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({ const ResizablePanelGroup = ({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( }: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.PanelGroup <ResizablePrimitive.Group
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[group-orientation=vertical]:flex-col",
className className
)} )}
{...props} {...props}
@@ -24,12 +24,12 @@ const ResizableHandle = ({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean withHandle?: boolean
}) => ( }) => (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.Separator
className={cn( className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
className className
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" /> <GripVertical className="h-2.5 w-2.5" />
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.Separator>
) )
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+50 -47
View File
@@ -4,44 +4,44 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --config vite.config.ts --host 0.0.0.0", "dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
"build": "vite build --config vite.config.ts", "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", "serve": "vite preview --config vite.config.ts --host 0.0.0.0",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "5.7.1",
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-alert-dialog": "1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.3", "@radix-ui/react-aspect-ratio": "1.1.15",
"@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "^1.1.4", "@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-context-menu": "^2.2.7", "@radix-ui/react-context-menu": "2.3.7",
"@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-hover-card": "^1.1.7", "@radix-ui/react-hover-card": "1.1.23",
"@radix-ui/react-label": "^2.1.3", "@radix-ui/react-label": "2.1.15",
"@radix-ui/react-menubar": "^1.1.7", "@radix-ui/react-menubar": "1.1.24",
"@radix-ui/react-navigation-menu": "^1.2.6", "@radix-ui/react-navigation-menu": "1.2.22",
"@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "^1.1.3", "@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "^1.2.4", "@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "^1.2.4", "@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "^2.1.7", "@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "^1.2.4", "@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "^1.1.4", "@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "^1.2.7", "@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle": "1.1.18",
"@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "1.2.16",
"@replit/vite-plugin-cartographer": "catalog:", "@replit/vite-plugin-cartographer": "catalog:",
"@replit/vite-plugin-dev-banner": "catalog:", "@replit/vite-plugin-dev-banner": "catalog:",
"@replit/vite-plugin-runtime-error-modal": "catalog:", "@replit/vite-plugin-runtime-error-modal": "catalog:",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "catalog:", "@tanstack/react-virtual": "catalog:",
@@ -52,29 +52,32 @@
"@workspace/api-client-react": "workspace:*", "@workspace/api-client-react": "workspace:*",
"class-variance-authority": "catalog:", "class-variance-authority": "catalog:",
"clsx": "catalog:", "clsx": "catalog:",
"cmdk": "^1.1.1", "cmdk": "1.1.1",
"date-fns": "^3.6.0", "date-fns": "4.4.0",
"embla-carousel-react": "^8.6.0", "dompurify": "catalog:",
"embla-carousel-react": "8.6.0",
"framer-motion": "catalog:", "framer-motion": "catalog:",
"i18next": "^26.3.6", "i18next": "26.3.6",
"input-otp": "^1.4.2", "input-otp": "1.4.2",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"next-themes": "^0.4.6", "marked": "catalog:",
"next-themes": "0.4.6",
"react": "catalog:", "react": "catalog:",
"react-day-picker": "^9.11.1", "react-day-picker": "10.0.1",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-hook-form": "^7.55.0", "react-hook-form": "7.84.0",
"react-i18next": "^17.0.11", "react-i18next": "17.0.11",
"react-icons": "^5.4.0", "react-icons": "5.7.0",
"react-resizable-panels": "^2.1.7", "react-is": "19.2.8",
"recharts": "^2.15.2", "react-resizable-panels": "4.12.2",
"sonner": "^2.0.7", "recharts": "3.10.1",
"sonner": "2.0.7",
"tailwind-merge": "catalog:", "tailwind-merge": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0", "tw-animate-css": "1.4.0",
"vaul": "^1.1.2", "vaul": "1.1.2",
"vite": "catalog:", "vite": "catalog:",
"wouter": "^3.3.5", "wouter": "catalog:",
"zod": "catalog:" "zod": "catalog:"
} }
} }
+2
View File
@@ -20,6 +20,7 @@ import Trash from "@/pages/trash";
import Compare from "@/pages/compare"; import Compare from "@/pages/compare";
import Watchlist from "@/pages/watchlist"; import Watchlist from "@/pages/watchlist";
import Login from "@/pages/login"; import Login from "@/pages/login";
import Docs from "@/pages/docs";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -46,6 +47,7 @@ function Router() {
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} /> <Route path="/admin/redundancy" component={Redundancy} />
<Route path="/trash" component={Trash} /> <Route path="/trash" component={Trash} />
<Route path="/docs/*?" component={Docs} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
); );
@@ -37,6 +37,31 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
crumbs.push({ label: t("compare.title") }); crumbs.push({ label: t("compare.title") });
} else if (location.startsWith("/analytics")) { } else if (location.startsWith("/analytics")) {
crumbs.push({ label: t("nav.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")) { } else if (location.startsWith("/login")) {
crumbs.push({ label: t("auth.signIn") }); crumbs.push({ label: t("auth.signIn") });
} }
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/reference/schemas/${schema}#${field}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`help-${schema}-${field}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Details in der Dokumentation</TooltipContent>
</Tooltip>
);
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react"; import { LayoutDashboard, Wrench, BarChart3, FileText, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
@@ -39,6 +39,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
{ href: "/", label: t("nav.home"), icon: LayoutDashboard }, { href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench }, { href: "/tools", label: t("nav.browseTools"), icon: Wrench },
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
{ href: "/docs", label: t("nav.docs"), icon: FileText },
]; ];
const adminLinks = [ const adminLinks = [
@@ -84,7 +84,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
table: "w-full border-collapse", month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
@@ -102,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & RechartsPrimitive.TooltipContentProps &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean hideIndicator?: boolean
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
return ( return (
<div <div
key={item.dataKey} key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
className={cn( className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center" indicator === "dot" && "items-center"
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({ const ResizablePanelGroup = ({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( }: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.PanelGroup <ResizablePrimitive.Group
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[group-orientation=vertical]:flex-col",
className className
)} )}
{...props} {...props}
@@ -24,12 +24,12 @@ const ResizableHandle = ({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean withHandle?: boolean
}) => ( }) => (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.Separator
className={cn( className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
className className
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" /> <GripVertical className="h-2.5 w-2.5" />
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.Separator>
) )
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -12,6 +12,7 @@
"trash": "Papierkorb", "trash": "Papierkorb",
"admin": "Admin", "admin": "Admin",
"redundancy": "Redundanz", "redundancy": "Redundanz",
"docs": "Doku",
"search": "Tools suchen…" "search": "Tools suchen…"
}, },
"auth": { "auth": {
@@ -181,6 +182,24 @@
"text": "Diese Seite existiert nicht.", "text": "Diese Seite existiert nicht.",
"backHome": "Zurück zur Startseite" "backHome": "Zurück zur Startseite"
}, },
"docs": {
"title": "Dokumentation",
"subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.",
"backToIndex": "Alle Releases",
"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": { "command": {
"navigate": "Navigation", "navigate": "Navigation",
"recent": "Zuletzt besucht", "recent": "Zuletzt besucht",
@@ -12,6 +12,7 @@
"trash": "Trash", "trash": "Trash",
"admin": "Admin", "admin": "Admin",
"redundancy": "Redundancy", "redundancy": "Redundancy",
"docs": "Docs",
"search": "Search tools…" "search": "Search tools…"
}, },
"auth": { "auth": {
@@ -181,6 +182,24 @@
"text": "This page doesn't exist.", "text": "This page doesn't exist.",
"backHome": "Back to Home" "backHome": "Back to Home"
}, },
"docs": {
"title": "Documentation",
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
"backToIndex": "All releases",
"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": { "command": {
"navigate": "Navigate", "navigate": "Navigate",
"recent": "Recent", "recent": "Recent",
+19
View File
@@ -265,6 +265,25 @@
*/ */
@layer utilities { @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 */ /* Hide ugly search cancel button in Chrome until we can style it properly */
input[type="search"]::-webkit-search-cancel-button { input[type="search"]::-webkit-search-cancel-button {
@apply hidden; @apply hidden;
+953
View File
@@ -0,0 +1,953 @@
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 { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
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;
};
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<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 {
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<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 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 (
<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[] }) {
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>
</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">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 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" />
Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.
</p>
</div>
<Toc headings={headings} title="Felder" />
</div>
);
}
function EndpointTagView({ tag }: { tag: TagGroup }) {
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">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>
</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">req</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">
Request Body {ep.requestBody.required && <Badge className="ml-1">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">Status</th>
<th className="px-3 py-2 font-semibold">Beschreibung</th>
<th className="px-3 py-2 font-semibold">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="Endpunkte" />
</div>
);
}
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
const [, setLocation] = useLocation();
const { t } = useTranslation();
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">{v.title}</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">API-Referenz</Badge>}
</button>
))}
</div>
</div>
<Toc headings={[]} />
</div>
);
}
function ReleaseNoteView({ version }: { version: string }) {
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}/tags/${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={`releases/${version}.md`} />
</div>
</div>
);
}
function HandbookView({ slug }: { slug: string }) {
const handbookFile = `${slug}.md`;
return <MarkdownView file={`handbook/${handbookFile}`} />;
}
// ---------------------------------------------------------------------------
// Search overlay
// ---------------------------------------------------------------------------
function useDocsSearch(query: string) {
const { data, error } = useJson<SearchEntry[]>(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 (
<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>
) : (
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 { 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 { data: handbook, error: handbookError } = useJson<HandbookPage[]>(
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<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]);
// 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 = <ReleaseNoteView version={version} />;
} else if (section === "home") {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
) : 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} />;
} else if (section === "reference" && param === "endpoints" && path[2]) {
const tag = reference?.tags.find((tg) => tg.name === path[2]);
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 === path[2]);
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={`/docs/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={`/docs/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} />;
} 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 (
<Layout>
<div className="mx-auto max-w-6xl space-y-6 pb-10">
<DocsHeader
versions={releases ?? []}
activeVersion={version}
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>
</Layout>
);
}
+17 -4
View File
@@ -60,6 +60,7 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react"; import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools"; import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
const ratingSchema = z.object({ const ratingSchema = z.object({
usefulness: z.number().min(1).max(5), usefulness: z.number().min(1).max(5),
@@ -776,7 +777,10 @@ export default function ToolDetail() {
name="usefulness" name="usefulness"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("detail.usefulness")}</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usefulness")}
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
</FormLabel>
<div className="py-2"> <div className="py-2">
<RatingStars <RatingStars
value={field.value} value={field.value}
@@ -794,7 +798,10 @@ export default function ToolDetail() {
name="usability" name="usability"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("detail.usability")}</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usability")}
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
</FormLabel>
<div className="py-2"> <div className="py-2">
<RatingStars <RatingStars
value={field.value} value={field.value}
@@ -814,7 +821,10 @@ export default function ToolDetail() {
name="comment" name="comment"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Comment (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Comment (Optional)
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="What do you think about this tool?" placeholder="What do you think about this tool?"
@@ -832,7 +842,10 @@ export default function ToolDetail() {
name="reviewerName" name="reviewerName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Name (Optional)
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Anonymous" {...field} /> <Input placeholder="Anonymous" {...field} />
</FormControl> </FormControl>
+32 -10
View File
@@ -30,6 +30,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input"; import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input"; import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
const toolSchema = z.object({ const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"), name: z.string().min(2, "Name must be at least 2 characters"),
@@ -171,7 +172,10 @@ export default function ToolEdit() {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Tool name" {...field} /> <Input placeholder="Tool name" {...field} />
</FormControl> </FormControl>
@@ -184,7 +188,10 @@ export default function ToolEdit() {
name="category" name="category"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Category</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} /> <CategoryCombobox value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
@@ -198,8 +205,11 @@ export default function ToolEdit() {
control={form.control} control={form.control}
name="websiteUrl" name="websiteUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Website URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://..." type="url" {...field} /> <Input placeholder="https://..." type="url" {...field} />
</FormControl> </FormControl>
@@ -212,8 +222,11 @@ export default function ToolEdit() {
control={form.control} control={form.control}
name="iconUrl" name="iconUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0"> <div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
@@ -245,8 +258,11 @@ export default function ToolEdit() {
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="What does this tool do?" placeholder="What does this tool do?"
@@ -262,7 +278,10 @@ export default function ToolEdit() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Features</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p> <p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}> <Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
@@ -306,7 +325,10 @@ export default function ToolEdit() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Tags</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p> <p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}> <Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
+34 -12
View File
@@ -18,6 +18,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input"; import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input"; import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
const toolSchema = z.object({ const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"), name: z.string().min(2, "Name must be at least 2 characters"),
@@ -134,7 +135,10 @@ export default function ToolNew() {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" /> <Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
</FormControl> </FormControl>
@@ -148,7 +152,10 @@ export default function ToolNew() {
name="category" name="category"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Category</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<CategoryCombobox <CategoryCombobox
value={field.value} value={field.value}
@@ -161,12 +168,15 @@ export default function ToolNew() {
/> />
</div> </div>
<FormField <FormField
control={form.control} control={form.control}
name="websiteUrl" name="websiteUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Website URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" /> <Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
</FormControl> </FormControl>
@@ -180,7 +190,10 @@ export default function ToolNew() {
name="iconUrl" name="iconUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0"> <div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
@@ -214,7 +227,10 @@ export default function ToolNew() {
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="What does this tool do? Why do people use it?" placeholder="What does this tool do? Why do people use it?"
@@ -231,7 +247,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Features</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p> <p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
</div> </div>
<Button <Button
@@ -284,7 +303,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Tags</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p> <p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
</div> </div>
<Button <Button
+51
View File
@@ -0,0 +1,51 @@
# Dokumentation (Handbuch, API-Referenz, Release-Notes)
Die App zeigt unter `/docs` eine MkDocs-artige Doku-Seite mit drei Bereichen:
- **Handbuch** (`docs/handbook/*.md`) — von Hand gepflegte Anleitungen
- **API-Referenz** (`lib/api-spec/openapi.yaml`) — automatisch generierte
Endpunkte & Datenfelder (Schema-Detailseiten mit Feld-Ankern; die `?`-Icons
in Formularen verlinken auf diese Felder)
- **Release-Notes** (`docs/releases/vX.Y.Z.md`) — pro Release
## Struktur
- `docs/handbook/` — Handbuch-Seiten mit Frontmatter (`title`, `order`)
- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases
- `docs/releases/vX.Y.Z.md` — Notes pro Release
- `docs/releases/vX.Y.Z/reference.json` — API-Snapshot der jeweiligen Version
## Generator
`scripts/src/generate-docs.mjs` wird beim Frontend-Build (und `dev`) automatisch
ausgeführt und schreibt die Artefakte nach `artifacts/toolrate/public/docs/`:
- `reference.json` (aktuelle API), `search.json` (Suchindex),
`index.json` (Releases), `handbook/*.md` + `handbook/index.json`
- `releases/vX.Y.Z.md` und `versions/vX.Y.Z.json` (API-Snapshots alter Versionen)
Manuell aufrufbar:
```sh
node scripts/src/generate-docs.mjs # Build-Modus
node scripts/src/generate-docs.mjs --snapshot v0.9.0 # Snapshot für neue Version
```
## Workflow beim Release
1. **Version taggen** wie bisher (`git tag vX.Y.Z`, CI baut und deployed).
2. **`docs/releases/vX.Y.Z.md` anlegen** — Vorlage aus `TEMPLATE.md` kopieren,
Entwurf aus der Git-Historie ableiten:
```sh
git log --oneline vX.Y.Z-1..vX.Y.Z
```
(API-Delta anhand `lib/api-spec/openapi.yaml` prüfen.)
3. **API-Snapshot erzeugen:** `node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`
erzeugt `docs/releases/vX.Y.Z/reference.json`.
4. **Committen & pushen.** Der Build kopiert die Dokumentation automatisch nach
`artifacts/toolrate/public/docs/` und generiert `index.json`.
> Hinweis: Alle Dateien unter `artifacts/toolrate/public/docs/` sind
> Build-Artefakte und werden bei jedem Build neu generiert — nicht von Hand
> bearbeiten. Einzige Quellen sind `docs/handbook/`, `docs/releases/` und
> `lib/api-spec/openapi.yaml`.
+54
View File
@@ -0,0 +1,54 @@
# Dependency Policy
How this workspace keeps npm dependencies current, safe and reproducible.
## Principles
1. **Exact pins** Every direct dependency in `package.json` is pinned to an
exact version (`1.2.3`, never `^1.2.3`). `save-exact=true` is set in
`.npmrc` so `pnpm add` follows this rule automatically.
2. **Committed lockfile** `pnpm-lock.yaml` is committed. CI and the Docker
build install with `--frozen-lockfile`, so builds are reproducible.
3. **Security gates** CI runs `pnpm audit --prod` (fails on any finding) and
`pnpm audit --audit-level high` (fails on high/critical). A non-zero exit
blocks the release pipeline.
4. **Supply-chain protection** `minimumReleaseAge: 1440` (1 day) in
`pnpm-workspace.yaml` blocks freshly published versions. Do not lower or
disable it; only allowlist trusted publishers via
`minimumReleaseAgeExclude` for urgent security fixes.
5. **Pinned critical packages** `react`, `react-dom` and `esbuild` are
intentionally excluded from automated updates (see `renovate.json`). Bump
them deliberately, one release at a time, with a test pass.
## Update cadence
| Frequency | Scope | Who |
|-----------|-------|-----|
| Weekly | Patch + minor (grouped by Renovate) | Renovate PR, human merge after green CI |
| Monthly | One major version at a time | Human, own commit + release tag |
| As needed | Security advisories | Immediate fix + patch release |
| Yearly | Infrastructure review (Node LTS, Postgres major, k3s) | Human |
## Rules
- **Patch/minor**: merge freely once CI (typecheck + build + audit) is green.
- **Major**: never bundle multiple majors into one release. One major per
commit so regressions can be bisected to the responsible change.
- **"Safe intermediate"**: if a package has a newer major that is not yet
absorbed, stay on the latest patch/minor of the *current* major line. The
exact-pin guarantees we never float into a new major by accident.
- **Node/Infra**: Node base image and pnpm version in the `Dockerfile` are
pinned exactly. Update them together with a build + live smoke test.
- **Postgres**: image tag is managed in the `admin/apps` repository
(`apps/system/toolrate`). Major upgrades run through a backup/restore flow.
## Process for applying updates
1. `pnpm install` to refresh the lockfile.
2. Regenerate clients if the API changed: `pnpm --filter @workspace/api-spec run codegen`.
3. Run `pnpm run typecheck`, `pnpm run build` (with `PORT=8080 BASE_PATH=/`),
and `pnpm audit`.
4. Fix any code that the new majors require (the common ones are
`openid-client`, `recharts`, `react-day-picker`, `date-fns`,
`@hookform/resolvers`, `react-resizable-panels`).
5. Commit, tag `vX.Y.Z`, push. CI builds, audits and deploys to k3s.
+61
View File
@@ -0,0 +1,61 @@
---
title: Administration & Papierkorb
order: 7
---
# Administration & Papierkorb
Diese Bereiche sind nur für **Administrator:innen** sichtbar und nutzbar.
## Nutzerverwaltung
Unter **Admin → Nutzer** kannst du:
- **Lokale Nutzer anlegen** (Benutzername, Passwort, E-Mail, Rolle, Tier).
- **Rollen/Tier ändern** (`admin`/`user`, `free`/`premium`/`enterprise`).
- **Passwörter zurücksetzen** (nur lokale Nutzer).
- **Nutzer löschen**.
Zugehörige Endpunkte (Admin-only):
- [`GET /users`](/docs/reference/endpoints/users#listusers)
- [`POST /users`](/docs/reference/endpoints/users#createuser)
- [`PATCH /users/{id}`](/docs/reference/endpoints/users#updateuser)
- [`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteuser)
- [`PATCH /users/{id}/password`](/docs/reference/endpoints/users#setuserpassword)
## Audit-Log
Das **Audit-Log** protokolliert sicherheitsrelevante Änderungen (wer hat wann
was geändert). Es ist über
[`GET /audit-logs`](/docs/reference/endpoints/audit#listauditlogs) abrufbar und
filterbar nach Entitätstyp, Entitäts-ID und Limit.
## Papierkorb (Trash)
Tools werden nicht sofort gelöscht, sondern zuerst **soft gelöscht** (in den
Papierkorb verschoben):
- **Liste:** [`GET /tools/trash`](/docs/reference/endpoints/tools#listtrashedtools)
- **In den Papierkorb verschieben:** [`POST /tools/trash`](/docs/reference/endpoints/tools#trashtools)
- **Wiederherstellen:** [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoretools)
- **Endgültig löschen (einzeln):** [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deletetrashedtools)
- **Papierkorb leeren:** [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptytrash)
> Die Aufbewahrungsfrist des Papierkorbs (in Tagen) ist im
> [`VersionInfo`](/docs/reference/schemas/versioninfo)-Schema als
> `trashRetentionDays` verfügbar.
## Felder im Überblick
### User
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `id` | integer | Eindeutige Nutzer-ID. |
| `username` | string | Anmeldename. |
| `email` | string? | E-Mail-Adresse. |
| `role` | string | `admin` oder `user`. |
| `tier` | string | `free`, `premium` oder `enterprise`. |
| `authProvider` | string | `local` oder `oidc`. |
| `createdAt` | date-time | Erstellungszeitpunkt. |
+37
View File
@@ -0,0 +1,37 @@
---
title: Analytics
order: 6
---
# Analytics
Der Bereich **Analytics** fasst die Plattform-Statistiken zusammen — für alle
Nutzer:innen ohne Einschränkung sichtbar.
## Übersicht
| Widget | Quelle (Endpunkt) | Inhalt |
| --- | --- | --- |
| **Plattform-Kennzahlen** | [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getanalyticssummary) | Gesamtzahl Tools, Bewertungen, Durchschnittswerte, Kategorienzahl. |
| **Top-Tools** | [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#gettoppertools) | Bestbewertete Tools nach wählbarer Metrik (Nützlichkeit, Bedienbarkeit, kombiniert). |
| **Nach Kategorie** | [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getanalyticsbycategory) | Kennzahlen je Kategorie. |
| **Verteilung** | [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getratingdistribution) | Verteilung der Bewertungswerte (optional je Tool). |
## Datenfelder
### AnalyticsSummary
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `totalTools` | integer | Anzahl aller Tools. |
| `totalRatings` | integer | Anzahl aller Bewertungen. |
| `avgUsefulness` | number? | Durchschnittliche Nützlichkeit. |
| `avgUsability` | number? | Durchschnittliche Bedienbarkeit. |
| `avgCombined` | number? | Durchschnitt kombinierter Wert. |
| `categoriesCount` | integer | Anzahl der Kategorien. |
| `mostRatedTool` | ToolWithStats | Das meistbewertete Tool. |
Vollständige Feldlisten: [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary),
[`TopToolEntry`](/docs/reference/schemas/topptoolentry),
[`CategoryStats`](/docs/reference/schemas/categorystats),
[`RatingDistribution`](/docs/reference/schemas/ratingdistribution).
+45
View File
@@ -0,0 +1,45 @@
---
title: Bewertungen
order: 4
---
# Bewertungen
Bewertungen sind das Herz von toolr: Sie zeigen, wie nützlich und wie gut
bedienbar ein Tool in den Augen der Community ist.
## Wie funktioniert die Bewertung?
Auf der Detailseite eines Tools vergibst du zwei Werte (15 Sterne):
- **Nützlichkeit** — Wie gut löst das Tool sein Kernproblem?
- **Bedienbarkeit** — Wie einfach ist es zu bedienen?
Optional kannst du einen **Kommentar** und deinen **Namen** hinterlassen. Die
Eingabefelder entsprechen dem Schema
[`RatingInput`](/docs/reference/schemas/ratinginput).
## Was passiert mit meiner Bewertung?
- Deine Bewertung wird sofort gespeichert und in den Durchschnittswerten des
Tools berücksichtigt.
- Jede Bewertung ist über [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listtoolratings)
abrufbar.
- Der **Rating-Verlauf** über die Zeit ist über
[`GET /tools/{id}/rating-history`](/docs/reference/endpoints/tools#gettoolratinghistory)
einsehbar (grafisch auf der Detailseite).
## Datenfelder
Eine Bewertung besteht aus diesen Feldern
([`Rating`](/docs/reference/schemas/rating)):
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `id` | integer | Eindeutige ID der Bewertung. |
| `toolId` | integer | ID des bewerteten Tools. |
| `usefulness` | integer (15) | Nützlichkeitsbewertung. |
| `usability` | integer (15) | Bedienbarkeitsbewertung. |
| `comment` | string? | Optionaler Kommentar. |
| `reviewerName` | string? | Optionaler Anzeigename des Bewerters. |
| `createdAt` | date-time | Zeitpunkt der Bewertung. |
+60
View File
@@ -0,0 +1,60 @@
---
title: Datenmodell & Felder
order: 8
---
# Datenmodell & Felder
Dieses Handbuch erklärt die zentralen Objekte von toolr auf verständliche
Weise. Die **vollständige, maschinell generierte Feld-Referenz** findest du in
der [Referenz](/docs/reference/schemas/tool) — dort sind alle Typen, Pflicht-
angaben und Constraints der aktuellen Version dokumentiert.
> Die Referenz ist **versionsgebunden**: Über das Versions-Dropdown oben
> kannst du ältere API-Stände einsehen.
## Die wichtigsten Objekte
### Tool
Ein Tool ist der zentrale Eintrag im Katalog
([Feld-Referenz](/docs/reference/schemas/tool)):
| Feld | Bedeutung |
| --- | --- |
| `id` | Eindeutige ID. |
| `name` | Anzeigename. |
| `description` | Kurzbeschreibung. |
| `category` | Kategorie-Zuordnung. |
| `websiteUrl` / `iconUrl` | Offizielle Website bzw. Logo-Link (optional). |
| `createdBy` | Nutzer, der das Tool angelegt hat (optional). |
| `features` / `tags` | Listen von Schlüsselfähigkeiten bzw. Schlagwörtern. |
| `createdAt` / `updatedAt` | Zeitstempel. |
| `deletedAt` / `deletedBy` | Soft-Delete-Informationen (Papierkorb). |
> **ToolWithStats** erweitert `Tool` um die Aggregatwerte `ratingCount`,
> `avgUsefulness`, `avgUsability` und `avgCombined` (siehe
> [Feld-Referenz](/docs/reference/schemas/toolwithstats)).
### Rating
Eine Bewertung (`Rating`) besteht aus `usefulness` und `usability` (jeweils
15) sowie optionalem Kommentar und Bewerternamen. Details unter
[Bewertungen](/docs/handbook/bewertungen).
### User / AuthUser
- **User** (Admin-Sicht): `id`, `username`, `email`, `role`, `tier`,
`authProvider`, `createdAt` — siehe [Administration](/docs/handbook/administration).
- **AuthUser** (Eigenansicht): `sub`, `email`, `name`, `preferredUsername`,
`role`, `tier`, `entitlements`, `isLocal`.
### VersionInfo
`GET /version` liefert `version`, `commitSha`, `buildDate` und
`trashRetentionDays` (siehe [Feld-Referenz](/docs/reference/schemas/versioninfo)).
## Referenz selber durchsuchen
Nutze das **Suchfeld** in der Doku-Seitenleiste: Es durchsucht Handbuch,
Endpunkt- und Feldbeschreibungen und springt direkt zum passenden Anker.
+54
View File
@@ -0,0 +1,54 @@
---
title: Erste Schritte
order: 2
---
# Erste Schritte
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
Besuch bis zum Anlegen und Bewerten eines Tools.
## 1. Anmelden
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist) erfordern ein
Konto. Klicke oben rechts auf **Anmelden**. Je nach Konfiguration der Instanz
hast du zwei Möglichkeiten:
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter.
Welcher Modus aktiv ist, steht im [Endpunkt
`GET /auth/mode`](/docs/reference/endpoints/auth#getauthmode).
## 2. Tools finden
Öffne den Bereich **Tools durchsuchen**:
- **Suchen** — Volltextsuche über Name & Beschreibung.
- **Filtern** — nach Kategorie, Tags und Features; zusätzlich
Mindestbewertung (`minRating`).
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
(auf-/absteigend) oder letztem Update.
Die Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
[`GET /tools`](/docs/reference/endpoints/tools#listtools).
## 3. Tool anlegen
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
findest du im [Handbuch "Tool anlegen"](/docs/handbook/tool-anlegen) und in der
[Feld-Referenz](/docs/reference/schemas/toolinput).
## 4. Bewerten
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und
**Bedienbarkeit** (jeweils 15) vergeben und optional einen Kommentar
hinterlassen. Deine Bewertung fließt sofort in die Statistiken ein.
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Administration & Papierkorb](/docs/handbook/administration)
+50
View File
@@ -0,0 +1,50 @@
---
title: Überblick
order: 1
---
# Willkommen bei toolr
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
die richtige Wahl zu treffen.
## Was kannst du mit toolr tun?
| Funktion | Beschreibung | Sichtbarkeit |
| --- | --- | --- |
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
| **Admin** | Nutzerverwaltung, Audit-Log, Papierkorb | Admin |
| **Trash** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Admin |
## Funktionen & Felder im Detail
Die **Referenz** ist automatisch aus der OpenAPI-Spezifikation generiert und
deckt damit garantiert *alle* Endpunkte und Datenfelder der aktuellen Version
ab:
- [Endpunkte](/docs/reference/endpoints/tools) — jede API-Operation mit
Parametern und Antwort-Schemas.
- [Datenmodell & Felder](/docs/reference/schemas/tool) — jedes Feld mit Typ,
Pflichtstatus und Bedeutung.
> Die Referenz ist **versionsgebunden**: Wähle oben rechts eine ältere Version,
> um den API-Stand dieses Releases zu sehen.
## Erste Schritte
- Neu hier? Starte mit dem [Erste-Schritte-Guide](/docs/handbook/getting-started).
- Möchtest du ein Tool eintragen? Siehe [Tool anlegen](/docs/handbook/tool-anlegen).
- Formulare zeigen neben jedem Feld ein **Hilfe-Icon (?)**, das direkt zur
Erklärung des Felds in der Doku springt.
## Wo ist der Quellcode?
Über das **Repository-Logo oben rechts** gelangst du direkt zum Quellcode auf
GitHub.
+49
View File
@@ -0,0 +1,49 @@
---
title: Tool anlegen & bearbeiten
order: 3
---
# Tool anlegen & bearbeiten
## Neues Tool anlegen
Unter **Tool hinzufügen** legst du ein neues Tool an. Die Felder entsprechen
dem Eingabeschema [`ToolInput`](/docs/reference/schemas/toolinput):
| Feld | Pflicht | Bedeutung |
| --- | --- | --- |
| **Name** | ja | Anzeigename des Tools (min. 2 Zeichen). |
| **Beschreibung** | ja | Was tut das Tool, warum nutzen es Leute? (min. 10 Zeichen) |
| **Kategorie** | ja | Zugeordnete Kategorie (aus bestehenden Kategorien wählbar). |
| **Website-URL** | nein | Offizielle Website (`https://…`). |
| **Icon-/Logo-URL** | nein | Direktlink zu einem Logo-Bild. |
| **Features** | nein | Schlüsselfähigkeiten, z. B. „Echtzeit-Kollaboration“. Bereits bekannte Features sind auswählbar. |
| **Tags** | nein | Schlagwörter zum Auffinden. Bereits bekannte Tags sind auswählbar. |
> Hinter jedem Label findest du ein **Hilfe-Icon (?)** — es verlinkt direkt
> zur Feldbeschreibung in dieser Doku.
### Hinweise
- **Features & Tags** sind Listen. Über **+ Feature / + Tag** fügst du weitere
Einträge hinzu; über das ✕-Symbol entfernst du sie.
- URLs müssen absolut und gültig sein.
- Leere Einträge in Feature-/Tag-Listen werden beim Speichern verworfen.
## Tool bearbeiten
Auf der Detailseite eines Tools öffnet **Bearbeiten** das Formular mit den
aktuellen Werten. Du kannst Name, Beschreibung, Kategorie, URLs, Features und
Tags ändern. Nur angemeldete Nutzer:innen können Tools bearbeiten.
## Tool löschen
Über die Detailseite kannst du ein Tool **in den Papierkorb verschieben**
(Soft-Delete). Es verschwindet aus dem Katalog, bleibt aber im Papierkorb
erhalten. Siehe [Administration & Papierkorb](/docs/handbook/administration).
## Zugehörige Endpunkte
- [`POST /tools`](/docs/reference/endpoints/tools#createtool) — Tool anlegen
- [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updatetool) — Tool bearbeiten
- [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deletetool) — Tool löschen
+46
View File
@@ -0,0 +1,46 @@
---
title: Vergleichen & Watchlist
order: 5
---
# Vergleichen & Watchlist
Diese Funktionen sind für **Premium-Nutzer:innen** verfügbar.
## Tools vergleichen
Mit **Vergleichen** stellst du mehrere Tools nebeneinander und siehst deren
Daten auf einen Blick — ideal für die Tool-Auswahl.
1. Füge Tools über die **Vergleichsleiste** (Vergleichs-Icon auf Karten) hinzu.
2. Öffne den Bereich **Vergleichen**. Die Tools erscheinen in der gewählten
Reihenfolge.
3. Die Vergleichsansicht zeigt pro Tool die wichtigsten Felder und
Durchschnittswerte.
Der zugrunde liegende Endpunkt ist
[`GET /compare`](/docs/reference/endpoints/tools#listcomparetools) mit dem
Parameter `ids` (kommagetrennt). Ohne Premium-Berechtigung liefert er `403`.
## Watchlist
Die **Watchlist** ist deine persönliche Favoritenliste:
- **Hinzufügen/Entfernen:** Nutze das Lesezeichen-Symbol auf der
Tool-Karte oder der Detailseite.
- Die gespeicherte Reihenfolge bleibt erhalten.
- Sie wird über [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getmewatchlist)
geladen; das Setzen erfolgt über
[`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updatemepreferences)
(Feld `watchlist`).
### Feld `watchlist`
Das Feld `watchlist` in [`UserPreferences`](/docs/reference/schemas/userpreferences)
ist ein Array von Tool-IDs in gespeicherter Reihenfolge:
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `view` | string | Ansichtsmodus (`grid`, `table`, `rows`). |
| `density` | string | Dichte (`cozy`, `compact`). |
| `watchlist` | integer[] | Tool-IDs in Favoriten-Reihenfolge. |
+35
View File
@@ -0,0 +1,35 @@
# vX.Y.Z — Release Notes
> Template für neue Release-Dokumentationen. Eine Kopie pro Release unter
> `docs/releases/vX.Y.Z.md` anlegen, Platzhalter ersetzen, Abschnitte die
> nicht zutreffen entfernen. Die Seite wird unter `/docs/vX.Y.Z` in der App
> angezeigt.
**Datum:** YYYY-MM-DD · **Tag:** [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
## Neue Features
- ...
## Fixes & Verbesserungen
- ...
## API-Änderungen
- ... (neue/geänderte/entfernte Endpunkte — siehe `lib/api-spec/openapi.yaml`)
## Betrieb / Upgrade
- **Env-Vars:** ... (neu/geändert/entfernt)
- **Migration:** ... (Datenbank-/Schema-Änderungen, Schritte für den Betreiber)
- **Breaking Changes:** ... (nur wenn vorhanden)
## Bekannte Einschränkungen
- ...
## Links
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
+37
View File
@@ -0,0 +1,37 @@
# v0.6.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
## Neue Features
- Vollständige Modernisierung aller Abhängigkeiten auf die aktuellen Hauptversionen
(TypeScript 7, Vite 8, React 19.2, openid-client 6, zod 4, recharts 3, react-day-picker 10).
## Fixes & Verbesserungen
- CI-Build durch `allowBuilds`-Konfiguration für pnpm 11 repariert
(Build-Scripts für esbuild & Co. werden nicht mehr blockiert).
- Image-Tagging vereinfacht: nur noch `latest` und `v*`-Tags, keine `nightly-*`/`sha-*`-Tags.
- Alle Dependencies exakt gepinnt; automatische Updates via Renovate vorbereitet
(`renovate.json`, `docs/dependency-policy.md`).
## API-Änderungen
- Keine Breaking Changes an der API. openid-client intern auf v6 migriert
(auth-Fluss verhält sich identisch).
## Betrieb / Upgrade
- **Env-Vars:** unverändert. Node-Image auf `node:24.18.1-alpine` gepinnt.
- **Migration:** keine Datenbank-Migration erforderlich.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- `typedoc` (indirekte orval-Abhängigkeit) zeigt eine Peer-Dependency-Warnung
(erwartet TypeScript 5.x/6.x, installiert ist 7.x) — harmlos für Build & Laufzeit.
## Links
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
+35
View File
@@ -0,0 +1,35 @@
# v0.7.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
## Neue Features
- Version-gebundene **Release-Dokumentation** in der App unter `/docs`
(Index + Detailseite je Version, Markdown aus `docs/releases/`).
- Generiertes Release-Vorlage (`docs/releases/TEMPLATE.md`) und
Sync-Schritt für den Frontend-Build.
## Fixes & Verbesserungen
- `tsx` auf 4.23.4 angehoben — letzte veraltete Abhängigkeit im Workspace
(`pnpm outdated -r` ist jetzt leer).
## API-Änderungen
- Keine Breaking Changes an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Die Doku ist bisher auf Release-Notes beschränkt; eine vollständige
API-/Feld-Referenz folgt in v0.8.0.
## Links
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
# v0.8.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
## Neue Features
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
- **Handbuch** mit verständlichen Erklärungen zu allen Features
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
Analytics, Administration, Datenmodell).
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
damit ist garantiert, dass *jedes* Feature dokumentiert ist.
- **Suche** über Handbuch, Endpunkte und Felder.
- **Versions-Dropdown**: ältere Releases behalten ihre vollständige
Feld-/Endpunkt-Referenz als Snapshot.
- **Repo-Link** oben rechts zur Quelle.
- **Hilfe-Buttons (?) in Formularen** (NetBox-Stil): neben jedem Feld
springt ein Icon direkt zur Feldbeschreibung in der Doku.
## Fixes & Verbesserungen
- Doku-Generator `scripts/src/generate-docs.mjs` ersetzt den bisherigen
`sync-release-docs.mjs` (OpenAPI-Parsing, Handbuch, Suchindex, Snapshots).
- Dokumentation für v0.7.0 nachgezogen.
## API-Änderungen
- Keine Breaking Changes an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Handbuch & Referenz gelten für die aktuelle Version; ältere Versionen
zeigen ihre Release-Notes und einen Referenz-Snapshot, sofern beim Release
erzeugt (`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API — Tool listing and rating platform * ToolRate API — Tool listing and rating platform
+113 -95
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API — Tool listing and rating platform * ToolRate API — Tool listing and rating platform
@@ -70,6 +70,21 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
const withQueryKey = <T extends object, K>(query: T, queryKey: K): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') continue;
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
export const getHealthCheckUrl = () => { export const getHealthCheckUrl = () => {
@@ -82,7 +97,7 @@ export const getHealthCheckUrl = () => {
* Returns server health status * Returns server health status
* @summary Health check * @summary Health check
*/ */
export const healthCheck = async ( options?: RequestInit): Promise<HealthStatus> => { export const healthCheck = async ( options?: Parameters<typeof customFetch>[1]): Promise<HealthStatus> => {
return customFetch<HealthStatus>(getHealthCheckUrl(), return customFetch<HealthStatus>(getHealthCheckUrl(),
{ {
@@ -139,7 +154,7 @@ export function useHealthCheck<TData = Awaited<ReturnType<typeof healthCheck>>,
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -160,7 +175,7 @@ export const getGetVersionUrl = () => {
* Returns the running build version, commit SHA and build date * Returns the running build version, commit SHA and build date
* @summary Build version information * @summary Build version information
*/ */
export const getVersion = async ( options?: RequestInit): Promise<VersionInfo> => { export const getVersion = async ( options?: Parameters<typeof customFetch>[1]): Promise<VersionInfo> => {
return customFetch<VersionInfo>(getGetVersionUrl(), return customFetch<VersionInfo>(getGetVersionUrl(),
{ {
@@ -217,7 +232,7 @@ export function useGetVersion<TData = Awaited<ReturnType<typeof getVersion>>, TE
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -232,7 +247,7 @@ export const getListToolsUrl = (params?: ListToolsParams,) => {
Object.entries(params || {}).forEach(([key, value]) => { Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString()) normalizedParams.append(key, value === null ? 'null' : String(value))
} }
}); });
@@ -244,7 +259,7 @@ export const getListToolsUrl = (params?: ListToolsParams,) => {
/** /**
* @summary List all tools * @summary List all tools
*/ */
export const listTools = async (params?: ListToolsParams, options?: RequestInit): Promise<ToolWithStats[]> => { export const listTools = async (params?: ListToolsParams, options?: Parameters<typeof customFetch>[1]): Promise<ToolWithStats[]> => {
return customFetch<ToolWithStats[]>(getListToolsUrl(params), return customFetch<ToolWithStats[]>(getListToolsUrl(params),
{ {
@@ -301,7 +316,7 @@ export function useListTools<TData = Awaited<ReturnType<typeof listTools>>, TErr
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -321,21 +336,21 @@ export const getCreateToolUrl = () => {
/** /**
* @summary Create a new tool * @summary Create a new tool
*/ */
export const createTool = async (toolInput: ToolInput, options?: RequestInit): Promise<Tool> => { export const createTool = async (toolInput: ToolInput, options?: Parameters<typeof customFetch>[1]): Promise<Tool> => {
return customFetch<Tool>(getCreateToolUrl(), return customFetch<Tool>(getCreateToolUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(toolInput)
toolInput,)
} }
);} );}
export const getCreateToolMutationOptions = <TError = ErrorType<ErrorResponse>, export const getCreateToolMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createTool>>, TError,{data: BodyType<ToolInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createTool>>, TError,{data: BodyType<ToolInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createTool>>, TError,{data: BodyType<ToolInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof createTool>>, TError,{data: BodyType<ToolInput>}, TContext> => {
@@ -387,7 +402,7 @@ export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => {
Object.entries(params || {}).forEach(([key, value]) => { Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString()) normalizedParams.append(key, value === null ? 'null' : String(value))
} }
}); });
@@ -399,7 +414,7 @@ export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => {
/** /**
* @summary Compare tools side by side (premium) * @summary Compare tools side by side (premium)
*/ */
export const listCompareTools = async (params: ListCompareToolsParams, options?: RequestInit): Promise<ToolWithStats[]> => { export const listCompareTools = async (params: ListCompareToolsParams, options?: Parameters<typeof customFetch>[1]): Promise<ToolWithStats[]> => {
return customFetch<ToolWithStats[]>(getListCompareToolsUrl(params), return customFetch<ToolWithStats[]>(getListCompareToolsUrl(params),
{ {
@@ -456,7 +471,7 @@ export function useListCompareTools<TData = Awaited<ReturnType<typeof listCompar
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -476,7 +491,7 @@ export const getGetToolRatingHistoryUrl = (id: number,) => {
/** /**
* @summary Get a tool's rating history over time * @summary Get a tool's rating history over time
*/ */
export const getToolRatingHistory = async (id: number, options?: RequestInit): Promise<RatingHistoryItem[]> => { export const getToolRatingHistory = async (id: number, options?: Parameters<typeof customFetch>[1]): Promise<RatingHistoryItem[]> => {
return customFetch<RatingHistoryItem[]>(getGetToolRatingHistoryUrl(id), return customFetch<RatingHistoryItem[]>(getGetToolRatingHistoryUrl(id),
{ {
@@ -513,7 +528,7 @@ const {query: queryOptions, request: requestOptions} = options ?? {};
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData> & { queryKey: QueryKey } return { queryKey, queryFn, enabled: id !== null && id !== undefined, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData> & { queryKey: QueryKey }
} }
export type GetToolRatingHistoryQueryResult = NonNullable<Awaited<ReturnType<typeof getToolRatingHistory>>> export type GetToolRatingHistoryQueryResult = NonNullable<Awaited<ReturnType<typeof getToolRatingHistory>>>
@@ -533,7 +548,7 @@ export function useGetToolRatingHistory<TData = Awaited<ReturnType<typeof getToo
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -553,7 +568,7 @@ export const getGetToolUrl = (id: number,) => {
/** /**
* @summary Get a tool by ID * @summary Get a tool by ID
*/ */
export const getTool = async (id: number, options?: RequestInit): Promise<ToolWithStats> => { export const getTool = async (id: number, options?: Parameters<typeof customFetch>[1]): Promise<ToolWithStats> => {
return customFetch<ToolWithStats>(getGetToolUrl(id), return customFetch<ToolWithStats>(getGetToolUrl(id),
{ {
@@ -590,7 +605,7 @@ const {query: queryOptions, request: requestOptions} = options ?? {};
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTool>>, TError, TData> & { queryKey: QueryKey } return { queryKey, queryFn, enabled: id !== null && id !== undefined, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTool>>, TError, TData> & { queryKey: QueryKey }
} }
export type GetToolQueryResult = NonNullable<Awaited<ReturnType<typeof getTool>>> export type GetToolQueryResult = NonNullable<Awaited<ReturnType<typeof getTool>>>
@@ -610,7 +625,7 @@ export function useGetTool<TData = Awaited<ReturnType<typeof getTool>>, TError =
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -631,21 +646,21 @@ export const getUpdateToolUrl = (id: number,) => {
* @summary Update a tool * @summary Update a tool
*/ */
export const updateTool = async (id: number, export const updateTool = async (id: number,
toolUpdate: ToolUpdate, options?: RequestInit): Promise<Tool> => { toolUpdate: ToolUpdate, options?: Parameters<typeof customFetch>[1]): Promise<Tool> => {
return customFetch<Tool>(getUpdateToolUrl(id), return customFetch<Tool>(getUpdateToolUrl(id),
{ {
...options, ...options,
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(toolUpdate)
toolUpdate,)
} }
);} );}
export const getUpdateToolMutationOptions = <TError = ErrorType<ErrorResponse>, export const getUpdateToolMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateTool>>, TError,{id: number;data: BodyType<ToolUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateTool>>, TError,{id: number;data: BodyType<ToolUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateTool>>, TError,{id: number;data: BodyType<ToolUpdate>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof updateTool>>, TError,{id: number;data: BodyType<ToolUpdate>}, TContext> => {
@@ -702,7 +717,7 @@ export const getDeleteToolUrl = (id: number,) => {
/** /**
* @summary Delete a tool * @summary Delete a tool
*/ */
export const deleteTool = async (id: number, options?: RequestInit): Promise<void> => { export const deleteTool = async (id: number, options?: Parameters<typeof customFetch>[1]): Promise<void> => {
return customFetch<void>(getDeleteToolUrl(id), return customFetch<void>(getDeleteToolUrl(id),
{ {
@@ -716,6 +731,7 @@ export const deleteTool = async (id: number, options?: RequestInit): Promise<voi
export const getDeleteToolMutationOptions = <TError = ErrorType<ErrorResponse>, export const getDeleteToolMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTool>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTool>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteTool>>, TError,{id: number}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof deleteTool>>, TError,{id: number}, TContext> => {
@@ -767,7 +783,7 @@ export const getListTrashedToolsUrl = (params?: ListTrashedToolsParams,) => {
Object.entries(params || {}).forEach(([key, value]) => { Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString()) normalizedParams.append(key, value === null ? 'null' : String(value))
} }
}); });
@@ -779,7 +795,7 @@ export const getListTrashedToolsUrl = (params?: ListTrashedToolsParams,) => {
/** /**
* @summary List trashed (soft-deleted) tools * @summary List trashed (soft-deleted) tools
*/ */
export const listTrashedTools = async (params?: ListTrashedToolsParams, options?: RequestInit): Promise<Tool[]> => { export const listTrashedTools = async (params?: ListTrashedToolsParams, options?: Parameters<typeof customFetch>[1]): Promise<Tool[]> => {
return customFetch<Tool[]>(getListTrashedToolsUrl(params), return customFetch<Tool[]>(getListTrashedToolsUrl(params),
{ {
@@ -836,7 +852,7 @@ export function useListTrashedTools<TData = Awaited<ReturnType<typeof listTrashe
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -856,21 +872,21 @@ export const getTrashToolsUrl = () => {
/** /**
* @summary Move tools to trash (admin) * @summary Move tools to trash (admin)
*/ */
export const trashTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise<TrashTools200> => { export const trashTools = async (trashToolsInput: TrashToolsInput, options?: Parameters<typeof customFetch>[1]): Promise<TrashTools200> => {
return customFetch<TrashTools200>(getTrashToolsUrl(), return customFetch<TrashTools200>(getTrashToolsUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(trashToolsInput)
trashToolsInput,)
} }
);} );}
export const getTrashToolsMutationOptions = <TError = ErrorType<ErrorResponse>, export const getTrashToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof trashTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => {
@@ -927,21 +943,21 @@ export const getDeleteTrashedToolsUrl = () => {
/** /**
* @summary Permanently delete trashed tools (admin) * @summary Permanently delete trashed tools (admin)
*/ */
export const deleteTrashedTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise<void> => { export const deleteTrashedTools = async (trashToolsInput: TrashToolsInput, options?: Parameters<typeof customFetch>[1]): Promise<void> => {
return customFetch<void>(getDeleteTrashedToolsUrl(), return customFetch<void>(getDeleteTrashedToolsUrl(),
{ {
...options, ...options,
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(trashToolsInput)
trashToolsInput,)
} }
);} );}
export const getDeleteTrashedToolsMutationOptions = <TError = ErrorType<ErrorResponse>, export const getDeleteTrashedToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof deleteTrashedTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => {
@@ -998,21 +1014,21 @@ export const getRestoreToolsUrl = () => {
/** /**
* @summary Restore trashed tools * @summary Restore trashed tools
*/ */
export const restoreTools = async (trashToolsInput: TrashToolsInput, options?: RequestInit): Promise<RestoreTools200> => { export const restoreTools = async (trashToolsInput: TrashToolsInput, options?: Parameters<typeof customFetch>[1]): Promise<RestoreTools200> => {
return customFetch<RestoreTools200>(getRestoreToolsUrl(), return customFetch<RestoreTools200>(getRestoreToolsUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(trashToolsInput)
trashToolsInput,)
} }
);} );}
export const getRestoreToolsMutationOptions = <TError = ErrorType<ErrorResponse>, export const getRestoreToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof restoreTools>>, TError,{data: BodyType<TrashToolsInput>}, TContext> => {
@@ -1069,7 +1085,7 @@ export const getEmptyTrashUrl = () => {
/** /**
* @summary Permanently delete all trashed tools (admin) * @summary Permanently delete all trashed tools (admin)
*/ */
export const emptyTrash = async ( options?: RequestInit): Promise<EmptyTrash200> => { export const emptyTrash = async ( options?: Parameters<typeof customFetch>[1]): Promise<EmptyTrash200> => {
return customFetch<EmptyTrash200>(getEmptyTrashUrl(), return customFetch<EmptyTrash200>(getEmptyTrashUrl(),
{ {
@@ -1083,6 +1099,7 @@ export const emptyTrash = async ( options?: RequestInit): Promise<EmptyTrash200>
export const getEmptyTrashMutationOptions = <TError = ErrorType<ErrorResponse>, export const getEmptyTrashMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext> => {
@@ -1139,7 +1156,7 @@ export const getListToolRatingsUrl = (id: number,) => {
/** /**
* @summary List ratings for a tool * @summary List ratings for a tool
*/ */
export const listToolRatings = async (id: number, options?: RequestInit): Promise<Rating[]> => { export const listToolRatings = async (id: number, options?: Parameters<typeof customFetch>[1]): Promise<Rating[]> => {
return customFetch<Rating[]>(getListToolRatingsUrl(id), return customFetch<Rating[]>(getListToolRatingsUrl(id),
{ {
@@ -1176,7 +1193,7 @@ const {query: queryOptions, request: requestOptions} = options ?? {};
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listToolRatings>>, TError, TData> & { queryKey: QueryKey } return { queryKey, queryFn, enabled: id !== null && id !== undefined, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listToolRatings>>, TError, TData> & { queryKey: QueryKey }
} }
export type ListToolRatingsQueryResult = NonNullable<Awaited<ReturnType<typeof listToolRatings>>> export type ListToolRatingsQueryResult = NonNullable<Awaited<ReturnType<typeof listToolRatings>>>
@@ -1196,7 +1213,7 @@ export function useListToolRatings<TData = Awaited<ReturnType<typeof listToolRat
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1217,21 +1234,21 @@ export const getCreateRatingUrl = (id: number,) => {
* @summary Submit a rating for a tool * @summary Submit a rating for a tool
*/ */
export const createRating = async (id: number, export const createRating = async (id: number,
ratingInput: RatingInput, options?: RequestInit): Promise<Rating> => { ratingInput: RatingInput, options?: Parameters<typeof customFetch>[1]): Promise<Rating> => {
return customFetch<Rating>(getCreateRatingUrl(id), return customFetch<Rating>(getCreateRatingUrl(id),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(ratingInput)
ratingInput,)
} }
);} );}
export const getCreateRatingMutationOptions = <TError = ErrorType<ErrorResponse>, export const getCreateRatingMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createRating>>, TError,{id: number;data: BodyType<RatingInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createRating>>, TError,{id: number;data: BodyType<RatingInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createRating>>, TError,{id: number;data: BodyType<RatingInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof createRating>>, TError,{id: number;data: BodyType<RatingInput>}, TContext> => {
@@ -1288,7 +1305,7 @@ export const getGetAnalyticsSummaryUrl = () => {
/** /**
* @summary Overall platform statistics * @summary Overall platform statistics
*/ */
export const getAnalyticsSummary = async ( options?: RequestInit): Promise<AnalyticsSummary> => { export const getAnalyticsSummary = async ( options?: Parameters<typeof customFetch>[1]): Promise<AnalyticsSummary> => {
return customFetch<AnalyticsSummary>(getGetAnalyticsSummaryUrl(), return customFetch<AnalyticsSummary>(getGetAnalyticsSummaryUrl(),
{ {
@@ -1345,7 +1362,7 @@ export function useGetAnalyticsSummary<TData = Awaited<ReturnType<typeof getAnal
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1360,7 +1377,7 @@ export const getGetTopToolsUrl = (params?: GetTopToolsParams,) => {
Object.entries(params || {}).forEach(([key, value]) => { Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString()) normalizedParams.append(key, value === null ? 'null' : String(value))
} }
}); });
@@ -1372,7 +1389,7 @@ export const getGetTopToolsUrl = (params?: GetTopToolsParams,) => {
/** /**
* @summary Top-rated tools * @summary Top-rated tools
*/ */
export const getTopTools = async (params?: GetTopToolsParams, options?: RequestInit): Promise<TopToolEntry[]> => { export const getTopTools = async (params?: GetTopToolsParams, options?: Parameters<typeof customFetch>[1]): Promise<TopToolEntry[]> => {
return customFetch<TopToolEntry[]>(getGetTopToolsUrl(params), return customFetch<TopToolEntry[]>(getGetTopToolsUrl(params),
{ {
@@ -1429,7 +1446,7 @@ export function useGetTopTools<TData = Awaited<ReturnType<typeof getTopTools>>,
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1449,7 +1466,7 @@ export const getGetAnalyticsByCategoryUrl = () => {
/** /**
* @summary Rating statistics grouped by category * @summary Rating statistics grouped by category
*/ */
export const getAnalyticsByCategory = async ( options?: RequestInit): Promise<CategoryStats[]> => { export const getAnalyticsByCategory = async ( options?: Parameters<typeof customFetch>[1]): Promise<CategoryStats[]> => {
return customFetch<CategoryStats[]>(getGetAnalyticsByCategoryUrl(), return customFetch<CategoryStats[]>(getGetAnalyticsByCategoryUrl(),
{ {
@@ -1506,7 +1523,7 @@ export function useGetAnalyticsByCategory<TData = Awaited<ReturnType<typeof getA
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1521,7 +1538,7 @@ export const getGetRatingDistributionUrl = (params?: GetRatingDistributionParams
Object.entries(params || {}).forEach(([key, value]) => { Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString()) normalizedParams.append(key, value === null ? 'null' : String(value))
} }
}); });
@@ -1533,7 +1550,7 @@ export const getGetRatingDistributionUrl = (params?: GetRatingDistributionParams
/** /**
* @summary Distribution of rating scores across the platform * @summary Distribution of rating scores across the platform
*/ */
export const getRatingDistribution = async (params?: GetRatingDistributionParams, options?: RequestInit): Promise<RatingDistribution> => { export const getRatingDistribution = async (params?: GetRatingDistributionParams, options?: Parameters<typeof customFetch>[1]): Promise<RatingDistribution> => {
return customFetch<RatingDistribution>(getGetRatingDistributionUrl(params), return customFetch<RatingDistribution>(getGetRatingDistributionUrl(params),
{ {
@@ -1590,7 +1607,7 @@ export function useGetRatingDistribution<TData = Awaited<ReturnType<typeof getRa
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1610,7 +1627,7 @@ export const getListCategoriesUrl = () => {
/** /**
* @summary List all distinct tool categories * @summary List all distinct tool categories
*/ */
export const listCategories = async ( options?: RequestInit): Promise<string[]> => { export const listCategories = async ( options?: Parameters<typeof customFetch>[1]): Promise<string[]> => {
return customFetch<string[]>(getListCategoriesUrl(), return customFetch<string[]>(getListCategoriesUrl(),
{ {
@@ -1667,7 +1684,7 @@ export function useListCategories<TData = Awaited<ReturnType<typeof listCategori
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1687,7 +1704,7 @@ export const getListAllFeaturesUrl = () => {
/** /**
* @summary List all distinct feature strings across all tools * @summary List all distinct feature strings across all tools
*/ */
export const listAllFeatures = async ( options?: RequestInit): Promise<string[]> => { export const listAllFeatures = async ( options?: Parameters<typeof customFetch>[1]): Promise<string[]> => {
return customFetch<string[]>(getListAllFeaturesUrl(), return customFetch<string[]>(getListAllFeaturesUrl(),
{ {
@@ -1744,7 +1761,7 @@ export function useListAllFeatures<TData = Awaited<ReturnType<typeof listAllFeat
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1764,7 +1781,7 @@ export const getListAllTagsUrl = () => {
/** /**
* @summary List all distinct tag strings across all tools * @summary List all distinct tag strings across all tools
*/ */
export const listAllTags = async ( options?: RequestInit): Promise<string[]> => { export const listAllTags = async ( options?: Parameters<typeof customFetch>[1]): Promise<string[]> => {
return customFetch<string[]>(getListAllTagsUrl(), return customFetch<string[]>(getListAllTagsUrl(),
{ {
@@ -1821,7 +1838,7 @@ export function useListAllTags<TData = Awaited<ReturnType<typeof listAllTags>>,
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1841,7 +1858,7 @@ export const getGetAuthModeUrl = () => {
/** /**
* @summary Get authentication mode (oidc or local) * @summary Get authentication mode (oidc or local)
*/ */
export const getAuthMode = async ( options?: RequestInit): Promise<AuthMode> => { export const getAuthMode = async ( options?: Parameters<typeof customFetch>[1]): Promise<AuthMode> => {
return customFetch<AuthMode>(getGetAuthModeUrl(), return customFetch<AuthMode>(getGetAuthModeUrl(),
{ {
@@ -1898,7 +1915,7 @@ export function useGetAuthMode<TData = Awaited<ReturnType<typeof getAuthMode>>,
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1918,7 +1935,7 @@ export const getGetCsrfTokenUrl = () => {
/** /**
* @summary Get a CSRF token for state-changing requests * @summary Get a CSRF token for state-changing requests
*/ */
export const getCsrfToken = async ( options?: RequestInit): Promise<CsrfToken> => { export const getCsrfToken = async ( options?: Parameters<typeof customFetch>[1]): Promise<CsrfToken> => {
return customFetch<CsrfToken>(getGetCsrfTokenUrl(), return customFetch<CsrfToken>(getGetCsrfTokenUrl(),
{ {
@@ -1975,7 +1992,7 @@ export function useGetCsrfToken<TData = Awaited<ReturnType<typeof getCsrfToken>>
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1995,21 +2012,21 @@ export const getLocalLoginUrl = () => {
/** /**
* @summary Local username/password login * @summary Local username/password login
*/ */
export const localLogin = async (localLoginInput: LocalLoginInput, options?: RequestInit): Promise<AuthUser> => { export const localLogin = async (localLoginInput: LocalLoginInput, options?: Parameters<typeof customFetch>[1]): Promise<AuthUser> => {
return customFetch<AuthUser>(getLocalLoginUrl(), return customFetch<AuthUser>(getLocalLoginUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(localLoginInput)
localLoginInput,)
} }
);} );}
export const getLocalLoginMutationOptions = <TError = ErrorType<ErrorResponse>, export const getLocalLoginMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext> => {
@@ -2066,7 +2083,7 @@ export const getGetMeUrl = () => {
/** /**
* @summary Get current authenticated user * @summary Get current authenticated user
*/ */
export const getMe = async ( options?: RequestInit): Promise<AuthUser> => { export const getMe = async ( options?: Parameters<typeof customFetch>[1]): Promise<AuthUser> => {
return customFetch<AuthUser>(getGetMeUrl(), return customFetch<AuthUser>(getGetMeUrl(),
{ {
@@ -2123,7 +2140,7 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -2143,21 +2160,21 @@ export const getChangeMyPasswordUrl = () => {
/** /**
* @summary Change own password (local users only) * @summary Change own password (local users only)
*/ */
export const changeMyPassword = async (changePasswordInput: ChangePasswordInput, options?: RequestInit): Promise<void> => { export const changeMyPassword = async (changePasswordInput: ChangePasswordInput, options?: Parameters<typeof customFetch>[1]): Promise<void> => {
return customFetch<void>(getChangeMyPasswordUrl(), return customFetch<void>(getChangeMyPasswordUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(changePasswordInput)
changePasswordInput,)
} }
);} );}
export const getChangeMyPasswordMutationOptions = <TError = ErrorType<ErrorResponse>, export const getChangeMyPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext> => {
@@ -2214,7 +2231,7 @@ export const getGetPasswordRedirectUrl = () => {
/** /**
* @summary Get redirect URL for managing credentials in the identity provider * @summary Get redirect URL for managing credentials in the identity provider
*/ */
export const getPasswordRedirect = async ( options?: RequestInit): Promise<PasswordRedirect> => { export const getPasswordRedirect = async ( options?: Parameters<typeof customFetch>[1]): Promise<PasswordRedirect> => {
return customFetch<PasswordRedirect>(getGetPasswordRedirectUrl(), return customFetch<PasswordRedirect>(getGetPasswordRedirectUrl(),
{ {
@@ -2271,7 +2288,7 @@ export function useGetPasswordRedirect<TData = Awaited<ReturnType<typeof getPass
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -2291,7 +2308,7 @@ export const getGetMePreferencesUrl = () => {
/** /**
* @summary Get current user's browse preferences * @summary Get current user's browse preferences
*/ */
export const getMePreferences = async ( options?: RequestInit): Promise<UserPreferences> => { export const getMePreferences = async ( options?: Parameters<typeof customFetch>[1]): Promise<UserPreferences> => {
return customFetch<UserPreferences>(getGetMePreferencesUrl(), return customFetch<UserPreferences>(getGetMePreferencesUrl(),
{ {
@@ -2348,7 +2365,7 @@ export function useGetMePreferences<TData = Awaited<ReturnType<typeof getMePrefe
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -2368,21 +2385,21 @@ export const getUpdateMePreferencesUrl = () => {
/** /**
* @summary Update current user's browse preferences * @summary Update current user's browse preferences
*/ */
export const updateMePreferences = async (userPreferences: UserPreferences, options?: RequestInit): Promise<UserPreferences> => { export const updateMePreferences = async (userPreferences: UserPreferences, options?: Parameters<typeof customFetch>[1]): Promise<UserPreferences> => {
return customFetch<UserPreferences>(getUpdateMePreferencesUrl(), return customFetch<UserPreferences>(getUpdateMePreferencesUrl(),
{ {
...options, ...options,
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(userPreferences)
userPreferences,)
} }
);} );}
export const getUpdateMePreferencesMutationOptions = <TError = ErrorType<ErrorResponse>, export const getUpdateMePreferencesMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext> => {
@@ -2439,7 +2456,7 @@ export const getGetMeWatchlistUrl = () => {
/** /**
* @summary Get current user's watchlist tools (premium) * @summary Get current user's watchlist tools (premium)
*/ */
export const getMeWatchlist = async ( options?: RequestInit): Promise<ToolWithStats[]> => { export const getMeWatchlist = async ( options?: Parameters<typeof customFetch>[1]): Promise<ToolWithStats[]> => {
return customFetch<ToolWithStats[]>(getGetMeWatchlistUrl(), return customFetch<ToolWithStats[]>(getGetMeWatchlistUrl(),
{ {
@@ -2496,7 +2513,7 @@ export function useGetMeWatchlist<TData = Awaited<ReturnType<typeof getMeWatchli
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -2516,7 +2533,7 @@ export const getListUsersUrl = () => {
/** /**
* @summary List all local users (admin only) * @summary List all local users (admin only)
*/ */
export const listUsers = async ( options?: RequestInit): Promise<User[]> => { export const listUsers = async ( options?: Parameters<typeof customFetch>[1]): Promise<User[]> => {
return customFetch<User[]>(getListUsersUrl(), return customFetch<User[]>(getListUsersUrl(),
{ {
@@ -2573,7 +2590,7 @@ export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TErr
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
@@ -2593,21 +2610,21 @@ export const getCreateUserUrl = () => {
/** /**
* @summary Create a new local user (admin only) * @summary Create a new local user (admin only)
*/ */
export const createUser = async (userCreateInput: UserCreateInput, options?: RequestInit): Promise<User> => { export const createUser = async (userCreateInput: UserCreateInput, options?: Parameters<typeof customFetch>[1]): Promise<User> => {
return customFetch<User>(getCreateUserUrl(), return customFetch<User>(getCreateUserUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(userCreateInput)
userCreateInput,)
} }
);} );}
export const getCreateUserMutationOptions = <TError = ErrorType<ErrorResponse>, export const getCreateUserMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext> => {
@@ -2665,21 +2682,21 @@ export const getUpdateUserUrl = (id: number,) => {
* @summary Update user role (admin only) * @summary Update user role (admin only)
*/ */
export const updateUser = async (id: number, export const updateUser = async (id: number,
userRoleUpdate: UserRoleUpdate, options?: RequestInit): Promise<User> => { userRoleUpdate: UserRoleUpdate, options?: Parameters<typeof customFetch>[1]): Promise<User> => {
return customFetch<User>(getUpdateUserUrl(id), return customFetch<User>(getUpdateUserUrl(id),
{ {
...options, ...options,
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(userRoleUpdate)
userRoleUpdate,)
} }
);} );}
export const getUpdateUserMutationOptions = <TError = ErrorType<ErrorResponse>, export const getUpdateUserMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext> => {
@@ -2736,7 +2753,7 @@ export const getDeleteUserUrl = (id: number,) => {
/** /**
* @summary Delete a user (admin only) * @summary Delete a user (admin only)
*/ */
export const deleteUser = async (id: number, options?: RequestInit): Promise<void> => { export const deleteUser = async (id: number, options?: Parameters<typeof customFetch>[1]): Promise<void> => {
return customFetch<void>(getDeleteUserUrl(id), return customFetch<void>(getDeleteUserUrl(id),
{ {
@@ -2750,6 +2767,7 @@ export const deleteUser = async (id: number, options?: RequestInit): Promise<voi
export const getDeleteUserMutationOptions = <TError = ErrorType<unknown>, export const getDeleteUserMutationOptions = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext> => {
@@ -2807,21 +2825,21 @@ export const getSetUserPasswordUrl = (id: number,) => {
* @summary Set/reset a user's password (admin only, local users only) * @summary Set/reset a user's password (admin only, local users only)
*/ */
export const setUserPassword = async (id: number, export const setUserPassword = async (id: number,
setPasswordInput: SetPasswordInput, options?: RequestInit): Promise<void> => { setPasswordInput: SetPasswordInput, options?: Parameters<typeof customFetch>[1]): Promise<void> => {
return customFetch<void>(getSetUserPasswordUrl(id), return customFetch<void>(getSetUserPasswordUrl(id),
{ {
...options, ...options,
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify( body: JSON.stringify(setPasswordInput)
setPasswordInput,)
} }
);} );}
export const getSetUserPasswordMutationOptions = <TError = ErrorType<ErrorResponse>, export const getSetUserPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext> => {
@@ -2873,7 +2891,7 @@ export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
Object.entries(params || {}).forEach(([key, value]) => { Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString()) normalizedParams.append(key, value === null ? 'null' : String(value))
} }
}); });
@@ -2885,7 +2903,7 @@ export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
/** /**
* @summary List audit log entries (admin only) * @summary List audit log entries (admin only)
*/ */
export const listAuditLogs = async (params?: ListAuditLogsParams, options?: RequestInit): Promise<AuditLog[]> => { export const listAuditLogs = async (params?: ListAuditLogsParams, options?: Parameters<typeof customFetch>[1]): Promise<AuditLog[]> => {
return customFetch<AuditLog[]>(getListAuditLogsUrl(params), return customFetch<AuditLog[]>(getListAuditLogsUrl(params),
{ {
@@ -2942,7 +2960,7 @@ export function useListAuditLogs<TData = Awaited<ReturnType<typeof listAuditLogs
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey }; const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey }; return withQueryKey(query, queryOptions.queryKey);
} }
+1 -1
View File
@@ -6,6 +6,6 @@
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs" "codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
}, },
"devDependencies": { "devDependencies": {
"orval": "^8.9.1" "orval": "8.23.0"
} }
} }
+121 -67
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -25,7 +25,7 @@ export const GetVersionResponse = zod.object({
"version": zod.string(), "version": zod.string(),
"commitSha": zod.string().nullish(), "commitSha": zod.string().nullish(),
"buildDate": zod.string().nullish(), "buildDate": zod.string().nullish(),
"trashRetentionDays": zod.number().optional() "trashRetentionDays": zod.int().optional()
}) })
@@ -47,7 +47,7 @@ export const ListToolsQueryParams = zod.object({
}) })
export const ListToolsResponseItem = zod.object({ export const ListToolsResponseItem = zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -58,7 +58,7 @@ export const ListToolsResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(), "updatedAt": zod.coerce.date(),
"ratingCount": zod.number(), "ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable() "avgCombined": zod.number().nullable()
@@ -84,16 +84,8 @@ export const CreateToolBody = zod.object({
"tags": zod.array(zod.string()).optional() "tags": zod.array(zod.string()).optional()
}) })
export const CreateToolResponse = zod.object({
/** "id": zod.int(),
* @summary Compare tools side by side (premium)
*/
export const ListCompareToolsQueryParams = zod.object({
"ids": zod.coerce.string().describe('Comma-separated tool ids')
})
export const ListCompareToolsResponseItem = zod.object({
"id": zod.number(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -104,7 +96,31 @@ export const ListCompareToolsResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(), "updatedAt": zod.coerce.date(),
"ratingCount": zod.number(), "deletedAt": zod.coerce.date().nullish(),
"deletedBy": zod.string().nullish()
})
/**
* @summary Compare tools side by side (premium)
*/
export const ListCompareToolsQueryParams = zod.object({
"ids": zod.coerce.string().describe('Comma-separated tool ids')
})
export const ListCompareToolsResponseItem = zod.object({
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
"websiteUrl": zod.string().nullish(),
"iconUrl": zod.string().nullish(),
"createdBy": zod.string().nullish(),
"features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(),
"ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable() "avgCombined": zod.number().nullable()
@@ -116,7 +132,7 @@ export const ListCompareToolsResponse = zod.array(ListCompareToolsResponseItem)
* @summary Get a tool's rating history over time * @summary Get a tool's rating history over time
*/ */
export const GetToolRatingHistoryParams = zod.object({ export const GetToolRatingHistoryParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const GetToolRatingHistoryResponseItem = zod.object({ export const GetToolRatingHistoryResponseItem = zod.object({
@@ -132,11 +148,11 @@ export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryRespon
* @summary Get a tool by ID * @summary Get a tool by ID
*/ */
export const GetToolParams = zod.object({ export const GetToolParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const GetToolResponse = zod.object({ export const GetToolResponse = zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -147,7 +163,7 @@ export const GetToolResponse = zod.object({
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(), "updatedAt": zod.coerce.date(),
"ratingCount": zod.number(), "ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable() "avgCombined": zod.number().nullable()
@@ -158,7 +174,7 @@ export const GetToolResponse = zod.object({
* @summary Update a tool * @summary Update a tool
*/ */
export const UpdateToolParams = zod.object({ export const UpdateToolParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
@@ -175,7 +191,7 @@ export const UpdateToolBody = zod.object({
}) })
export const UpdateToolResponse = zod.object({ export const UpdateToolResponse = zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -195,9 +211,11 @@ export const UpdateToolResponse = zod.object({
* @summary Delete a tool * @summary Delete a tool
*/ */
export const DeleteToolParams = zod.object({ export const DeleteToolParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const DeleteToolResponse = zod.void()
/** /**
* @summary List trashed (soft-deleted) tools * @summary List trashed (soft-deleted) tools
@@ -207,7 +225,7 @@ export const ListTrashedToolsQueryParams = zod.object({
}) })
export const ListTrashedToolsResponseItem = zod.object({ export const ListTrashedToolsResponseItem = zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -232,11 +250,11 @@ export const trashToolsBodyIdsMax = 500;
export const TrashToolsBody = zod.object({ export const TrashToolsBody = zod.object({
"ids": zod.array(zod.number()).min(1).max(trashToolsBodyIdsMax) "ids": zod.array(zod.int()).min(1).max(trashToolsBodyIdsMax)
}) })
export const TrashToolsResponse = zod.object({ export const TrashToolsResponse = zod.object({
"trashed": zod.number().optional() "trashed": zod.int().optional()
}) })
@@ -248,9 +266,11 @@ export const deleteTrashedToolsBodyIdsMax = 500;
export const DeleteTrashedToolsBody = zod.object({ export const DeleteTrashedToolsBody = zod.object({
"ids": zod.array(zod.number()).min(1).max(deleteTrashedToolsBodyIdsMax) "ids": zod.array(zod.int()).min(1).max(deleteTrashedToolsBodyIdsMax)
}) })
export const DeleteTrashedToolsResponse = zod.void()
/** /**
* @summary Restore trashed tools * @summary Restore trashed tools
@@ -260,11 +280,11 @@ export const restoreToolsBodyIdsMax = 500;
export const RestoreToolsBody = zod.object({ export const RestoreToolsBody = zod.object({
"ids": zod.array(zod.number()).min(1).max(restoreToolsBodyIdsMax) "ids": zod.array(zod.int()).min(1).max(restoreToolsBodyIdsMax)
}) })
export const RestoreToolsResponse = zod.object({ export const RestoreToolsResponse = zod.object({
"restored": zod.number().optional() "restored": zod.int().optional()
}) })
@@ -272,7 +292,7 @@ export const RestoreToolsResponse = zod.object({
* @summary Permanently delete all trashed tools (admin) * @summary Permanently delete all trashed tools (admin)
*/ */
export const EmptyTrashResponse = zod.object({ export const EmptyTrashResponse = zod.object({
"deleted": zod.number().optional() "deleted": zod.int().optional()
}) })
@@ -280,7 +300,7 @@ export const EmptyTrashResponse = zod.object({
* @summary List ratings for a tool * @summary List ratings for a tool
*/ */
export const ListToolRatingsParams = zod.object({ export const ListToolRatingsParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const listToolRatingsResponseUsefulnessMax = 5; export const listToolRatingsResponseUsefulnessMax = 5;
@@ -290,10 +310,10 @@ export const listToolRatingsResponseUsabilityMax = 5;
export const ListToolRatingsResponseItem = zod.object({ export const ListToolRatingsResponseItem = zod.object({
"id": zod.number(), "id": zod.int(),
"toolId": zod.number(), "toolId": zod.int(),
"usefulness": zod.number().min(1).max(listToolRatingsResponseUsefulnessMax), "usefulness": zod.int().min(1).max(listToolRatingsResponseUsefulnessMax),
"usability": zod.number().min(1).max(listToolRatingsResponseUsabilityMax), "usability": zod.int().min(1).max(listToolRatingsResponseUsabilityMax),
"comment": zod.string().nullish(), "comment": zod.string().nullish(),
"reviewerName": zod.string().nullish(), "reviewerName": zod.string().nullish(),
"createdAt": zod.coerce.date() "createdAt": zod.coerce.date()
@@ -305,7 +325,7 @@ export const ListToolRatingsResponse = zod.array(ListToolRatingsResponseItem)
* @summary Submit a rating for a tool * @summary Submit a rating for a tool
*/ */
export const CreateRatingParams = zod.object({ export const CreateRatingParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const createRatingBodyUsefulnessMax = 5; export const createRatingBodyUsefulnessMax = 5;
@@ -315,25 +335,41 @@ export const createRatingBodyUsabilityMax = 5;
export const CreateRatingBody = zod.object({ export const CreateRatingBody = zod.object({
"usefulness": zod.number().min(1).max(createRatingBodyUsefulnessMax), "usefulness": zod.int().min(1).max(createRatingBodyUsefulnessMax),
"usability": zod.number().min(1).max(createRatingBodyUsabilityMax), "usability": zod.int().min(1).max(createRatingBodyUsabilityMax),
"comment": zod.string().optional(), "comment": zod.string().optional(),
"reviewerName": zod.string().optional() "reviewerName": zod.string().optional()
}) })
export const createRatingResponseUsefulnessMax = 5;
export const createRatingResponseUsabilityMax = 5;
export const CreateRatingResponse = zod.object({
"id": zod.int(),
"toolId": zod.int(),
"usefulness": zod.int().min(1).max(createRatingResponseUsefulnessMax),
"usability": zod.int().min(1).max(createRatingResponseUsabilityMax),
"comment": zod.string().nullish(),
"reviewerName": zod.string().nullish(),
"createdAt": zod.coerce.date()
})
/** /**
* @summary Overall platform statistics * @summary Overall platform statistics
*/ */
export const GetAnalyticsSummaryResponse = zod.object({ export const GetAnalyticsSummaryResponse = zod.object({
"totalTools": zod.number(), "totalTools": zod.int(),
"totalRatings": zod.number(), "totalRatings": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable(), "avgCombined": zod.number().nullable(),
"categoriesCount": zod.number(), "categoriesCount": zod.int(),
"mostRatedTool": zod.object({ "mostRatedTool": zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -344,7 +380,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(), "updatedAt": zod.coerce.date(),
"ratingCount": zod.number(), "ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable() "avgCombined": zod.number().nullable()
@@ -356,13 +392,13 @@ export const GetAnalyticsSummaryResponse = zod.object({
* @summary Top-rated tools * @summary Top-rated tools
*/ */
export const GetTopToolsQueryParams = zod.object({ export const GetTopToolsQueryParams = zod.object({
"limit": zod.coerce.number().optional(), "limit": zod.coerce.number().int().optional(),
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional() "metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
}) })
export const GetTopToolsResponseItem = zod.object({ export const GetTopToolsResponseItem = zod.object({
"tool": zod.object({ "tool": zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -373,13 +409,13 @@ export const GetTopToolsResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(), "updatedAt": zod.coerce.date(),
"ratingCount": zod.number(), "ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable() "avgCombined": zod.number().nullable()
}), }),
"score": zod.number(), "score": zod.number(),
"ratingCount": zod.number() "ratingCount": zod.int()
}) })
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem) export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
@@ -389,8 +425,8 @@ export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
*/ */
export const GetAnalyticsByCategoryResponseItem = zod.object({ export const GetAnalyticsByCategoryResponseItem = zod.object({
"category": zod.string(), "category": zod.string(),
"toolCount": zod.number(), "toolCount": zod.int(),
"totalRatings": zod.number(), "totalRatings": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable() "avgUsability": zod.number().nullable()
}) })
@@ -401,17 +437,17 @@ export const GetAnalyticsByCategoryResponse = zod.array(GetAnalyticsByCategoryRe
* @summary Distribution of rating scores across the platform * @summary Distribution of rating scores across the platform
*/ */
export const GetRatingDistributionQueryParams = zod.object({ export const GetRatingDistributionQueryParams = zod.object({
"toolId": zod.coerce.number().optional() "toolId": zod.coerce.number().int().optional()
}) })
export const GetRatingDistributionResponse = zod.object({ export const GetRatingDistributionResponse = zod.object({
"usefulness": zod.array(zod.object({ "usefulness": zod.array(zod.object({
"score": zod.number(), "score": zod.int(),
"count": zod.number() "count": zod.int()
})), })),
"usability": zod.array(zod.object({ "usability": zod.array(zod.object({
"score": zod.number(), "score": zod.int(),
"count": zod.number() "count": zod.int()
})) }))
}) })
@@ -501,6 +537,8 @@ export const ChangeMyPasswordBody = zod.object({
"newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin) "newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin)
}) })
export const ChangeMyPasswordResponse = zod.void()
/** /**
* @summary Get redirect URL for managing credentials in the identity provider * @summary Get redirect URL for managing credentials in the identity provider
@@ -516,7 +554,7 @@ export const GetPasswordRedirectResponse = zod.object({
export const GetMePreferencesResponse = zod.object({ export const GetMePreferencesResponse = zod.object({
"view": zod.enum(['grid', 'table', 'rows']).optional(), "view": zod.enum(['grid', 'table', 'rows']).optional(),
"density": zod.enum(['cozy', 'compact']).optional(), "density": zod.enum(['cozy', 'compact']).optional(),
"watchlist": zod.array(zod.number()).optional() "watchlist": zod.array(zod.int()).optional()
}) })
@@ -526,13 +564,13 @@ export const GetMePreferencesResponse = zod.object({
export const UpdateMePreferencesBody = zod.object({ export const UpdateMePreferencesBody = zod.object({
"view": zod.enum(['grid', 'table', 'rows']).optional(), "view": zod.enum(['grid', 'table', 'rows']).optional(),
"density": zod.enum(['cozy', 'compact']).optional(), "density": zod.enum(['cozy', 'compact']).optional(),
"watchlist": zod.array(zod.number()).optional() "watchlist": zod.array(zod.int()).optional()
}) })
export const UpdateMePreferencesResponse = zod.object({ export const UpdateMePreferencesResponse = zod.object({
"view": zod.enum(['grid', 'table', 'rows']).optional(), "view": zod.enum(['grid', 'table', 'rows']).optional(),
"density": zod.enum(['cozy', 'compact']).optional(), "density": zod.enum(['cozy', 'compact']).optional(),
"watchlist": zod.array(zod.number()).optional() "watchlist": zod.array(zod.int()).optional()
}) })
@@ -540,7 +578,7 @@ export const UpdateMePreferencesResponse = zod.object({
* @summary Get current user's watchlist tools (premium) * @summary Get current user's watchlist tools (premium)
*/ */
export const GetMeWatchlistResponseItem = zod.object({ export const GetMeWatchlistResponseItem = zod.object({
"id": zod.number(), "id": zod.int(),
"name": zod.string(), "name": zod.string(),
"description": zod.string(), "description": zod.string(),
"category": zod.string(), "category": zod.string(),
@@ -551,7 +589,7 @@ export const GetMeWatchlistResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(), "createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(), "updatedAt": zod.coerce.date(),
"ratingCount": zod.number(), "ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(), "avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(), "avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable() "avgCombined": zod.number().nullable()
@@ -565,7 +603,7 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
export const listUsersResponseAuthProviderDefault = `local`; export const listUsersResponseAuthProviderDefault = `local`;
export const ListUsersResponseItem = zod.object({ export const ListUsersResponseItem = zod.object({
"id": zod.number(), "id": zod.int(),
"username": zod.string(), "username": zod.string(),
"email": zod.string().nullish(), "email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']), "role": zod.enum(['admin', 'user']),
@@ -593,12 +631,24 @@ export const CreateUserBody = zod.object({
"tier": zod.enum(['free', 'premium', 'enterprise']).optional() "tier": zod.enum(['free', 'premium', 'enterprise']).optional()
}) })
export const createUserResponseAuthProviderDefault = `local`;
export const CreateUserResponse = zod.object({
"id": zod.int(),
"username": zod.string(),
"email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"authProvider": zod.enum(['local', 'oidc']).default(createUserResponseAuthProviderDefault),
"createdAt": zod.coerce.date()
})
/** /**
* @summary Update user role (admin only) * @summary Update user role (admin only)
*/ */
export const UpdateUserParams = zod.object({ export const UpdateUserParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const UpdateUserBody = zod.object({ export const UpdateUserBody = zod.object({
@@ -609,7 +659,7 @@ export const UpdateUserBody = zod.object({
export const updateUserResponseAuthProviderDefault = `local`; export const updateUserResponseAuthProviderDefault = `local`;
export const UpdateUserResponse = zod.object({ export const UpdateUserResponse = zod.object({
"id": zod.number(), "id": zod.int(),
"username": zod.string(), "username": zod.string(),
"email": zod.string().nullish(), "email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']), "role": zod.enum(['admin', 'user']),
@@ -623,15 +673,17 @@ export const UpdateUserResponse = zod.object({
* @summary Delete a user (admin only) * @summary Delete a user (admin only)
*/ */
export const DeleteUserParams = zod.object({ export const DeleteUserParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const DeleteUserResponse = zod.void()
/** /**
* @summary Set/reset a user's password (admin only, local users only) * @summary Set/reset a user's password (admin only, local users only)
*/ */
export const SetUserPasswordParams = zod.object({ export const SetUserPasswordParams = zod.object({
"id": zod.coerce.number() "id": zod.coerce.number().int()
}) })
export const setUserPasswordBodyPasswordMin = 6; export const setUserPasswordBodyPasswordMin = 6;
@@ -642,20 +694,22 @@ export const SetUserPasswordBody = zod.object({
"password": zod.string().min(setUserPasswordBodyPasswordMin) "password": zod.string().min(setUserPasswordBodyPasswordMin)
}) })
export const SetUserPasswordResponse = zod.void()
/** /**
* @summary List audit log entries (admin only) * @summary List audit log entries (admin only)
*/ */
export const ListAuditLogsQueryParams = zod.object({ export const ListAuditLogsQueryParams = zod.object({
"entityType": zod.coerce.string().optional(), "entityType": zod.coerce.string().optional(),
"entityId": zod.coerce.number().optional(), "entityId": zod.coerce.number().int().optional(),
"limit": zod.coerce.number().optional() "limit": zod.coerce.number().int().optional()
}) })
export const ListAuditLogsResponseItem = zod.object({ export const ListAuditLogsResponseItem = zod.object({
"id": zod.number(), "id": zod.int(),
"entityType": zod.string(), "entityType": zod.string(),
"entityId": zod.number().nullish(), "entityId": zod.int().nullish(),
"action": zod.string(), "action": zod.string(),
"userId": zod.string(), "userId": zod.string(),
"username": zod.string(), "username": zod.string(),
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
@@ -1,5 +1,5 @@
/** /**
* Generated by orval v8.9.1 🍺 * Generated by orval v8.23.0 🍺
* Do not edit manually. * Do not edit manually.
* Api * Api
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
+4 -4
View File
@@ -13,13 +13,13 @@
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"drizzle-zod": "^0.8.3", "drizzle-zod": "0.8.3",
"pg": "^8.20.0", "pg": "8.22.0",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/pg": "^8.20.0", "@types/pg": "8.20.3",
"drizzle-kit": "^0.31.10" "drizzle-kit": "0.31.10"
} }
} }
+6 -17
View File
@@ -10,24 +10,13 @@
}, },
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"prettier": "^3.8.3", "prettier": "3.9.6",
"typescript": "~5.9.3" "typescript": "7.0.2"
},
"pnpm": {
"supportedArchitectures": {
"os": [
"current",
"linux"
],
"cpu": [
"current",
"x64"
]
}
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-darwin-arm64": "^4.62.4", "@esbuild/darwin-arm64": "0.28.1",
"@tailwindcss/oxide-darwin-arm64": "^4.3.3", "@rollup/rollup-darwin-arm64": "4.62.4",
"lightningcss-darwin-arm64": "^1.33.0" "@tailwindcss/oxide-darwin-arm64": "4.3.3",
"lightningcss-darwin-arm64": "1.33.0"
} }
} }
+2552 -1929
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More