Compare commits

...

13 Commits

Author SHA1 Message Date
opencode d23990e322 docs: link v0.8.2 release notes to commit 33cf261
Build & Push Docker Image / build (push) Successful in 2m39s
2026-08-04 07:01:28 +02:00
opencode 33cf261777 fix(docs): mobile nav drawer and version dropdown state
Build & Push Docker Image / build (push) Successful in 2m30s
- sidebar was hidden below lg with no replacement; add a Sheet-based
  nav drawer opened from a menu button in the header
- version dropdown stayed on 'aktuell' for /docs/releases/* pages because
  only first-segment versions were parsed; derive header version from the
  releases route too
2026-08-04 06:58:07 +02:00
opencode 56f1edd613 docs: link v0.8.1 release notes to commit fdf2d74
Build & Push Docker Image / build (push) Successful in 2m30s
2026-08-03 22:52:23 +02:00
opencode fdf2d741a1 feat(docs): standalone docs page, Hilfe nav entry, rewritten user guide
- docs route renders without the app shell (own header, back-to-app link,
  theme toggle)
- nav entry renamed to Hilfe (de) / Help (en)
- reference links now case-insensitive (schema/tag slugs)
- handbook rewritten as 17-page user guide with links into the API reference
- release notes v0.8.1 added
2026-08-03 22:52:14 +02:00
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
opencode bcae59626f feat(security): add CSRF protection for all state-changing API routes
Build & Push Docker Image / build (push) Successful in 2m16s
- Synchronizer token stored in session; GET /auth/csrf to obtain it
- csrfProtection middleware requires X-CSRF-Token on non-safe methods
- customFetch injects the header via setCsrfTokenGetter
- toolrate boot loads token; reload after local login (session regenerate)
- OpenAPI GET /auth/csrf + CsrfToken schema, orval regenerated
2026-08-03 10:30:13 +02:00
opencode f851305d78 fix(tools): OR'ed where clauses now AND'd so search excludes soft-deleted tools; localize delete confirm dialog
Build & Push Docker Image / build (push) Successful in 2m25s
2026-08-03 08:48:08 +02:00
123 changed files with 11713 additions and 2413 deletions
+15 -11
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"
} }
} }
@@ -0,0 +1,25 @@
import crypto from "node:crypto";
import type { Request, Response, NextFunction } from "express";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
export function getCsrfToken(req: Request): string {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(24).toString("hex");
}
return req.session.csrfToken;
}
export function csrfProtection(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method.toUpperCase())) {
next();
return;
}
const provided = req.headers["x-csrf-token"];
const token = getCsrfToken(req);
if (typeof provided === "string" && provided && provided === token) {
next();
return;
}
res.status(403).json({ error: "CSRF token missing or invalid" });
}
+38 -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";
@@ -8,10 +19,11 @@ import { logger } from "../lib/logger";
import { writeAuditLog } from "../lib/audit"; import { writeAuditLog } from "../lib/audit";
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit"; import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
import { getEntitlements, requireFeature } from "../middleware/feature"; import { getEntitlements, requireFeature } from "../middleware/feature";
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 !!(
@@ -38,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;
@@ -51,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;
@@ -109,6 +116,10 @@ router.get("/auth/mode", (_req, res): void => {
res.json({ mode: isOidcConfigured() ? "oidc" : "local" }); res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
}); });
router.get("/auth/csrf", (req, res): void => {
res.json({ token: getCsrfToken(req) });
});
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => { router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
if (isOidcConfigured()) { if (isOidcConfigured()) {
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." }); res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
@@ -171,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;
@@ -182,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",
@@ -190,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> => {
@@ -216,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) => {
@@ -254,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;
} }
@@ -287,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` });
}); });
+3
View File
@@ -8,9 +8,12 @@ import usersRouter from "./users";
import auditRouter from "./audit"; import auditRouter from "./audit";
import costsRouter from "./costs"; import costsRouter from "./costs";
import adminRouter from "./admin"; import adminRouter from "./admin";
import { csrfProtection } from "../middleware/csrf";
const router: IRouter = Router(); const router: IRouter = Router();
router.use(csrfProtection);
router.use(authRouter); router.use(authRouter);
router.use(healthRouter); router.use(healthRouter);
router.use(toolsRouter); router.use(toolsRouter);
+8 -6
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm"; import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db"; import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import { import {
@@ -57,21 +57,23 @@ router.get("/tools", async (req, res): Promise<void> => {
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean); const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean); const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic(); const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
if (category) { if (category) {
query = query.where(eq(toolsTable.category, category)); conditions.push(eq(toolsTable.category, category));
} }
if (search) { if (search) {
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`); const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`); conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
} }
if (tagList.length > 0) { if (tagList.length > 0) {
query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`); conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
} }
if (featureList.length > 0) { if (featureList.length > 0) {
query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`); conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
} }
const query = db.select().from(toolsTable).where(and(...conditions));
const tools = await query.orderBy(desc(toolsTable.createdAt)); const tools = await query.orderBy(desc(toolsTable.createdAt));
const toolIds = tools.map((t) => t.id); const toolIds = tools.map((t) => t.id);
+1
View File
@@ -14,5 +14,6 @@ declare module "express-session" {
codeVerifier?: string; codeVerifier?: string;
returnTo?: string; returnTo?: string;
oidcState?: string; oidcState?: string;
csrfToken?: string;
} }
} }
+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:"
} }
} }
+7
View File
@@ -1,7 +1,9 @@
import { Switch, Route, Router as WouterRouter } from "wouter"; import { Switch, Route, Router as WouterRouter } from "wouter";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { loadCsrfToken } from "@/lib/csrf";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -18,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({
@@ -44,12 +47,16 @@ 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>
); );
} }
function App() { function App() {
useEffect(() => {
void loadCsrfToken();
}, []);
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -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 }
+36 -1
View File
@@ -12,6 +12,7 @@
"trash": "Papierkorb", "trash": "Papierkorb",
"admin": "Admin", "admin": "Admin",
"redundancy": "Redundanz", "redundancy": "Redundanz",
"docs": "Hilfe",
"search": "Tools suchen…" "search": "Tools suchen…"
}, },
"auth": { "auth": {
@@ -127,7 +128,12 @@
"relatedTools": "Ähnliche Tools", "relatedTools": "Ähnliche Tools",
"costs": "Kosten", "costs": "Kosten",
"addCost": "Kosten hinzufügen", "addCost": "Kosten hinzufügen",
"recentRatings": "Letzte Bewertungen" "recentRatings": "Letzte Bewertungen",
"deleteConfirmTitle": "Dieses Tool löschen?",
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
"deleting": "Löschen…",
"deleteAction": "Löschen"
}, },
"compare": { "compare": {
"title": "Tools vergleichen", "title": "Tools vergleichen",
@@ -176,6 +182,35 @@
"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…",
"backToApp": "Zur App",
"field": "Feld",
"type": "Typ",
"required": "Pflicht",
"description": "Beschreibung",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "Keine Treffer",
"fields": "Felder",
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
},
"command": { "command": {
"navigate": "Navigation", "navigate": "Navigation",
"recent": "Zuletzt besucht", "recent": "Zuletzt besucht",
+36 -1
View File
@@ -12,6 +12,7 @@
"trash": "Trash", "trash": "Trash",
"admin": "Admin", "admin": "Admin",
"redundancy": "Redundancy", "redundancy": "Redundancy",
"docs": "Help",
"search": "Search tools…" "search": "Search tools…"
}, },
"auth": { "auth": {
@@ -127,7 +128,12 @@
"relatedTools": "Related Tools", "relatedTools": "Related Tools",
"costs": "Costs", "costs": "Costs",
"addCost": "Add Cost", "addCost": "Add Cost",
"recentRatings": "Recent Ratings" "recentRatings": "Recent Ratings",
"deleteConfirmTitle": "Delete this tool?",
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
"deleting": "Deleting…",
"deleteAction": "Delete"
}, },
"compare": { "compare": {
"title": "Compare Tools", "title": "Compare Tools",
@@ -176,6 +182,35 @@
"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…",
"backToApp": "Back to app",
"field": "Field",
"type": "Type",
"required": "Required",
"description": "Description",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "No results",
"fields": "Fields",
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
},
"command": { "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;
+27
View File
@@ -0,0 +1,27 @@
import { setCsrfTokenGetter } from "@workspace/api-client-react";
let token: string | null = null;
setCsrfTokenGetter(() => token);
export function getCsrfToken(): string | null {
return token;
}
export async function loadCsrfToken(): Promise<string | null> {
try {
const res = await fetch(`/api/auth/csrf`, {
credentials: "include",
});
if (!res.ok) {
token = null;
return null;
}
const data = (await res.json()) as { token?: string };
token = data.token ?? null;
return token;
} catch {
token = null;
return null;
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/password-input"; import { PasswordInput } from "@/components/password-input";
import { loadCsrfToken } from "@/lib/csrf";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Wrench, AlertCircle } from "lucide-react"; import { Wrench, AlertCircle } from "lucide-react";
@@ -33,6 +34,7 @@ export default function Login() {
{ {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(); queryClient.invalidateQueries();
void loadCsrfToken();
setLocation(returnTo); setLocation(returnTo);
}, },
onError: (err) => { onError: (err) => {
+22 -9
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>
@@ -914,23 +927,23 @@ export default function ToolDetail() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete this tool?</AlertDialogTitle> <AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{hasTrash ? ( {hasTrash ? (
<>This will move <span className="font-medium">{tool?.name}</span> to the trash. It can be restored later.</> <>{t("detail.deleteToTrash", { name: tool?.name })}</>
) : ( ) : (
<>This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.</> <>{t("detail.deletePermanent", { name: tool?.name })}</>
)} )}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteTool.isPending} disabled={deleteTool.isPending}
> >
{deleteTool.isPending ? "Deleting" : "Delete"} {deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+29 -7
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>
@@ -199,7 +206,10 @@ export default function ToolEdit() {
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>
@@ -213,7 +223,10 @@ export default function ToolEdit() {
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">
@@ -246,7 +259,10 @@ export default function ToolEdit() {
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: "" })}>
+29 -7
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}
@@ -166,7 +173,10 @@ export default function ToolNew() {
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.
+65
View File
@@ -0,0 +1,65 @@
---
title: Administration
order: 13
---
# Administration
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
## Tab „Nutzer"
Verwaltung der lokalen Konten.
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
(z. B. Keycloak) angeboten.
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
API-Referenz:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## Tab „Tools"
Zentraler Zugriff auf den Tool-Katalog.
- **Suchen** nach Tools.
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
## Tab „Audit-Log"
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
geänderte Felder.
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## Tab „System"
Versionsinformationen der laufenden Instanz:
- **Version** (z. B. `v0.8.1`),
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
- **Build-Datum**,
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
## Tool-Verknüpfungen (Admin)
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
(eigene/„manual" sowie automatisch erkannte) verwalten:
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
+33
View File
@@ -0,0 +1,33 @@
---
title: Analytics
order: 10
---
# Analytics
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
## Kennzahlen (KPI-Karten)
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
- **Aktive Kategorien** — wie viele Kategorien existieren.
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
## Diagramme
| Diagramm | Inhalt |
| --- | --- |
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (05) |
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
+43
View File
@@ -0,0 +1,43 @@
---
title: Bewerten
order: 7
---
# Bewerten
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
**Bewertung abgeben** (erfordert ein Konto).
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Nützlichkeit** | Ja | 15 Sterne |
| **Bedienbarkeit** | Ja | 15 Sterne |
| **Kommentar** | Nein | Freitext |
| **Name** | Nein | Standard „Anonym" |
Neben den Feldern führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
## Was passiert nach dem Abgeben?
- Deine Bewertung wird sofort gespeichert und erscheint in der
**Bewertungsliste** der Detailseite.
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
**Punkteverteilung** werden aktualisiert.
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
neu berechnet.
## Statistik-Bereiche auf der Detailseite
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
Fortschrittsbalken.
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
(erst ab mehreren Bewertungen sichtbar).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
+74
View File
@@ -0,0 +1,74 @@
---
title: Datenmodell
order: 17
---
# Datenmodell
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
Typen und Constraints findest du in der
[API-Referenz](/docs/reference/schemas/tool).
## Tool
Das Herzstück: ein im Katalog erfasstes Werkzeug.
| Eigenschaft | Beschreibung |
| --- | --- |
| `id` | Eindeutige Kennung |
| `name` | Anzeigename |
| `description` | Beschreibung (Was macht das Tool?) |
| `category` | Kategorie-Zuordnung |
| `websiteUrl` | Offizielle Website (optional) |
| `iconUrl` | Logo-/Icon-URL (optional) |
| `features` | Liste von Fähigkeiten |
| `tags` | Liste von Schlagwörtern |
| `createdAt` / `updatedAt` | Zeitstempel |
| `createdBy` | Erstellende Person |
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
Eingabe-Formulare verwenden die abgeleiteten Schemas
[`ToolInput`](/docs/reference/schemas/toolinput) und
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(z. B. mit Durchschnittsbewertung).
## Rating (Bewertung)
Eine einzelne Bewertung zu einem Tool:
- `usefulness` (Nützlichkeit, 15) und `usability` (Bedienbarkeit, 15)
- optional `comment` und ein Anzeigename (`reviewerName`)
- Zeitstempel
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
und Tarif (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
inklusive `entitlements` (verfügbare Features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
## Analytics
Die Statistik-Endpunkte liefern aggregierte Daten:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
Kategorie.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
## Weitere
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
+57
View File
@@ -0,0 +1,57 @@
---
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, Vergleichen) erfordern
ein Konto. Klicke unten links 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 (z. B.
Keycloak).
Welcher Modus aktiv ist, steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
## 2. Tools finden
Öffne den Bereich **Tools durchsuchen**:
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
(`minRating`).
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
(auf-/absteigend) oder letztem Update.
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
## 3. Tool anlegen
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
findest du im Abschnitt [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.
Siehe [Bewerten](/docs/handbook/bewerten).
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Pläne & Berechtigungen](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+56
View File
@@ -0,0 +1,56 @@
---
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 |
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
## Wie diese Doku aufgebaut ist
- **User Guide** (diese Seiten): Schritt-für-Schritt-Anleitungen für alle
Funktionen — von den [Ersten Schritten](/docs/handbook/getting-started) bis
zur [Administration](/docs/handbook/administration).
- **API-Referenz**: automatisch aus der OpenAPI-Spezifikation generiert — alle
[Endpunkte](/docs/reference/endpoints/tools) und
[Datenfelder](/docs/reference/schemas/toolinput) der aktuellen Version.
- **Release-Notes**: Was ist in welcher [Version](/docs/releases/v0.8.1) neu.
## Der Einstieg
Der schnellste Weg:
1. **Anmelden** — ohne Konto kannst du nur stöbern
(siehe [Erste Schritte](/docs/handbook/getting-started#1-anmelden)).
2. **Tools finden** — Suche, Filter und Sortierung im Bereich
[Tools durchsuchen](/docs/handbook/tools-finden).
3. **Tool anlegen** — über „Tool hinzufügen"
([Anleitung](/docs/handbook/tool-anlegen)).
4. **Bewerten** — auf der Detailseite eines Tools
([Anleitung](/docs/handbook/bewerten)).
## Kontakt & Quellcode
Der Quellcode liegt unter
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
über das Repository-Icon oben rechts erreichst du ihn jederzeit.
+57
View File
@@ -0,0 +1,57 @@
---
title: Anmelden & Konto
order: 3
---
# Anmelden & Konto
## Anmelden
Klicke unten links in der Seitenleiste auf **Anmelden**. Je nach Konfiguration
der Instanz:
- **Lokale Konten:** Benutzername und Passwort eingeben. Die Konten werden von
einem Admin angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Du wirst an den konfigurierten Identitätsanbieter
weitergeleitet und meldest dich dort an.
Der aktive Modus steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
> Die Login-Seite erreichst du direkt unter `/login`. Nach erfolgreicher
> Anmeldung wirst du zur ursprünglich aufgerufenen Seite zurückgeleitet.
## Benutzerprofil
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
- **Watchlist** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
die Passwortverwaltung im Identitätsanbieter angeboten.
- **Abmelden** — beendet deine Sitzung.
## Passwort ändern (lokales Konto)
1. Öffne das Benutzermenü unten links.
2. Wähle **Passwort ändern**.
3. Gib das **aktuelle** sowie ein **neues** Passwort ein (mind. 6 Zeichen) und
bestätige es.
4. Speichern — das Passwort wird sofort übernommen.
API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
## Anzeigeeinstellungen
Über die Schaltflächen oben rechts kannst du:
- **Sprache** wechseln (Deutsch / Englisch),
- **Theme** umschalten (Hell / Dunkel / System),
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
Deine Präferenzen (inkl. Watchlist) werden im Endpunkt
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
aktualisiert.
+39
View File
@@ -0,0 +1,39 @@
---
title: Kosten erfassen
order: 12
---
# Kosten erfassen
Auf der Detailseite eines Tools kannst du Kosten- und Lizenzmodelle eintragen,
damit die Gesamtkosten je Tool transparent werden.
> Kosten ist ein **Premium-Feature** (`costs`, Premium/Enterprise). Admins
> haben immer Zugriff.
## Kosten hinzufügen
Klicke auf **Kosten hinzufügen** im Kosten-Bereich der Detailseite und fülle
das Formular aus:
| Feld | Hinweise |
| --- | --- |
| **Lizenztyp** | Free / Subscription / One-Time / Usage-Based |
| **Abrechnungszeitraum** | Nur für „Subscription": Monatlich / Quartalsweise / Jährlich |
| **Kosten** | Betrag als Zahl |
| **Währung** | EUR / USD / GBP / CHF |
| **Notizen** | Optionaler Freitext |
Speichern legt den Eintrag an. Jeder Kosten-Eintrag wird als Karte mit
Lizenz-Badge, Abrechnungszeitraum, Betrag (`Betrag Währung` bzw. „Free") und
Notizen angezeigt.
## Kosten bearbeiten & löschen
Beim Überfahren einer Kosten-Karte erscheinen die Aktionen **Bearbeiten**
(Bleistift) und **Löschen** (Papierkorb).
## API
Die Kosten-Daten werden über die Tool-Endpunkte verwaltet
(siehe [API-Referenz](/docs/reference/endpoints/tools)).
+45
View File
@@ -0,0 +1,45 @@
---
title: Papierkorb
order: 15
---
# Papierkorb
Der **Papierkorb** (`/trash`) enthält soft gelöschte Tools. Mit Papierkorb-Zugang
können sie wiederhergestellt werden; endgültiges Löschen ist Admins vorbehalten.
> Der Papierkorb ist ein **Premium-Feature** (`trash`, Premium/Enterprise).
> Admins haben immer Zugriff.
## Zugang
Der Papierkorb ist über das Benutzermenü oder die Seitenleiste erreichbar.
Ohne `trash`-Berechtigung erscheint ein Hinweis auf den Tarifwechsel.
## Wiederherstellen
- Markiere ein oder mehrere Tools (Checkboxen).
- Klicke auf **Wiederherstellen (N)** — die Tools erscheinen wieder in allen
öffentlichen Ansichten.
> Wiederherstellen steht jeder Person mit Papierkorb-Zugang zur Verfügung.
## Endgültig löschen (nur Admin)
- **Löschen (N)** entfernt die ausgewählten Tools **endgültig** — inklusive
aller Bewertungen, Kosten und Verknüpfungen. Das kann nicht rückgängig
gemacht werden.
- **Papierkorb leeren** entfernt alle soft gelöschten Tools endgültig.
## Tabelle
Der Papierkorb listet: Name, Kategorie, **Gelöscht am** (`tt.MM.jjjj HH:mm`),
**Gelöscht von** sowie Aktionen (Wiederherstellen; Löschen nur Admin). Die Suche
filtert nach Namen.
## API
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — Liste
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — Wiederherstellen
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — Endgültig löschen (Admin)
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — Papierkorb leeren (Admin)
+45
View File
@@ -0,0 +1,45 @@
---
title: Pläne & Berechtigungen
order: 11
---
# Pläne & Berechtigungen
toolr unterscheidet **Tarife** (Tier) und **Rollen**. Admins umgehen alle
Feature-Beschränkungen.
## Tarife
| Tarif | Beschreibung |
| --- | --- |
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
| **Premium** | Zusätzlich Watchlist, Vergleichen, Papierkorb, Kosten |
| **Enterprise** | Alle Premium-Features + erweiterter Support |
### Feature-Berechtigungen
Premium/Enterprise schalten folgende Features frei:
| Feature | Funktion | Mehr erfahren |
| --- | --- | --- |
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
| `watchlist` | Favoritenliste | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
Tarifverwaltung.
## Rollen
| Rolle | Berechtigungen |
| --- | --- |
| **User** | Standard-Konto: Tools anlegen/bewerten, eigene Tools bearbeiten |
| **Admin** | Alle User-Rechte + Verwaltung, Audit-Log, Redundanz, Papierkorb leeren, Tool-Verknüpfungen |
Admins passieren **alle** Feature-Checks — auch ohne Premium-Tarif.
## Tarif-/Rollenverwaltung
Die Zuordnung von Rolle und Tarif wird durch Admins im Bereich
[Administration](/docs/handbook/administration) (Tab „Nutzer") verwaltet.
+42
View File
@@ -0,0 +1,42 @@
---
title: Redundanz-Dashboard
order: 14
---
# Redundanz-Dashboard
Das **Redundanz-Dashboard** (`/admin/redundancy`) ist ein Admin-Werkzeug zur
automatischen Erkennung doppelter oder stark überlappender Tools — jeweils
pro Kategorie — inklusive Kosten- und Bewertungsvergleich.
> Der Zugriff ist ausschließlich Admins vorbehalten (die API ist
> admin-geschützt).
## Aufbau
- **Pro Kategorie** wird eine Gruppe angezeigt: Name der Kategorie,
Anzahl Tools und Vergleiche sowie ggf. die **gesamten monatlichen Kosten**
(z. B. `€X.XX/mo gesamt`).
- Jedes Tool wird als Karte dargestellt: Name, monatliche Kosten, Anzahl der
Bewertungen, kombinierte Bewertung, Lizenz-Badges und Feature-Anzahl.
## Vergleiche & Empfehlungen
Für jedes Tool-Paar erscheint:
- Tool A vs. Tool B, jeweils mit Bewertung (`X.X ★`) und monatlichen Kosten.
- **Überlappung** in Prozent (Fortschrittsbalken in der Mitte).
- Eine **Empfehlung** mit Konfidenz-Farbe:
- **hoch** (grün), **mittel** (gelb), **niedrig** (grau)
- Das empfohlene, bessere Tool wird mit „Daumen hoch" markiert und begründet.
## Manuelle Bewertung
Du kannst ein Paar manuell bewerten: Klicke auf Tool A oder Tool B, um
festzuhalten, welches besser ist. Die Auswahl wird gespeichert und die
Darstellung aktualisiert.
## API
- [`GET /api/admin/redundancy`](#) — Daten laden (admin-geschützt)
- [`POST /api/admin/redundancy/evaluate`](#) — manuelle Bewertung speichern
+40
View File
@@ -0,0 +1,40 @@
---
title: Tastenkürzel & Kommandopalette
order: 16
---
# Tastenkürzel & Kommandopalette
## Kommandopalette
Die Kommandopalette ist die zentrale Schnellnavigation:
- Öffnen mit **`⌘K`** (macOS) bzw. **`Ctrl+K`** (Windows/Linux).
- Alternativ über die Suchleiste oben rechts („Tools suchen… ⌘K") oder das
Such-Icon auf Mobilgeräten.
### Leerer Zustand
Ohne Eingabe zeigt die Palette:
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
(abhängig von Berechtigungen) Watchlist, Papierkorb und Admin.
### Suche
Tippe, um live nach Tools zu suchen (max. 10 Ergebnisse, inkl. Bewertung
`X.X★`).
## Tastenkürzel im Überblick
| Kürzel | Aktion |
| --- | --- |
| `⌘K` / `Ctrl+K` | Kommandopalette öffnen |
| `/` | Suche im Bereich „Tools durchsuchen" fokussieren |
## Weitere Hinweise
- **Zuletzt angesehen** wird lokal im Browser gespeichert (max. 5 Einträge).
- Die Seitenleiste (linke Navigation) ist auf Desktop einklappbar; der
Breadcrumb oben zeigt deinen aktuellen Ort.
+50
View File
@@ -0,0 +1,50 @@
---
title: Tool anlegen
order: 5
---
# Tool anlegen
Um ein neues Tool zum Katalog hinzuzufügen, klicke auf **Tool hinzufügen**
(`/tools/new`). Das Anlegen erfordert ein Konto — ohne Anmeldung erscheint ein
Hinweis mit Login-Button.
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Name** | Ja | Mind. 2 Zeichen |
| **Kategorie** | Ja | Auswahlliste; neue Kategorien lassen sich direkt anlegen |
| **Website URL** | Nein | Gültige URL (z. B. `https://...`) |
| **Icon / Logo URL** | Nein | Gültige URL; Vorschau wird live angezeigt |
| **Beschreibung** | Ja | Mind. 10 Zeichen; beschreibe, was das Tool tut |
| **Features** | Nein | Dynamische Liste mit Autovervollständigung (max. 6) |
| **Tags** | Nein | Dynamische Liste mit Autovervollständigung |
Neben jedem Feld führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/toolinput).
### Kategorie
- Tippe, um nach bestehenden Kategorien zu suchen.
- Wähle **+ Erstelle „..."**, um eine neue Kategorie anzulegen.
### Features & Tags
- **Feature hinzufügen** / **Tag hinzufügen** hängt eine neue Zeile an.
- Die Eingabefelder schlagen bestehende Features/Tags vor
(Autovervollständigung, max. 6 Vorschläge).
- Mit dem **×**Button entfernst du einzelne Zeilen.
- Features und Tags helfen beim Filtern und Wiederfinden.
## Speichern
Klicke auf **Tool hinzufügen**. Nach erfolgreicher Anlage wirst du auf die
Detailseite des neuen Tools weitergeleitet.
## API
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — Tool anlegen
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — Kategorien
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — Features
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — Tags
+37
View File
@@ -0,0 +1,37 @@
---
title: Tool bearbeiten & löschen
order: 6
---
# Tool bearbeiten & löschen
## Bearbeiten
Auf der Detailseite eines Tools findest du die Schaltfläche **Bearbeiten**
(nur für die Person, die das Tool angelegt hat, sowie für Admins).
Die Bearbeitungsseite (`/tools/:id/edit`) enthält dieselben Felder wie beim
Anlegen (Name, Kategorie, Website/Icon-URL, Beschreibung, Features, Tags) —
bereits mit den aktuellen Werten befüllt.
- **Speichern** übernimmt die Änderungen.
- **Abbrechen** führt zurück zur Detailseite.
API-Referenz: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
## Löschen
Über **Löschen** auf der Detailseite wird das Tool entfernt. Das Verhalten
hängt von deinem Tarif ab:
- **Mit Papierkorb-Zugang** (Premium/Enterprise oder Admin): Das Tool wird
**soft gelöscht** — es verschwindet aus allen öffentlichen Ansichten, kann
aber im [Papierkorb](/docs/handbook/papierkorb) wiederhergestellt oder
endgültig gelöscht werden.
- **Ohne Papierkorb-Zugang:** Das Tool wird **endgültig** gelöscht und kann
nicht wiederhergestellt werden.
Die Löschung ist nur für die Person, die das Tool angelegt hat, sowie für
Admins möglich.
API-Referenz: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
+71
View File
@@ -0,0 +1,71 @@
---
title: Tools finden & durchsuchen
order: 4
---
# Tools finden & durchsuchen
Der Bereich **Tools durchsuchen** (`/tools`) ist der Einstieg in den Katalog.
Hier kombinierst du Suche, Filter und Sortierung, um genau die Tools zu finden,
die dich interessieren.
## Suche
- Die **Suchleiste** durchsucht Name und Beschreibung (Volltext).
- Tastenkürzel: Drücke **`/`**, um die Suche zu fokussieren.
- Die Eingabe ist deaktiviert (Debounce), damit bei jedem Tastendruck sofort
nachgefiltert wird.
## Filtern
Über die Schaltfläche **Filter** (mit Badge für die Anzahl aktiver Filter)
öffnest du den Filter-Popover mit:
- **Tags** — Auswahl über Checkboxen (scrollbare Liste).
- **Features** — Auswahl über Checkboxen.
- **Mindestbewertung** — Schieberegler von 0 bis 5 (Schritte von 0,5); zeigt
z. B. „3.0+" an.
Aktive Filter erscheinen als **entfernbare Chips** über der Ergebnisliste.
Mit **Filter zurücksetzen** bzw. **Alle entfernen** räumst du sie wieder auf.
## Sortieren
Über das Dropdown **Sortieren** stehen folgende Optionen zur Verfügung:
| Sortierung | Beschreibung |
| --- | --- |
| Neueste | Neue Tools zuerst |
| Top bewertet | Nach kombinierter Bewertung |
| Meistbewertet | Nach Anzahl der Bewertungen |
| Name (AZ) | Alphabetisch aufsteigend |
| Name (ZA) | Alphabetisch absteigend |
| Zuletzt aktualisiert | Nach letztem Update |
## Ansicht & Dichte
- **Ansicht wechseln:** Raster / Tabelle / Zeilen.
- **Dichte:** gemütlich / kompakt (Schieberegler).
Deine Auswahl wird gespeichert — lokal im Browser und für angemeldete Nutzer:innen
zusätzlich serverseitig in den Präferenzen. Ansicht, Dichte, Suche, Filter und
Sortierung werden dabei in die URL übernommen, sodass du Ergebnisse teilen
kannst.
## Tabellenansicht
In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
mit Bewertungsdetails, Tags und Mini-Balken.
## Auswählen für Vergleich & Watchlist
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
- Das **Lesezeichen-Icon** speichert Tools in deiner
[Watchlist](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
## API
Alle Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
+46
View File
@@ -0,0 +1,46 @@
---
title: Vergleichen
order: 9
---
# Vergleichen
Mit der Vergleichsfunktion stellst du mehrere Tools **nebeneinander** gegenüber —
ideal, um eine fundierte Entscheidung zu treffen.
> Vergleichen ist ein **Premium-Feature** (Premium/Enterprise) und steht Admins
> immer zur Verfügung.
## Tools auswählen
1. Im Bereich **Tools durchsuchen** klickst du auf jeder Karte/Zeile auf das
**Vergleichs-Icon** (Waage).
2. Unten erscheint die **Vergleichsleiste** mit den ausgewählten Tools als
Chips. Du kannst einzelne Tools entfernen (×) oder die Auswahl leeren.
3. Klicke auf **Vergleichen (N)**, um zur Vergleichsansicht zu gelangen.
> Ohne Premium-Tarif ist der Button gesperrt (Schloss-Icon). Über den
> Dialog gelangst du zum Tarifwechsel
> (siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
## Die Vergleichsansicht
Die Ansicht zeigt eine Tabelle mit einer Spalte pro Tool. Zeilen:
| Zeile | Inhalt |
| --- | --- |
| **Bewertung** | Sterne + Wert (z. B. `4.2/5`) |
| **Nützlichkeit** | Wert (X.X/5) |
| **Bedienbarkeit** | Wert (X.X/5) |
| **Anzahl Bewertungen** | Anzahl |
| **Beschreibung** | Text |
| **Features** | Badges |
| **Tags** | Badges |
| **Zuletzt aktualisiert** | Datum |
Der **beste Wert** pro Zeile wird hervorgehoben (mit Trophäen-Icon).
## API
Die Vergleichsansicht liest die Daten über
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
+38
View File
@@ -0,0 +1,38 @@
---
title: Watchlist
order: 8
---
# Watchlist
Die **Watchlist** ist eine persönliche Favoritenliste. Tools darin kannst du
jederzeit per Klick wieder aufrufen und vergleichen.
> Die Watchlist ist ein **Premium-Feature** (Premium/Enterprise) und steht
> Admins immer zur Verfügung.
## Voraussetzung
Du benötigst einen Tarif mit `watchlist`-Berechtigung. Fehlt diese, erscheint
beim Lesezeichen ein Hinweis auf den Tarifwechsel
(siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
## Tool speichern
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
**Lesezeichen-Icon**.
- Ein Klick speichert das Tool in deiner Watchlist — das Icon wird gefüllt.
- Ein erneuter Klick entfernt es wieder.
## Watchlist ansehen
Öffne die Watchlist über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
entfernt das Tool aus der Liste.
## Wo wird die Watchlist gespeichert?
Die Watchlist ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
+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
+47
View File
@@ -0,0 +1,47 @@
# v0.8.1 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.1)
## Neue Features
- **Standalone-Doku-Seite**: Die Dokumentation steht jetzt als eigene,
mkdocs-artige Seite unter `toolr.kubebase.de/docs` — ohne die App-Shell
(eigene Kopfzeile mit Repo-Link, Versions-Dropdown, Suche, Theme-Umschalter
und Link zurück zur App).
- **Navigation umbenannt**: Der Seitenleisten-Eintrag heißt jetzt **„Hilfe"**
und führt zur Standalone-Doku.
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
Schritt-für-Schritt-Anleitungen für alle Funktionen (Tools finden, Tool
anlegen, Bewerten, Watchlist, Vergleichen, Kosten, Analytics, Pläne,
Administration, Redundanz, Papierkorb, Tastenkürzel, Datenmodell).
## Fixes & Verbesserungen
- Referenz-Links sind jetzt unabhängig von Groß-/Kleinschreibung
(Schema-/Endpoint-Slugs wie `toolinput` und `ToolInput` funktionieren beide).
- Handbuch-Links auf Endpunkt-Anker korrigiert (PascalCase-OperationIds).
- Veraltete, kaputte Handbuch-Links (`vergleichen`, `watchlist` …) ersetzt.
- Dokumentations-Tabellenkopfzeilen und Hinweistexte in der Doku-Seite über
i18n internationalisiert (de/en).
## API-Änderungen
- Keine Änderungen an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine. Die Doku-Seite ist unter `/docs` erreichbar wie
bisher; lediglich die Darstellung ist nun eigenständig.
## 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: [`fdf2d74`](https://git.kubebase.de/admin/tool-evaluator/commit/fdf2d74)
- Tag: [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.1)
+36
View File
@@ -0,0 +1,36 @@
# v0.8.2 — Release Notes
**Datum:** 2026-08-04 · **Tag:** [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.2)
## Fixes & Verbesserungen
- **Mobile Navigation in der Doku**: Die linke Navigationsleiste (Handbuch,
Endpunkte, Schemas) wurde unterhalb von `lg` (1024px) komplett ausgeblendet.
Es gibt jetzt einen Menü-Button im Kopfbereich, der ein seitliches
Navigations-Drawer mit demselben Inhalt öffnet — auf kleinen Bildschirmen
bleibt die Doku voll navigierbar.
- **Versions-Dropdown zeigt die gewählte Version an**: Beim Wechsel zu einem
anderen Release (`/docs/releases/vX.Y.Z`) blieb das Dropdown fälschlich auf
„aktuell". Die angezeigte Version wird jetzt auch aus der releases-Route
abgeleitet.
## API-Änderungen
- Keine Änderungen 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: [`33cf261`](https://git.kubebase.de/admin/tool-evaluator/commit/33cf261)
- Tag: [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.2)
+18
View File
@@ -17,6 +17,7 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
let _baseUrl: string | null = null; let _baseUrl: string | null = null;
let _authTokenGetter: AuthTokenGetter | null = null; let _authTokenGetter: AuthTokenGetter | null = null;
let _csrfTokenGetter: (() => string | null) | null = null;
/** /**
* Set a base URL that is prepended to every relative request URL * Set a base URL that is prepended to every relative request URL
@@ -44,6 +45,15 @@ export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
_authTokenGetter = getter; _authTokenGetter = getter;
} }
/**
* Register a getter that supplies a CSRF token. Before every state-changing
* fetch an `X-CSRF-Token` header is attached when the getter returns a value.
* Pass `null` to clear the getter.
*/
export function setCsrfTokenGetter(getter: (() => string | null) | null): void {
_csrfTokenGetter = getter;
}
function isRequest(input: RequestInfo | URL): input is Request { function isRequest(input: RequestInfo | URL): input is Request {
return typeof Request !== "undefined" && input instanceof Request; return typeof Request !== "undefined" && input instanceof Request;
} }
@@ -349,6 +359,14 @@ export async function customFetch<T = unknown>(
headers.set("accept", DEFAULT_JSON_ACCEPT); headers.set("accept", DEFAULT_JSON_ACCEPT);
} }
// Attach CSRF token for state-changing requests, unless one is already set.
if (_csrfTokenGetter && !headers.has("x-csrf-token")) {
const csrf = _csrfTokenGetter();
if (csrf) {
headers.set("x-csrf-token", csrf);
}
}
// Attach bearer token when an auth getter is configured and no // Attach bearer token when an auth getter is configured and no
// Authorization header has been explicitly provided. // Authorization header has been explicitly provided.
if (_authTokenGetter && !headers.has("authorization")) { if (_authTokenGetter && !headers.has("authorization")) {
@@ -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
@@ -30,6 +30,10 @@ export interface AuthMode {
mode: AuthModeMode; mode: AuthModeMode;
} }
export interface CsrfToken {
token: string;
}
export interface LocalLoginInput { export interface LocalLoginInput {
username: string; username: string;
password: string; password: string;
+189 -93
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
@@ -26,6 +26,7 @@ import type {
AuthUser, AuthUser,
CategoryStats, CategoryStats,
ChangePasswordInput, ChangePasswordInput,
CsrfToken,
EmptyTrash200, EmptyTrash200,
ErrorResponse, ErrorResponse,
GetRatingDistributionParams, GetRatingDistributionParams,
@@ -69,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 = () => {
@@ -81,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(),
{ {
@@ -138,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);
} }
@@ -159,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(),
{ {
@@ -216,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);
} }
@@ -231,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))
} }
}); });
@@ -243,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),
{ {
@@ -300,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);
} }
@@ -320,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> => {
@@ -386,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))
} }
}); });
@@ -398,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),
{ {
@@ -455,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);
} }
@@ -475,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),
{ {
@@ -512,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>>>
@@ -532,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);
} }
@@ -552,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),
{ {
@@ -589,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>>>
@@ -609,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);
} }
@@ -630,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> => {
@@ -701,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),
{ {
@@ -715,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> => {
@@ -766,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))
} }
}); });
@@ -778,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),
{ {
@@ -835,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);
} }
@@ -855,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> => {
@@ -926,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> => {
@@ -997,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> => {
@@ -1068,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(),
{ {
@@ -1082,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> => {
@@ -1138,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),
{ {
@@ -1175,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>>>
@@ -1195,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);
} }
@@ -1216,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> => {
@@ -1287,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(),
{ {
@@ -1344,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);
} }
@@ -1359,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))
} }
}); });
@@ -1371,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),
{ {
@@ -1428,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);
} }
@@ -1448,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(),
{ {
@@ -1505,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);
} }
@@ -1520,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))
} }
}); });
@@ -1532,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),
{ {
@@ -1589,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);
} }
@@ -1609,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(),
{ {
@@ -1666,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);
} }
@@ -1686,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(),
{ {
@@ -1743,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);
} }
@@ -1763,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(),
{ {
@@ -1820,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);
} }
@@ -1840,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(),
{ {
@@ -1897,7 +1915,84 @@ 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);
}
export const getGetCsrfTokenUrl = () => {
return `/api/auth/csrf`
}
/**
* @summary Get a CSRF token for state-changing requests
*/
export const getCsrfToken = async ( options?: Parameters<typeof customFetch>[1]): Promise<CsrfToken> => {
return customFetch<CsrfToken>(getGetCsrfTokenUrl(),
{
...options,
method: 'GET'
}
);}
export const getGetCsrfTokenQueryKey = () => {
return [
`/api/auth/csrf`
] as const;
}
export const getGetCsrfTokenQueryOptions = <TData = Awaited<ReturnType<typeof getCsrfToken>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetCsrfTokenQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getCsrfToken>>> = ({ signal }) => getCsrfToken({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData> & { queryKey: QueryKey }
}
export type GetCsrfTokenQueryResult = NonNullable<Awaited<ReturnType<typeof getCsrfToken>>>
export type GetCsrfTokenQueryError = ErrorType<unknown>
/**
* @summary Get a CSRF token for state-changing requests
*/
export function useGetCsrfToken<TData = Awaited<ReturnType<typeof getCsrfToken>>, TError = ErrorType<unknown>>(
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetCsrfTokenQueryOptions(options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return withQueryKey(query, queryOptions.queryKey);
} }
@@ -1917,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> => {
@@ -1988,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(),
{ {
@@ -2045,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);
} }
@@ -2065,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> => {
@@ -2136,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(),
{ {
@@ -2193,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);
} }
@@ -2213,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(),
{ {
@@ -2270,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);
} }
@@ -2290,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> => {
@@ -2361,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(),
{ {
@@ -2418,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);
} }
@@ -2438,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(),
{ {
@@ -2495,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);
} }
@@ -2515,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> => {
@@ -2587,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> => {
@@ -2658,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),
{ {
@@ -2672,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> => {
@@ -2729,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> => {
@@ -2795,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))
} }
}); });
@@ -2807,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),
{ {
@@ -2864,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
@@ -1,4 +1,4 @@
export * from "./generated/api"; export * from "./generated/api";
export * from "./generated/api.schemas"; export * from "./generated/api.schemas";
export { setBaseUrl, setAuthTokenGetter, customFetch } from "./custom-fetch"; export { setBaseUrl, setAuthTokenGetter, setCsrfTokenGetter, customFetch } from "./custom-fetch";
export type { AuthTokenGetter } from "./custom-fetch"; export type { AuthTokenGetter } from "./custom-fetch";
+20
View File
@@ -570,6 +570,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/AuthMode" $ref: "#/components/schemas/AuthMode"
/auth/csrf:
get:
operationId: getCsrfToken
tags: [auth]
summary: Get a CSRF token for state-changing requests
responses:
"200":
description: CSRF token
content:
application/json:
schema:
$ref: "#/components/schemas/CsrfToken"
/auth/login: /auth/login:
post: post:
operationId: localLogin operationId: localLogin
@@ -942,6 +955,13 @@ components:
type: string type: string
enum: [oidc, local] enum: [oidc, local]
CsrfToken:
type: object
required: [token]
properties:
token:
type: string
LocalLoginInput: LocalLoginInput:
type: object type: object
required: [username, password] required: [username, password]
+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"
} }
} }
+129 -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()
})) }))
}) })
@@ -445,6 +481,14 @@ export const GetAuthModeResponse = zod.object({
}) })
/**
* @summary Get a CSRF token for state-changing requests
*/
export const GetCsrfTokenResponse = zod.object({
"token": zod.string()
})
/** /**
* @summary Local username/password login * @summary Local username/password login
*/ */
@@ -493,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
@@ -508,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()
}) })
@@ -518,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()
}) })
@@ -532,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(),
@@ -543,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()
@@ -557,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']),
@@ -585,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({
@@ -601,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']),
@@ -615,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;
@@ -634,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
@@ -0,0 +1,11 @@
/**
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export interface CsrfToken {
token: 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,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
+2 -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
@@ -15,6 +15,7 @@ export * from './authUserRole';
export * from './authUserTier'; export * from './authUserTier';
export * from './categoryStats'; export * from './categoryStats';
export * from './changePasswordInput'; export * from './changePasswordInput';
export * from './csrfToken';
export * from './emptyTrash200'; export * from './emptyTrash200';
export * from './errorResponse'; export * from './errorResponse';
export * from './getRatingDistributionParams'; export * from './getRatingDistributionParams';
@@ -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

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