Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ceb11e7f9 | |||
| 6c92b6358d | |||
| 520f917723 | |||
| d1dd77bc1e | |||
| 0be45b6513 | |||
| 4b1274e34a | |||
| 2f66fff993 |
+16
-12
@@ -14,6 +14,14 @@ jobs:
|
||||
steps:
|
||||
- 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
|
||||
run: |
|
||||
apt-get update -qq
|
||||
@@ -30,33 +38,29 @@ jobs:
|
||||
FULL_SHA=$(git rev-parse HEAD)
|
||||
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
||||
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
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
VERSION_TAG="${{ gitea.ref_name }}"
|
||||
else
|
||||
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
||||
VERSION_TAG="nightly-${DATE_STAMP}"
|
||||
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
|
||||
fi
|
||||
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
|
||||
docker build --no-cache \
|
||||
--build-arg COMMIT_SHA="$FULL_SHA" \
|
||||
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
$TAGS .
|
||||
docker push "${IMAGE}:sha-${SHA}"
|
||||
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
|
||||
if: gitea.ref_type == 'tag'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||
NEWTAG="${{ gitea.ref_name }}"
|
||||
else
|
||||
NEWTAG="sha-${SHA}"
|
||||
fi
|
||||
NEWTAG="${{ gitea.ref_name }}"
|
||||
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||
cd /tmp/apps
|
||||
cd apps/system/toolrate/overlays/k3s
|
||||
|
||||
@@ -47,3 +47,6 @@ Thumbs.db
|
||||
# Replit
|
||||
.cache/
|
||||
.local/
|
||||
|
||||
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
|
||||
/artifacts/toolrate/public/docs/
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
auto-install-peers=false
|
||||
strict-peer-dependencies=false
|
||||
save-exact=true
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
FROM node:24-alpine AS builder
|
||||
FROM node:24.18.1-alpine AS builder
|
||||
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
|
||||
COPY . .
|
||||
@@ -17,10 +17,10 @@ ENV PORT=8080
|
||||
|
||||
RUN pnpm -r --if-present run build
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
FROM node:24.18.1-alpine AS runner
|
||||
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 lib/db/package.json lib/db/
|
||||
|
||||
@@ -12,30 +12,29 @@
|
||||
"dependencies": {
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"bcryptjs": "3.0.3",
|
||||
"connect-pg-simple": "10.0.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.6.1",
|
||||
"express-session": "^1.19.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"pino": "^9.14.0",
|
||||
"pino-http": "^10.5.0",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.6.1",
|
||||
"express-session": "1.19.0",
|
||||
"openid-client": "6.8.4",
|
||||
"pino": "10.3.1",
|
||||
"pino-http": "11.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/connect-pg-simple": "7.0.3",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/express-session": "1.19.0",
|
||||
"@types/node": "catalog:",
|
||||
"esbuild": "0.27.3",
|
||||
"esbuild-plugin-pino": "^2.3.3",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"thread-stream": "3.1.0"
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-pino": "2.3.3",
|
||||
"pino-pretty": "13.1.3",
|
||||
"thread-stream": "4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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 { eq, and, inArray, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
@@ -12,7 +23,7 @@ import { getCsrfToken } from "../middleware/csrf";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
let cachedClient: Client | null = null;
|
||||
let cachedConfig: Configuration | null = null;
|
||||
|
||||
function isOidcConfigured(): boolean {
|
||||
return !!(
|
||||
@@ -39,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function getClient(): Promise<Client | null> {
|
||||
if (cachedClient) return cachedClient;
|
||||
async function getClient(): Promise<Configuration | null> {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
const keycloakUrl = process.env.KEYCLOAK_URL;
|
||||
const realm = process.env.KEYCLOAK_REALM;
|
||||
@@ -52,14 +63,9 @@ async function getClient(): Promise<Client | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
cachedClient = new issuer.Client({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
response_types: ["code"],
|
||||
});
|
||||
return cachedClient;
|
||||
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
|
||||
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
|
||||
return cachedConfig;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to discover Keycloak issuer");
|
||||
return null;
|
||||
@@ -176,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const codeVerifier = generators.codeVerifier();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
const state = generators.state();
|
||||
const codeVerifier = randomPKCECodeVerifier();
|
||||
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = randomState();
|
||||
|
||||
req.session.codeVerifier = codeVerifier;
|
||||
req.session.oidcState = state;
|
||||
@@ -187,7 +193,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
}
|
||||
|
||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||
const url = client.authorizationUrl({
|
||||
const url = buildAuthorizationUrl(client, {
|
||||
scope: "openid email profile",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
@@ -195,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
state,
|
||||
});
|
||||
|
||||
res.redirect(url);
|
||||
res.redirect(url.href);
|
||||
});
|
||||
|
||||
router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||
@@ -221,13 +227,13 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||
|
||||
try {
|
||||
const params = client.callbackParams(req);
|
||||
const tokenSet = await client.callback(redirectUri, params, {
|
||||
code_verifier: codeVerifier,
|
||||
state,
|
||||
});
|
||||
const tokens = await authorizationCodeGrant(
|
||||
client,
|
||||
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
|
||||
{ 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);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -259,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
|
||||
req.session.destroy(() => {});
|
||||
|
||||
const client = await getClient();
|
||||
if (client && client.issuer.metadata.end_session_endpoint) {
|
||||
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
|
||||
res.redirect(logoutUrl);
|
||||
if (client && client.serverMetadata().end_session_endpoint) {
|
||||
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
|
||||
res.redirect(logoutUrl.href);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,7 +298,7 @@ router.get("/auth/password-redirect", async (req, res): Promise<void> => {
|
||||
res.json({ url: null });
|
||||
return;
|
||||
}
|
||||
const realm = client.issuer.metadata.issuer ?? "";
|
||||
const realm = client.serverMetadata().issuer ?? "";
|
||||
res.json({ url: `${realm}/account/password` });
|
||||
});
|
||||
|
||||
|
||||
@@ -10,34 +10,34 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@hookform/resolvers": "5.7.1",
|
||||
"@radix-ui/react-accordion": "1.2.20",
|
||||
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-context-menu": "2.3.7",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-hover-card": "1.1.23",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-menubar": "1.1.24",
|
||||
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slider": "1.4.7",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-toggle": "1.1.18",
|
||||
"@radix-ui/react-toggle-group": "1.1.19",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@replit/vite-plugin-cartographer": "catalog:",
|
||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -45,29 +45,30 @@
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar": "5.0.0",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"framer-motion": "catalog:",
|
||||
"input-otp": "^1.4.2",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "^0.4.6",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"react-hook-form": "7.84.0",
|
||||
"react-is": "19.2.8",
|
||||
"react-resizable-panels": "4.12.2",
|
||||
"recharts": "3.10.1",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"vite": "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",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"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"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
RechartsPrimitive.TooltipContentProps &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||
className={cn(
|
||||
"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"
|
||||
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
|
||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||
<ResizablePrimitive.Group
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
<ResizablePrimitive.Separator
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
|
||||
@@ -4,44 +4,44 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --config vite.config.ts --host 0.0.0.0",
|
||||
"build": "vite build --config vite.config.ts",
|
||||
"dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
|
||||
"build": "node ../../scripts/src/generate-docs.mjs && vite build --config vite.config.ts",
|
||||
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.4",
|
||||
"@radix-ui/react-context-menu": "^2.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-hover-card": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-menubar": "^1.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.3",
|
||||
"@radix-ui/react-radio-group": "^1.2.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slider": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-toast": "^1.2.7",
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@hookform/resolvers": "5.7.1",
|
||||
"@radix-ui/react-accordion": "1.2.20",
|
||||
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-context-menu": "2.3.7",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-hover-card": "1.1.23",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-menubar": "1.1.24",
|
||||
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slider": "1.4.7",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-toggle": "1.1.18",
|
||||
"@radix-ui/react-toggle-group": "1.1.19",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@replit/vite-plugin-cartographer": "catalog:",
|
||||
"@replit/vite-plugin-dev-banner": "catalog:",
|
||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
@@ -52,29 +52,32 @@
|
||||
"@workspace/api-client-react": "workspace:*",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
"dompurify": "catalog:",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"framer-motion": "catalog:",
|
||||
"i18next": "^26.3.6",
|
||||
"input-otp": "^1.4.2",
|
||||
"i18next": "26.3.6",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "^0.4.6",
|
||||
"marked": "catalog:",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.7",
|
||||
"react-hook-form": "7.84.0",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-icons": "5.7.0",
|
||||
"react-is": "19.2.8",
|
||||
"react-resizable-panels": "4.12.2",
|
||||
"recharts": "3.10.1",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"vite": "catalog:",
|
||||
"wouter": "^3.3.5",
|
||||
"wouter": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import Trash from "@/pages/trash";
|
||||
import Compare from "@/pages/compare";
|
||||
import Watchlist from "@/pages/watchlist";
|
||||
import Login from "@/pages/login";
|
||||
import Docs from "@/pages/docs";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -46,6 +47,7 @@ function Router() {
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/admin/redundancy" component={Redundancy} />
|
||||
<Route path="/trash" component={Trash} />
|
||||
<Route path="/docs/*?" component={Docs} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,31 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
|
||||
crumbs.push({ label: t("compare.title") });
|
||||
} else if (location.startsWith("/analytics")) {
|
||||
crumbs.push({ label: t("nav.analytics") });
|
||||
} else if (location.startsWith("/docs")) {
|
||||
crumbs.push({ href: "/docs", label: t("docs.title") });
|
||||
const m = location.replace(/^\/docs\/?/, "");
|
||||
if (m.startsWith("handbook/")) {
|
||||
crumbs.push({ href: "/docs/handbook", label: t("docs.guides") });
|
||||
const slug = m.replace(/^handbook\//, "").split("#")[0];
|
||||
if (slug) crumbs.push({ label: slug });
|
||||
} else if (m.startsWith("reference/")) {
|
||||
crumbs.push({ href: "/docs/reference/endpoints", label: t("docs.reference") });
|
||||
const rest = m.replace(/^reference\//, "").split("#")[0];
|
||||
if (rest.startsWith("schemas/")) {
|
||||
crumbs.push({ label: t("docs.schemas") });
|
||||
const name = rest.replace(/^schemas\//, "");
|
||||
if (name) crumbs.push({ label: name });
|
||||
} else {
|
||||
const tag = rest.replace(/^endpoints\//, "");
|
||||
if (tag) crumbs.push({ label: tag });
|
||||
}
|
||||
} else if (m.startsWith("releases/")) {
|
||||
crumbs.push({ href: "/docs/releases", label: t("docs.releases") });
|
||||
const version = m.replace(/^releases\//, "").split("#")[0];
|
||||
if (version) crumbs.push({ label: version });
|
||||
} else if (m) {
|
||||
crumbs.push({ label: m });
|
||||
}
|
||||
} else if (location.startsWith("/login")) {
|
||||
crumbs.push({ label: t("auth.signIn") });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function FieldHelp({
|
||||
schema,
|
||||
field,
|
||||
children,
|
||||
}: {
|
||||
schema: string;
|
||||
field: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const label = children ?? field;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/docs/reference/schemas/${schema}#${field}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Help: ${label}`}
|
||||
data-testid={`help-${schema}-${field}`}
|
||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label} — Details in der Dokumentation</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from "@/hooks/use-auth";
|
||||
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: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||
{ href: "/docs", label: t("nav.docs"), icon: FileText },
|
||||
];
|
||||
|
||||
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",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"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<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
RechartsPrimitive.TooltipContentProps &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||
className={cn(
|
||||
"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"
|
||||
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||
<ResizablePrimitive.Group
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
<ResizablePrimitive.Separator
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"trash": "Papierkorb",
|
||||
"admin": "Admin",
|
||||
"redundancy": "Redundanz",
|
||||
"docs": "Doku",
|
||||
"search": "Tools suchen…"
|
||||
},
|
||||
"auth": {
|
||||
@@ -181,6 +182,24 @@
|
||||
"text": "Diese Seite existiert nicht.",
|
||||
"backHome": "Zurück zur Startseite"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Dokumentation",
|
||||
"subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.",
|
||||
"backToIndex": "Alle Releases",
|
||||
"noDocs": "Keine Dokumentation für diesen Pfad verfügbar.",
|
||||
"version": "Version",
|
||||
"latest": "Aktuell",
|
||||
"repo": "Repository",
|
||||
"nav": "Dokumentation",
|
||||
"guides": "Handbuch",
|
||||
"endpoints": "Endpunkte",
|
||||
"schemas": "Datenmodelle",
|
||||
"releases": "Release-Notes",
|
||||
"reference": "API-Referenz",
|
||||
"referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.",
|
||||
"onThisPage": "Auf dieser Seite",
|
||||
"searchPlaceholder": "Doku durchsuchen…"
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigation",
|
||||
"recent": "Zuletzt besucht",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"trash": "Trash",
|
||||
"admin": "Admin",
|
||||
"redundancy": "Redundancy",
|
||||
"docs": "Docs",
|
||||
"search": "Search tools…"
|
||||
},
|
||||
"auth": {
|
||||
@@ -181,6 +182,24 @@
|
||||
"text": "This page doesn't exist.",
|
||||
"backHome": "Back to Home"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Documentation",
|
||||
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
|
||||
"backToIndex": "All releases",
|
||||
"noDocs": "No documentation available for this path.",
|
||||
"version": "Version",
|
||||
"latest": "Latest",
|
||||
"repo": "Repository",
|
||||
"nav": "Documentation",
|
||||
"guides": "Guide",
|
||||
"endpoints": "Endpoints",
|
||||
"schemas": "Data models",
|
||||
"releases": "Release notes",
|
||||
"reference": "API reference",
|
||||
"referenceIntro": "Generated automatically from the OpenAPI spec — all endpoints and data fields of the current version.",
|
||||
"onThisPage": "On this page",
|
||||
"searchPlaceholder": "Search docs…"
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigate",
|
||||
"recent": "Recent",
|
||||
|
||||
@@ -265,6 +265,25 @@
|
||||
*/
|
||||
@layer utilities {
|
||||
|
||||
/* Documentation heading anchors (mkdocs style "¶" links) */
|
||||
.docs-prose :is(h1, h2, h3, h4) {
|
||||
scroll-margin-top: 6rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docs-prose .docs-anchor::after {
|
||||
content: "¶";
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.8em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.docs-prose :is(h1, h2, h3, h4):hover .docs-anchor::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Hide ugly search cancel button in Chrome until we can style it properly */
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
@apply hidden;
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||
import {
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Library,
|
||||
Search,
|
||||
Server,
|
||||
Tag,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
|
||||
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FieldType = { kind: "ref" | "type" | "array"; value: string };
|
||||
|
||||
type Parameter = {
|
||||
name: string;
|
||||
in: string;
|
||||
required: boolean;
|
||||
type: FieldType;
|
||||
description: string;
|
||||
constraints: string;
|
||||
};
|
||||
|
||||
type Endpoint = {
|
||||
operationId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
parameters: Parameter[];
|
||||
requestBody: { required: boolean; schema: FieldType } | null;
|
||||
responses: { status: string; description: string; schema: FieldType }[];
|
||||
};
|
||||
|
||||
type TagGroup = { name: string; description: string; endpoints: Endpoint[] };
|
||||
|
||||
type Field = {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
description: string;
|
||||
constraints: string;
|
||||
};
|
||||
|
||||
type SchemaModel = { name: string; description: string; fields: Field[] };
|
||||
|
||||
type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
|
||||
|
||||
type ReleaseDoc = {
|
||||
version: string;
|
||||
file: string;
|
||||
title: string;
|
||||
date: string | null;
|
||||
hasReference: boolean;
|
||||
};
|
||||
|
||||
type HandbookPage = { slug: string; file: string; title: string; order: number };
|
||||
|
||||
type SearchEntry = { title: string; href: string; kind: string; text: string };
|
||||
|
||||
type Heading = { id: string; text: string; level: number };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fetchJson<T>(url: string): Promise<T> {
|
||||
return fetch(url).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
});
|
||||
}
|
||||
|
||||
function useJson<T>(url: string | null) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setData(null);
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setData(null);
|
||||
setError(false);
|
||||
fetchJson<T>(url)
|
||||
.then((d) => {
|
||||
if (!cancelled) setData(d);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
|
||||
function useMarkdown(file: string | null) {
|
||||
const [state, setState] = useState<{ html: string; headings: Heading[] } | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setState(null);
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState(null);
|
||||
setError(false);
|
||||
fetch(`${DOCS_BASE}/${file}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
})
|
||||
.then((md) => {
|
||||
const rendered = renderMarkdown(md);
|
||||
if (!cancelled) setState(rendered);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return { ...state, error };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown rendering with heading anchors + TOC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
}
|
||||
|
||||
function renderMarkdown(md: string): { html: string; headings: Heading[] } {
|
||||
const headings: Heading[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
const renderer = {
|
||||
heading({ tokens, depth }: { tokens: { raw: string; text?: string }[]; depth: number }) {
|
||||
const text = tokens.map((t) => t.text ?? t.raw).join("");
|
||||
let id = slugify(text);
|
||||
const count = seen.get(id) ?? 0;
|
||||
seen.set(id, count + 1);
|
||||
if (count > 0) id = `${id}-${count}`;
|
||||
headings.push({ id, text, level: depth });
|
||||
return `<h${depth} id="${id}"><a href="#${id}" class="docs-anchor" aria-hidden="true"></a>${text}</h${depth}>`;
|
||||
},
|
||||
};
|
||||
|
||||
const marked = new Marked({ gfm: true, async: false, renderer });
|
||||
const html = marked.parse(md) as string;
|
||||
return { html: DOMPurify.sanitize(html), headings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDocsPath(location: string) {
|
||||
const rest = location.replace(/^\/docs\/?/, "");
|
||||
const segments = rest.split("/").filter(Boolean);
|
||||
const versionRe = /^v\d+\.\d+\.\d+$/;
|
||||
if (segments.length > 0 && versionRe.test(segments[0])) {
|
||||
return { version: segments[0], path: segments.slice(1) };
|
||||
}
|
||||
return { version: null, path: segments };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field type helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fieldTypeLabel(t: FieldType): string {
|
||||
if (t.kind === "array") return `${t.value}[]`;
|
||||
return t.value;
|
||||
}
|
||||
|
||||
function isLinkableType(t: FieldType): boolean {
|
||||
return t.kind === "ref" || (t.kind === "array" && /^[A-Z]/.test(t.value));
|
||||
}
|
||||
|
||||
function resolveTypeHref(t: FieldType): string | null {
|
||||
if (t.kind === "ref") return `/docs/reference/schemas/${t.value}`;
|
||||
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return `/docs/reference/schemas/${t.value}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const METHOD_STYLES: Record<string, string> = {
|
||||
GET: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||||
POST: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
||||
PATCH: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
||||
PUT: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-400",
|
||||
DELETE: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-views
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DocsHeader({
|
||||
versions,
|
||||
activeVersion,
|
||||
onVersionChange,
|
||||
onSearchChange,
|
||||
}: {
|
||||
versions: ReleaseDoc[];
|
||||
activeVersion: string | null;
|
||||
onVersionChange: (v: string | null) => void;
|
||||
onSearchChange?: (q: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<BookOpen className="h-5 w-5 text-primary shrink-0" />
|
||||
<h1 className="text-xl font-bold tracking-tight truncate">{t("docs.title")}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onSearchChange && (
|
||||
<div className="relative hidden md:block">
|
||||
<Search className="h-4 w-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("docs.searchPlaceholder")}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8 w-52"
|
||||
data-testid="input-docs-search"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{versions.length > 0 && (
|
||||
<Select
|
||||
value={activeVersion ?? "latest"}
|
||||
onValueChange={(v) => onVersionChange(v === "latest" ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]" data-testid="select-docs-version">
|
||||
<SelectValue placeholder={t("docs.version")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">
|
||||
{activeVersion === null ? `✓ ${t("docs.latest")}` : t("docs.latest")}
|
||||
</SelectItem>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.version} value={v.version}>
|
||||
{activeVersion === v.version ? `✓ ${v.version}` : v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild data-testid="button-docs-repo" title={t("docs.repo")}>
|
||||
<a href={REPO_URL} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsNav({
|
||||
version,
|
||||
handbook,
|
||||
reference,
|
||||
versions,
|
||||
}: {
|
||||
version: string | null;
|
||||
handbook: HandbookPage[] | null;
|
||||
reference: Reference | null;
|
||||
versions: ReleaseDoc[];
|
||||
}) {
|
||||
const [location] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navLink = (href: string) => {
|
||||
const active = location === href || (href !== "/docs" && location.startsWith(href));
|
||||
return active;
|
||||
};
|
||||
|
||||
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
|
||||
|
||||
if (handbook && handbook.length > 0 && version === null) {
|
||||
groups.push({
|
||||
label: t("docs.guides"),
|
||||
icon: BookOpen,
|
||||
items: handbook.map((p) => ({
|
||||
href: `/docs/handbook/${p.slug}`,
|
||||
label: p.title,
|
||||
active: navLink(`/docs/handbook/${p.slug}`),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (reference) {
|
||||
groups.push({
|
||||
label: t("docs.endpoints"),
|
||||
icon: Server,
|
||||
items: reference.tags.map((tag) => ({
|
||||
href: `/docs/reference/endpoints/${tag.name}`,
|
||||
label: tag.name,
|
||||
active: navLink(`/docs/reference/endpoints/${tag.name}`),
|
||||
})),
|
||||
});
|
||||
groups.push({
|
||||
label: t("docs.schemas"),
|
||||
icon: Library,
|
||||
items: reference.schemas.map((s) => ({
|
||||
href: `/docs/reference/schemas/${s.name}`,
|
||||
label: s.name,
|
||||
active: navLink(`/docs/reference/schemas/${s.name}`),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
groups.push({
|
||||
label: t("docs.releases"),
|
||||
icon: Tag,
|
||||
items: versions.map((v) => ({
|
||||
href: v.version === (version ?? versions[0]?.version) && version !== null
|
||||
? `/docs/releases/${v.version}`
|
||||
: `/docs/releases/${v.version}`,
|
||||
label: v.version,
|
||||
active: navLink(`/docs/releases/${v.version}`),
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="space-y-6" aria-label={t("docs.nav")}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<group.icon className="h-3.5 w-3.5" />
|
||||
{group.label}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`block rounded-md px-2 py-1.5 text-sm transition-colors ${
|
||||
item.active
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block">{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Toc({ headings, title }: { headings: Heading[]; title?: string }) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) setActiveId(entry.target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-80px 0px -70% 0px" },
|
||||
);
|
||||
for (const h of headings) {
|
||||
const el = document.getElementById(h.id);
|
||||
if (el) observer.observe(el);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [headings]);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<aside className="hidden xl:block" aria-label={t("docs.onThisPage")}>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title ?? t("docs.onThisPage")}
|
||||
</p>
|
||||
<ul className="space-y-1 border-l">
|
||||
{headings.map((h) => (
|
||||
<li key={h.id} style={{ paddingLeft: `${Math.min(h.level - 1, 2)}rem` }}>
|
||||
<a
|
||||
href={`#${h.id}`}
|
||||
className={`block border-l -ml-px px-2 py-0.5 text-xs transition-colors ${
|
||||
activeId === h.id
|
||||
? "border-primary text-foreground font-medium"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{h.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownView({ file }: { file: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { html, headings, error } = useMarkdown(file);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl">
|
||||
{error ? (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
) : !html ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="docs-prose prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Toc headings={headings ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTypeChip({ type }: { type: FieldType }) {
|
||||
const href = resolveTypeHref(type);
|
||||
const label = fieldTypeLabel(type);
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="inline-flex">
|
||||
<Badge variant="secondary" className="font-mono hover:bg-accent">
|
||||
{label}
|
||||
</Badge>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary" className="font-mono">{label}</Badge>;
|
||||
}
|
||||
|
||||
function FieldTable({ fields }: { fields: Field[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 font-semibold">Feld</th>
|
||||
<th className="px-3 py-2 font-semibold">Typ</th>
|
||||
<th className="px-3 py-2 font-semibold">Pflicht</th>
|
||||
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((f) => (
|
||||
<tr key={f.name} id={f.name} className="border-b last:border-0 align-top">
|
||||
<td className="px-3 py-2">
|
||||
<a href={`#${f.name}`} className="font-mono text-primary hover:underline">
|
||||
{f.name}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FieldTypeChip type={f.type} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{f.required ? (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20">required</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-muted-foreground">{f.description}</div>
|
||||
{f.constraints && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground/70 font-mono">{f.constraints}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaView({ schema }: { schema: SchemaModel }) {
|
||||
const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 }));
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight font-mono">{schema.name}</h1>
|
||||
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<FieldTable fields={schema.fields} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
|
||||
Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.
|
||||
</p>
|
||||
</div>
|
||||
<Toc headings={headings} title="Felder" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||
const headings: Heading[] = tag.endpoints.map((e) => ({
|
||||
id: e.operationId,
|
||||
text: `${e.method} ${e.path}`,
|
||||
level: 2,
|
||||
}));
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl space-y-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{tag.name}</h1>
|
||||
{tag.description && <p className="text-muted-foreground">{tag.description}</p>}
|
||||
</div>
|
||||
{tag.endpoints.map((ep) => (
|
||||
<section key={ep.operationId} id={ep.operationId} className="scroll-mt-20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge className={`font-mono ${METHOD_STYLES[ep.method] ?? "bg-muted text-muted-foreground"}`}>
|
||||
{ep.method}
|
||||
</Badge>
|
||||
<code className="font-mono text-sm">{ep.path}</code>
|
||||
<a href={`#${ep.operationId}`} className="ml-auto text-muted-foreground hover:text-foreground">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<h2 className="mb-1 text-lg font-semibold">{ep.summary}</h2>
|
||||
{ep.description && <p className="mb-3 text-sm text-muted-foreground">{ep.description}</p>}
|
||||
|
||||
{ep.parameters.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parameter</p>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 font-semibold">Name</th>
|
||||
<th className="px-3 py-2 font-semibold">In</th>
|
||||
<th className="px-3 py-2 font-semibold">Typ</th>
|
||||
<th className="px-3 py-2 font-semibold">Pflicht</th>
|
||||
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.parameters.map((p) => (
|
||||
<tr key={`${p.name}-${p.in}`} className="border-b last:border-0">
|
||||
<td className="px-3 py-1.5 font-mono">{p.name}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
|
||||
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
|
||||
<td className="px-3 py-1.5">
|
||||
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">req</Badge> : "–"}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">
|
||||
{p.description}
|
||||
{p.constraints && <span className="block font-mono text-xs">{p.constraints}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ep.requestBody && (
|
||||
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Request Body {ep.requestBody.required && <Badge className="ml-1">required</Badge>}
|
||||
</p>
|
||||
<FieldTypeChip type={ep.requestBody.schema} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 font-semibold">Status</th>
|
||||
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||
<th className="px-3 py-2 font-semibold">Schema</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ep.responses.map((r) => (
|
||||
<tr key={r.status} className="border-b last:border-0">
|
||||
<td className="px-3 py-1.5 font-mono">{r.status}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">{r.description}</td>
|
||||
<td className="px-3 py-1.5"><FieldTypeChip type={r.schema} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<Toc headings={headings} title="Endpunkte" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||
const [, setLocation] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("docs.releases")}</h1>
|
||||
<p className="text-muted-foreground">{t("docs.subtitle")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{versions.map((v) => (
|
||||
<button
|
||||
key={v.version}
|
||||
type="button"
|
||||
onClick={() => setLocation(`/docs/releases/${v.version}`)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{v.title}</span>
|
||||
<Badge variant="secondary">{v.version}</Badge>
|
||||
</div>
|
||||
{v.date && (
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{new Date(`${v.date}T00:00:00`).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.hasReference && <Badge className="bg-primary/10 text-primary">API-Referenz</Badge>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Toc headings={[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseNoteView({ version }: { version: string }) {
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span className="font-mono">{version}</span>
|
||||
<a
|
||||
href={`${REPO_URL}/tags/${version}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<MarkdownView file={`releases/${version}.md`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HandbookView({ slug }: { slug: string }) {
|
||||
const handbookFile = `${slug}.md`;
|
||||
return <MarkdownView file={`handbook/${handbookFile}`} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useDocsSearch(query: string) {
|
||||
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/search.json` : null);
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim() || !data) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
return data
|
||||
.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(q) ||
|
||||
e.text.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, 25);
|
||||
}, [query, data]);
|
||||
|
||||
return { results, error };
|
||||
}
|
||||
|
||||
function SearchOverlay({ query, onClose }: { query: string; onClose: () => void }) {
|
||||
const { results } = useDocsSearch(query);
|
||||
if (!query.trim()) return null;
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border bg-card p-2 shadow-md max-h-96 overflow-auto">
|
||||
{results.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">Keine Treffer</p>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
<Link
|
||||
key={r.href}
|
||||
href={r.href}
|
||||
onClick={onClose}
|
||||
className="flex items-start gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{r.kind === "endpoint" && <Server className="h-4 w-4 text-emerald-500" />}
|
||||
{r.kind === "field" && <Library className="h-4 w-4 text-blue-500" />}
|
||||
{r.kind === "guide" && <BookOpen className="h-4 w-4 text-amber-500" />}
|
||||
{r.kind === "release" && <Tag className="h-4 w-4 text-primary" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium truncate">{r.title}</span>
|
||||
<span className="block text-xs text-muted-foreground truncate">{r.text.slice(0, 80)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Docs() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { version, path } = useMemo(() => parseDocsPath(location), [location]);
|
||||
|
||||
const { data: versionInfo } = useGetVersion({
|
||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||
});
|
||||
|
||||
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
|
||||
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(
|
||||
version === null ? `${DOCS_BASE}/handbook/index.json` : null,
|
||||
);
|
||||
|
||||
const isCurrentVersion =
|
||||
version === null ||
|
||||
(versionInfo?.version && versionInfo.version !== "dev" && version === versionInfo.version) ||
|
||||
(version !== null && (!versionInfo?.version || versionInfo.version === "dev"));
|
||||
|
||||
const refUrl = version === null
|
||||
? `${DOCS_BASE}/reference.json`
|
||||
: releases?.find((r) => r.version === version)?.hasReference
|
||||
? `${DOCS_BASE}/versions/${version}.json`
|
||||
: null;
|
||||
|
||||
const { data: reference, error: refError } = useJson<Reference>(refUrl);
|
||||
|
||||
// Current version detection: prefer running version, fallback newest documented
|
||||
const currentVersion = useMemo(() => {
|
||||
if (releases && releases.length > 0) {
|
||||
if (versionInfo?.version && versionInfo.version !== "dev") {
|
||||
const match = releases.find((r) => r.version === versionInfo.version);
|
||||
if (match) return match.version;
|
||||
}
|
||||
return releases[0].version;
|
||||
}
|
||||
return null;
|
||||
}, [releases, versionInfo]);
|
||||
|
||||
// Redirect old-style /docs/vX.Y.Z to /docs/releases/vX.Y.Z
|
||||
useEffect(() => {
|
||||
if (version !== null && path.length === 0) {
|
||||
setLocation(`/docs/releases/${version}`, { replace: true });
|
||||
}
|
||||
}, [version, path, setLocation]);
|
||||
|
||||
const handleVersionChange = (v: string | null) => {
|
||||
setSearchQuery("");
|
||||
if (v === null || v === currentVersion) {
|
||||
setLocation("/docs");
|
||||
return;
|
||||
}
|
||||
setLocation(`/docs/releases/${v}`);
|
||||
};
|
||||
|
||||
// ---- route resolution ----
|
||||
const section = path[0] ?? "home";
|
||||
const param = path[1];
|
||||
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
if (version !== null && path.length === 0) {
|
||||
content = <ReleaseNoteView version={version} />;
|
||||
} else if (section === "home") {
|
||||
content =
|
||||
handbook && handbook.length > 0 ? (
|
||||
<HandbookView slug={handbook[0].slug} />
|
||||
) : releases && releases.length > 0 ? (
|
||||
<ReleasesView versions={releases} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
);
|
||||
} else if (section === "handbook" && param) {
|
||||
content = <HandbookView slug={param} />;
|
||||
} else if (section === "reference" && param === "endpoints" && path[2]) {
|
||||
const tag = reference?.tags.find((tg) => tg.name === path[2]);
|
||||
content = tag ? (
|
||||
<EndpointTagView tag={tag} />
|
||||
) : refError || (reference && !tag) ? (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
) : (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
);
|
||||
} else if (section === "reference" && param === "schemas" && path[2]) {
|
||||
const schema = reference?.schemas.find((s) => s.name === path[2]);
|
||||
content = schema ? (
|
||||
<SchemaView schema={schema} />
|
||||
) : refError || (reference && !schema) ? (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
) : (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
);
|
||||
} else if (section === "reference") {
|
||||
content = (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("docs.reference")}</h1>
|
||||
<p className="text-muted-foreground">{t("docs.referenceIntro")}</p>
|
||||
{!reference && !refError && <Skeleton className="h-64 w-full" />}
|
||||
{reference && (
|
||||
<>
|
||||
<div>
|
||||
<h2 className="mb-1 text-lg font-semibold">{t("docs.endpoints")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{reference.tags.map((tg) => (
|
||||
<Link
|
||||
key={tg.name}
|
||||
href={`/docs/reference/endpoints/${tg.name}`}
|
||||
className="rounded-lg border p-3 text-sm hover:bg-accent/50"
|
||||
>
|
||||
<span className="font-medium">{tg.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{tg.endpoints.length} {t("docs.endpoints")}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 text-lg font-semibold">{t("docs.schemas")}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{reference.schemas.map((s) => (
|
||||
<Link
|
||||
key={s.name}
|
||||
href={`/docs/reference/schemas/${s.name}`}
|
||||
className="rounded-lg border p-3 text-sm font-mono hover:bg-accent/50"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toc headings={[]} />
|
||||
</div>
|
||||
);
|
||||
} else if (section === "releases" && param) {
|
||||
content = <ReleaseNoteView version={param} />;
|
||||
} else if (section === "releases") {
|
||||
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
|
||||
} else {
|
||||
content = <p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>;
|
||||
}
|
||||
|
||||
const showSearch = version === null && section !== "releases";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mx-auto max-w-6xl space-y-6 pb-10">
|
||||
<DocsHeader
|
||||
versions={releases ?? []}
|
||||
activeVersion={version}
|
||||
onVersionChange={handleVersionChange}
|
||||
onSearchChange={showSearch ? setSearchQuery : undefined}
|
||||
/>
|
||||
|
||||
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
|
||||
<DocsNav
|
||||
version={version}
|
||||
handbook={handbook}
|
||||
reference={reference}
|
||||
versions={releases ?? []}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { customFetch } from "@workspace/api-client-react";
|
||||
import { recordRecentTool } from "@/lib/recent-tools";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
|
||||
const ratingSchema = z.object({
|
||||
usefulness: z.number().min(1).max(5),
|
||||
@@ -776,7 +777,10 @@ export default function ToolDetail() {
|
||||
name="usefulness"
|
||||
render={({ field }) => (
|
||||
<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">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
@@ -794,7 +798,10 @@ export default function ToolDetail() {
|
||||
name="usability"
|
||||
render={({ field }) => (
|
||||
<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">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
@@ -814,7 +821,10 @@ export default function ToolDetail() {
|
||||
name="comment"
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
<Textarea
|
||||
placeholder="What do you think about this tool?"
|
||||
@@ -832,7 +842,10 @@ export default function ToolDetail() {
|
||||
name="reviewerName"
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
<Input placeholder="Anonymous" {...field} />
|
||||
</FormControl>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
|
||||
const toolSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
@@ -171,7 +172,10 @@ export default function ToolEdit() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Name
|
||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tool name" {...field} />
|
||||
</FormControl>
|
||||
@@ -184,7 +188,10 @@ export default function ToolEdit() {
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Category
|
||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
@@ -198,8 +205,11 @@ export default function ToolEdit() {
|
||||
control={form.control}
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website URL (Optional)</FormLabel>
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} />
|
||||
</FormControl>
|
||||
@@ -212,8 +222,11 @@ export default function ToolEdit() {
|
||||
control={form.control}
|
||||
name="iconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Icon / Logo URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<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">
|
||||
@@ -245,8 +258,11 @@ export default function ToolEdit() {
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Description
|
||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
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="flex justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
<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="flex justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
|
||||
const toolSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
@@ -134,7 +135,10 @@ export default function ToolNew() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Name
|
||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
||||
</FormControl>
|
||||
@@ -148,7 +152,10 @@ export default function ToolNew() {
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Category
|
||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox
|
||||
value={field.value}
|
||||
@@ -161,12 +168,15 @@ export default function ToolNew() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website URL (Optional)</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
||||
</FormControl>
|
||||
@@ -180,7 +190,10 @@ export default function ToolNew() {
|
||||
name="iconUrl"
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
<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">
|
||||
@@ -214,7 +227,10 @@ export default function ToolNew() {
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Description
|
||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
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="flex justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
@@ -284,7 +303,10 @@ export default function ToolNew() {
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Administration & Papierkorb
|
||||
order: 7
|
||||
---
|
||||
|
||||
# Administration & Papierkorb
|
||||
|
||||
Diese Bereiche sind nur für **Administrator:innen** sichtbar und nutzbar.
|
||||
|
||||
## Nutzerverwaltung
|
||||
|
||||
Unter **Admin → Nutzer** kannst du:
|
||||
|
||||
- **Lokale Nutzer anlegen** (Benutzername, Passwort, E-Mail, Rolle, Tier).
|
||||
- **Rollen/Tier ändern** (`admin`/`user`, `free`/`premium`/`enterprise`).
|
||||
- **Passwörter zurücksetzen** (nur lokale Nutzer).
|
||||
- **Nutzer löschen**.
|
||||
|
||||
Zugehörige Endpunkte (Admin-only):
|
||||
|
||||
- [`GET /users`](/docs/reference/endpoints/users#listusers)
|
||||
- [`POST /users`](/docs/reference/endpoints/users#createuser)
|
||||
- [`PATCH /users/{id}`](/docs/reference/endpoints/users#updateuser)
|
||||
- [`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteuser)
|
||||
- [`PATCH /users/{id}/password`](/docs/reference/endpoints/users#setuserpassword)
|
||||
|
||||
## Audit-Log
|
||||
|
||||
Das **Audit-Log** protokolliert sicherheitsrelevante Änderungen (wer hat wann
|
||||
was geändert). Es ist über
|
||||
[`GET /audit-logs`](/docs/reference/endpoints/audit#listauditlogs) abrufbar und
|
||||
filterbar nach Entitätstyp, Entitäts-ID und Limit.
|
||||
|
||||
## Papierkorb (Trash)
|
||||
|
||||
Tools werden nicht sofort gelöscht, sondern zuerst **soft gelöscht** (in den
|
||||
Papierkorb verschoben):
|
||||
|
||||
- **Liste:** [`GET /tools/trash`](/docs/reference/endpoints/tools#listtrashedtools)
|
||||
- **In den Papierkorb verschieben:** [`POST /tools/trash`](/docs/reference/endpoints/tools#trashtools)
|
||||
- **Wiederherstellen:** [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoretools)
|
||||
- **Endgültig löschen (einzeln):** [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deletetrashedtools)
|
||||
- **Papierkorb leeren:** [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptytrash)
|
||||
|
||||
> Die Aufbewahrungsfrist des Papierkorbs (in Tagen) ist im
|
||||
> [`VersionInfo`](/docs/reference/schemas/versioninfo)-Schema als
|
||||
> `trashRetentionDays` verfügbar.
|
||||
|
||||
## Felder im Überblick
|
||||
|
||||
### User
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `id` | integer | Eindeutige Nutzer-ID. |
|
||||
| `username` | string | Anmeldename. |
|
||||
| `email` | string? | E-Mail-Adresse. |
|
||||
| `role` | string | `admin` oder `user`. |
|
||||
| `tier` | string | `free`, `premium` oder `enterprise`. |
|
||||
| `authProvider` | string | `local` oder `oidc`. |
|
||||
| `createdAt` | date-time | Erstellungszeitpunkt. |
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Analytics
|
||||
order: 6
|
||||
---
|
||||
|
||||
# Analytics
|
||||
|
||||
Der Bereich **Analytics** fasst die Plattform-Statistiken zusammen — für alle
|
||||
Nutzer:innen ohne Einschränkung sichtbar.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Widget | Quelle (Endpunkt) | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| **Plattform-Kennzahlen** | [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getanalyticssummary) | Gesamtzahl Tools, Bewertungen, Durchschnittswerte, Kategorienzahl. |
|
||||
| **Top-Tools** | [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#gettoppertools) | Bestbewertete Tools nach wählbarer Metrik (Nützlichkeit, Bedienbarkeit, kombiniert). |
|
||||
| **Nach Kategorie** | [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getanalyticsbycategory) | Kennzahlen je Kategorie. |
|
||||
| **Verteilung** | [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getratingdistribution) | Verteilung der Bewertungswerte (optional je Tool). |
|
||||
|
||||
## Datenfelder
|
||||
|
||||
### AnalyticsSummary
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `totalTools` | integer | Anzahl aller Tools. |
|
||||
| `totalRatings` | integer | Anzahl aller Bewertungen. |
|
||||
| `avgUsefulness` | number? | Durchschnittliche Nützlichkeit. |
|
||||
| `avgUsability` | number? | Durchschnittliche Bedienbarkeit. |
|
||||
| `avgCombined` | number? | Durchschnitt kombinierter Wert. |
|
||||
| `categoriesCount` | integer | Anzahl der Kategorien. |
|
||||
| `mostRatedTool` | ToolWithStats | Das meistbewertete Tool. |
|
||||
|
||||
Vollständige Feldlisten: [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary),
|
||||
[`TopToolEntry`](/docs/reference/schemas/topptoolentry),
|
||||
[`CategoryStats`](/docs/reference/schemas/categorystats),
|
||||
[`RatingDistribution`](/docs/reference/schemas/ratingdistribution).
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Bewertungen
|
||||
order: 4
|
||||
---
|
||||
|
||||
# Bewertungen
|
||||
|
||||
Bewertungen sind das Herz von toolr: Sie zeigen, wie nützlich und wie gut
|
||||
bedienbar ein Tool in den Augen der Community ist.
|
||||
|
||||
## Wie funktioniert die Bewertung?
|
||||
|
||||
Auf der Detailseite eines Tools vergibst du zwei Werte (1–5 Sterne):
|
||||
|
||||
- **Nützlichkeit** — Wie gut löst das Tool sein Kernproblem?
|
||||
- **Bedienbarkeit** — Wie einfach ist es zu bedienen?
|
||||
|
||||
Optional kannst du einen **Kommentar** und deinen **Namen** hinterlassen. Die
|
||||
Eingabefelder entsprechen dem Schema
|
||||
[`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||
|
||||
## Was passiert mit meiner Bewertung?
|
||||
|
||||
- Deine Bewertung wird sofort gespeichert und in den Durchschnittswerten des
|
||||
Tools berücksichtigt.
|
||||
- Jede Bewertung ist über [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listtoolratings)
|
||||
abrufbar.
|
||||
- Der **Rating-Verlauf** über die Zeit ist über
|
||||
[`GET /tools/{id}/rating-history`](/docs/reference/endpoints/tools#gettoolratinghistory)
|
||||
einsehbar (grafisch auf der Detailseite).
|
||||
|
||||
## Datenfelder
|
||||
|
||||
Eine Bewertung besteht aus diesen Feldern
|
||||
([`Rating`](/docs/reference/schemas/rating)):
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `id` | integer | Eindeutige ID der Bewertung. |
|
||||
| `toolId` | integer | ID des bewerteten Tools. |
|
||||
| `usefulness` | integer (1–5) | Nützlichkeitsbewertung. |
|
||||
| `usability` | integer (1–5) | Bedienbarkeitsbewertung. |
|
||||
| `comment` | string? | Optionaler Kommentar. |
|
||||
| `reviewerName` | string? | Optionaler Anzeigename des Bewerters. |
|
||||
| `createdAt` | date-time | Zeitpunkt der Bewertung. |
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Datenmodell & Felder
|
||||
order: 8
|
||||
---
|
||||
|
||||
# Datenmodell & Felder
|
||||
|
||||
Dieses Handbuch erklärt die zentralen Objekte von toolr auf verständliche
|
||||
Weise. Die **vollständige, maschinell generierte Feld-Referenz** findest du in
|
||||
der [Referenz](/docs/reference/schemas/tool) — dort sind alle Typen, Pflicht-
|
||||
angaben und Constraints der aktuellen Version dokumentiert.
|
||||
|
||||
> Die Referenz ist **versionsgebunden**: Über das Versions-Dropdown oben
|
||||
> kannst du ältere API-Stände einsehen.
|
||||
|
||||
## Die wichtigsten Objekte
|
||||
|
||||
### Tool
|
||||
|
||||
Ein Tool ist der zentrale Eintrag im Katalog
|
||||
([Feld-Referenz](/docs/reference/schemas/tool)):
|
||||
|
||||
| Feld | Bedeutung |
|
||||
| --- | --- |
|
||||
| `id` | Eindeutige ID. |
|
||||
| `name` | Anzeigename. |
|
||||
| `description` | Kurzbeschreibung. |
|
||||
| `category` | Kategorie-Zuordnung. |
|
||||
| `websiteUrl` / `iconUrl` | Offizielle Website bzw. Logo-Link (optional). |
|
||||
| `createdBy` | Nutzer, der das Tool angelegt hat (optional). |
|
||||
| `features` / `tags` | Listen von Schlüsselfähigkeiten bzw. Schlagwörtern. |
|
||||
| `createdAt` / `updatedAt` | Zeitstempel. |
|
||||
| `deletedAt` / `deletedBy` | Soft-Delete-Informationen (Papierkorb). |
|
||||
|
||||
> **ToolWithStats** erweitert `Tool` um die Aggregatwerte `ratingCount`,
|
||||
> `avgUsefulness`, `avgUsability` und `avgCombined` (siehe
|
||||
> [Feld-Referenz](/docs/reference/schemas/toolwithstats)).
|
||||
|
||||
### Rating
|
||||
|
||||
Eine Bewertung (`Rating`) besteht aus `usefulness` und `usability` (jeweils
|
||||
1–5) sowie optionalem Kommentar und Bewerternamen. Details unter
|
||||
[Bewertungen](/docs/handbook/bewertungen).
|
||||
|
||||
### User / AuthUser
|
||||
|
||||
- **User** (Admin-Sicht): `id`, `username`, `email`, `role`, `tier`,
|
||||
`authProvider`, `createdAt` — siehe [Administration](/docs/handbook/administration).
|
||||
- **AuthUser** (Eigenansicht): `sub`, `email`, `name`, `preferredUsername`,
|
||||
`role`, `tier`, `entitlements`, `isLocal`.
|
||||
|
||||
### VersionInfo
|
||||
|
||||
`GET /version` liefert `version`, `commitSha`, `buildDate` und
|
||||
`trashRetentionDays` (siehe [Feld-Referenz](/docs/reference/schemas/versioninfo)).
|
||||
|
||||
## Referenz selber durchsuchen
|
||||
|
||||
Nutze das **Suchfeld** in der Doku-Seitenleiste: Es durchsucht Handbuch,
|
||||
Endpunkt- und Feldbeschreibungen und springt direkt zum passenden Anker.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Erste Schritte
|
||||
order: 2
|
||||
---
|
||||
|
||||
# Erste Schritte
|
||||
|
||||
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
|
||||
Besuch bis zum Anlegen und Bewerten eines Tools.
|
||||
|
||||
## 1. Anmelden
|
||||
|
||||
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist) erfordern ein
|
||||
Konto. Klicke oben rechts auf **Anmelden**. Je nach Konfiguration der Instanz
|
||||
hast du zwei Möglichkeiten:
|
||||
|
||||
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
|
||||
angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter.
|
||||
|
||||
Welcher Modus aktiv ist, steht im [Endpunkt
|
||||
`GET /auth/mode`](/docs/reference/endpoints/auth#getauthmode).
|
||||
|
||||
## 2. Tools finden
|
||||
|
||||
Öffne den Bereich **Tools durchsuchen**:
|
||||
|
||||
- **Suchen** — Volltextsuche über Name & Beschreibung.
|
||||
- **Filtern** — nach Kategorie, Tags und Features; zusätzlich
|
||||
Mindestbewertung (`minRating`).
|
||||
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
|
||||
(auf-/absteigend) oder letztem Update.
|
||||
|
||||
Die Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
|
||||
[`GET /tools`](/docs/reference/endpoints/tools#listtools).
|
||||
|
||||
## 3. Tool anlegen
|
||||
|
||||
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
|
||||
findest du im [Handbuch "Tool anlegen"](/docs/handbook/tool-anlegen) und in der
|
||||
[Feld-Referenz](/docs/reference/schemas/toolinput).
|
||||
|
||||
## 4. Bewerten
|
||||
|
||||
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und
|
||||
**Bedienbarkeit** (jeweils 1–5) vergeben und optional einen Kommentar
|
||||
hinterlassen. Deine Bewertung fließt sofort in die Statistiken ein.
|
||||
|
||||
## 5. Weiterführend
|
||||
|
||||
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||
- [Watchlist](/docs/handbook/watchlist)
|
||||
- [Analytics](/docs/handbook/analytics)
|
||||
- [Administration & Papierkorb](/docs/handbook/administration)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Überblick
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Willkommen bei toolr
|
||||
|
||||
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
|
||||
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
|
||||
die richtige Wahl zu treffen.
|
||||
|
||||
## Was kannst du mit toolr tun?
|
||||
|
||||
| Funktion | Beschreibung | Sichtbarkeit |
|
||||
| --- | --- | --- |
|
||||
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
|
||||
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
|
||||
| **Bewerten** | Nützlichkeit & Bedienbarkeit (1–5) plus Kommentar vergeben | Angemeldet |
|
||||
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
|
||||
| **Watchlist** | Tools als Favoriten speichern | Premium |
|
||||
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
|
||||
| **Admin** | Nutzerverwaltung, Audit-Log, Papierkorb | Admin |
|
||||
| **Trash** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Admin |
|
||||
|
||||
## Funktionen & Felder im Detail
|
||||
|
||||
Die **Referenz** ist automatisch aus der OpenAPI-Spezifikation generiert und
|
||||
deckt damit garantiert *alle* Endpunkte und Datenfelder der aktuellen Version
|
||||
ab:
|
||||
|
||||
- [Endpunkte](/docs/reference/endpoints/tools) — jede API-Operation mit
|
||||
Parametern und Antwort-Schemas.
|
||||
- [Datenmodell & Felder](/docs/reference/schemas/tool) — jedes Feld mit Typ,
|
||||
Pflichtstatus und Bedeutung.
|
||||
|
||||
> Die Referenz ist **versionsgebunden**: Wähle oben rechts eine ältere Version,
|
||||
> um den API-Stand dieses Releases zu sehen.
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
- Neu hier? Starte mit dem [Erste-Schritte-Guide](/docs/handbook/getting-started).
|
||||
- Möchtest du ein Tool eintragen? Siehe [Tool anlegen](/docs/handbook/tool-anlegen).
|
||||
- Formulare zeigen neben jedem Feld ein **Hilfe-Icon (?)**, das direkt zur
|
||||
Erklärung des Felds in der Doku springt.
|
||||
|
||||
## Wo ist der Quellcode?
|
||||
|
||||
Über das **Repository-Logo oben rechts** gelangst du direkt zum Quellcode auf
|
||||
GitHub.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Tool anlegen & bearbeiten
|
||||
order: 3
|
||||
---
|
||||
|
||||
# Tool anlegen & bearbeiten
|
||||
|
||||
## Neues Tool anlegen
|
||||
|
||||
Unter **Tool hinzufügen** legst du ein neues Tool an. Die Felder entsprechen
|
||||
dem Eingabeschema [`ToolInput`](/docs/reference/schemas/toolinput):
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| **Name** | ja | Anzeigename des Tools (min. 2 Zeichen). |
|
||||
| **Beschreibung** | ja | Was tut das Tool, warum nutzen es Leute? (min. 10 Zeichen) |
|
||||
| **Kategorie** | ja | Zugeordnete Kategorie (aus bestehenden Kategorien wählbar). |
|
||||
| **Website-URL** | nein | Offizielle Website (`https://…`). |
|
||||
| **Icon-/Logo-URL** | nein | Direktlink zu einem Logo-Bild. |
|
||||
| **Features** | nein | Schlüsselfähigkeiten, z. B. „Echtzeit-Kollaboration“. Bereits bekannte Features sind auswählbar. |
|
||||
| **Tags** | nein | Schlagwörter zum Auffinden. Bereits bekannte Tags sind auswählbar. |
|
||||
|
||||
> Hinter jedem Label findest du ein **Hilfe-Icon (?)** — es verlinkt direkt
|
||||
> zur Feldbeschreibung in dieser Doku.
|
||||
|
||||
### Hinweise
|
||||
|
||||
- **Features & Tags** sind Listen. Über **+ Feature / + Tag** fügst du weitere
|
||||
Einträge hinzu; über das ✕-Symbol entfernst du sie.
|
||||
- URLs müssen absolut und gültig sein.
|
||||
- Leere Einträge in Feature-/Tag-Listen werden beim Speichern verworfen.
|
||||
|
||||
## Tool bearbeiten
|
||||
|
||||
Auf der Detailseite eines Tools öffnet **Bearbeiten** das Formular mit den
|
||||
aktuellen Werten. Du kannst Name, Beschreibung, Kategorie, URLs, Features und
|
||||
Tags ändern. Nur angemeldete Nutzer:innen können Tools bearbeiten.
|
||||
|
||||
## Tool löschen
|
||||
|
||||
Über die Detailseite kannst du ein Tool **in den Papierkorb verschieben**
|
||||
(Soft-Delete). Es verschwindet aus dem Katalog, bleibt aber im Papierkorb
|
||||
erhalten. Siehe [Administration & Papierkorb](/docs/handbook/administration).
|
||||
|
||||
## Zugehörige Endpunkte
|
||||
|
||||
- [`POST /tools`](/docs/reference/endpoints/tools#createtool) — Tool anlegen
|
||||
- [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updatetool) — Tool bearbeiten
|
||||
- [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deletetool) — Tool löschen
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Vergleichen & Watchlist
|
||||
order: 5
|
||||
---
|
||||
|
||||
# Vergleichen & Watchlist
|
||||
|
||||
Diese Funktionen sind für **Premium-Nutzer:innen** verfügbar.
|
||||
|
||||
## Tools vergleichen
|
||||
|
||||
Mit **Vergleichen** stellst du mehrere Tools nebeneinander und siehst deren
|
||||
Daten auf einen Blick — ideal für die Tool-Auswahl.
|
||||
|
||||
1. Füge Tools über die **Vergleichsleiste** (Vergleichs-Icon auf Karten) hinzu.
|
||||
2. Öffne den Bereich **Vergleichen**. Die Tools erscheinen in der gewählten
|
||||
Reihenfolge.
|
||||
3. Die Vergleichsansicht zeigt pro Tool die wichtigsten Felder und
|
||||
Durchschnittswerte.
|
||||
|
||||
Der zugrunde liegende Endpunkt ist
|
||||
[`GET /compare`](/docs/reference/endpoints/tools#listcomparetools) mit dem
|
||||
Parameter `ids` (kommagetrennt). Ohne Premium-Berechtigung liefert er `403`.
|
||||
|
||||
## Watchlist
|
||||
|
||||
Die **Watchlist** ist deine persönliche Favoritenliste:
|
||||
|
||||
- **Hinzufügen/Entfernen:** Nutze das Lesezeichen-Symbol auf der
|
||||
Tool-Karte oder der Detailseite.
|
||||
- Die gespeicherte Reihenfolge bleibt erhalten.
|
||||
- Sie wird über [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getmewatchlist)
|
||||
geladen; das Setzen erfolgt über
|
||||
[`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updatemepreferences)
|
||||
(Feld `watchlist`).
|
||||
|
||||
### Feld `watchlist`
|
||||
|
||||
Das Feld `watchlist` in [`UserPreferences`](/docs/reference/schemas/userpreferences)
|
||||
ist ein Array von Tool-IDs in gespeicherter Reihenfolge:
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `view` | string | Ansichtsmodus (`grid`, `table`, `rows`). |
|
||||
| `density` | string | Dichte (`cozy`, `compact`). |
|
||||
| `watchlist` | integer[] | Tool-IDs in Favoriten-Reihenfolge. |
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -0,0 +1,46 @@
|
||||
# v0.8.0 — Release Notes
|
||||
|
||||
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
|
||||
|
||||
## Neue Features
|
||||
|
||||
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
|
||||
- **Handbuch** mit verständlichen Erklärungen zu allen Features
|
||||
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
|
||||
Analytics, Administration, Datenmodell).
|
||||
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
|
||||
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
|
||||
damit ist garantiert, dass *jedes* Feature dokumentiert ist.
|
||||
- **Suche** über Handbuch, Endpunkte und Felder.
|
||||
- **Versions-Dropdown**: ältere Releases behalten ihre vollständige
|
||||
Feld-/Endpunkt-Referenz als Snapshot.
|
||||
- **Repo-Link** oben rechts zur Quelle.
|
||||
- **Hilfe-Buttons (?) in Formularen** (NetBox-Stil): neben jedem Feld
|
||||
springt ein Icon direkt zur Feldbeschreibung in der Doku.
|
||||
|
||||
## Fixes & Verbesserungen
|
||||
|
||||
- Doku-Generator `scripts/src/generate-docs.mjs` ersetzt den bisherigen
|
||||
`sync-release-docs.mjs` (OpenAPI-Parsing, Handbuch, Suchindex, Snapshots).
|
||||
- Dokumentation für v0.7.0 nachgezogen.
|
||||
|
||||
## API-Änderungen
|
||||
|
||||
- Keine Breaking Changes an der API.
|
||||
|
||||
## Betrieb / Upgrade
|
||||
|
||||
- **Env-Vars:** unverändert.
|
||||
- **Migration:** keine.
|
||||
- **Breaking Changes:** keine.
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Handbuch & Referenz gelten für die aktuelle Version; ältere Versionen
|
||||
zeigen ihre Release-Notes und einen Referenz-Snapshot, sofern beim Release
|
||||
erzeugt (`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
|
||||
|
||||
## Links
|
||||
|
||||
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
|
||||
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Generated by orval v8.23.0 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
@@ -70,6 +70,21 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
const withQueryKey = <T extends object, K>(query: T, queryKey: K): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') continue;
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getHealthCheckUrl = () => {
|
||||
|
||||
|
||||
@@ -82,7 +97,7 @@ export const getHealthCheckUrl = () => {
|
||||
* Returns server health status
|
||||
* @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(),
|
||||
{
|
||||
@@ -139,7 +154,7 @@ export function useHealthCheck<TData = Awaited<ReturnType<typeof healthCheck>>,
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +175,7 @@ export const getGetVersionUrl = () => {
|
||||
* Returns the running build version, commit SHA and build date
|
||||
* @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(),
|
||||
{
|
||||
@@ -217,7 +232,7 @@ export function useGetVersion<TData = Awaited<ReturnType<typeof getVersion>>, TE
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +247,7 @@ export const getListToolsUrl = (params?: ListToolsParams,) => {
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -244,7 +259,7 @@ export const getListToolsUrl = (params?: ListToolsParams,) => {
|
||||
/**
|
||||
* @summary List all tools
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -301,7 +316,7 @@ export function useListTools<TData = Awaited<ReturnType<typeof listTools>>, TErr
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -321,21 +336,21 @@ export const getCreateToolUrl = () => {
|
||||
/**
|
||||
* @summary Create a new tool
|
||||
*/
|
||||
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(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
toolInput,)
|
||||
body: JSON.stringify(toolInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getCreateToolMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -387,7 +402,7 @@ export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => {
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -399,7 +414,7 @@ export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => {
|
||||
/**
|
||||
* @summary Compare tools side by side (premium)
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -456,7 +471,7 @@ export function useListCompareTools<TData = Awaited<ReturnType<typeof listCompar
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -476,7 +491,7 @@ export const getGetToolRatingHistoryUrl = (id: number,) => {
|
||||
/**
|
||||
* @summary Get a tool's rating history over time
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -513,7 +528,7 @@ const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData> & { queryKey: QueryKey }
|
||||
return { queryKey, queryFn, enabled: id !== null && id !== undefined, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetToolRatingHistoryQueryResult = NonNullable<Awaited<ReturnType<typeof getToolRatingHistory>>>
|
||||
@@ -533,7 +548,7 @@ export function useGetToolRatingHistory<TData = Awaited<ReturnType<typeof getToo
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -553,7 +568,7 @@ export const getGetToolUrl = (id: number,) => {
|
||||
/**
|
||||
* @summary Get a tool by ID
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -590,7 +605,7 @@ const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTool>>, TError, TData> & { queryKey: QueryKey }
|
||||
return { queryKey, queryFn, enabled: id !== null && id !== undefined, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTool>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetToolQueryResult = NonNullable<Awaited<ReturnType<typeof getTool>>>
|
||||
@@ -610,7 +625,7 @@ export function useGetTool<TData = Awaited<ReturnType<typeof getTool>>, TError =
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -631,21 +646,21 @@ export const getUpdateToolUrl = (id: number,) => {
|
||||
* @summary Update a tool
|
||||
*/
|
||||
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),
|
||||
{
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
toolUpdate,)
|
||||
body: JSON.stringify(toolUpdate)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateTool>>, TError,{id: number;data: BodyType<ToolUpdate>}, TContext> => {
|
||||
@@ -702,7 +717,7 @@ export const getDeleteToolUrl = (id: number,) => {
|
||||
/**
|
||||
* @summary Delete a tool
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -716,6 +731,7 @@ export const deleteTool = async (id: number, options?: RequestInit): Promise<voi
|
||||
|
||||
|
||||
|
||||
|
||||
export const getDeleteToolMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -767,7 +783,7 @@ export const getListTrashedToolsUrl = (params?: ListTrashedToolsParams,) => {
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -779,7 +795,7 @@ export const getListTrashedToolsUrl = (params?: ListTrashedToolsParams,) => {
|
||||
/**
|
||||
* @summary List trashed (soft-deleted) tools
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -836,7 +852,7 @@ export function useListTrashedTools<TData = Awaited<ReturnType<typeof listTrashe
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -856,21 +872,21 @@ export const getTrashToolsUrl = () => {
|
||||
/**
|
||||
* @summary Move tools to trash (admin)
|
||||
*/
|
||||
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(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
trashToolsInput,)
|
||||
body: JSON.stringify(trashToolsInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getTrashToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -927,21 +943,21 @@ export const getDeleteTrashedToolsUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
...options,
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
trashToolsInput,)
|
||||
body: JSON.stringify(trashToolsInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getDeleteTrashedToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -998,21 +1014,21 @@ export const getRestoreToolsUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
trashToolsInput,)
|
||||
body: JSON.stringify(trashToolsInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getRestoreToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -1069,7 +1085,7 @@ export const getEmptyTrashUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
@@ -1083,6 +1099,7 @@ export const emptyTrash = async ( options?: RequestInit): Promise<EmptyTrash200>
|
||||
|
||||
|
||||
|
||||
|
||||
export const getEmptyTrashMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof emptyTrash>>, TError,void, TContext> => {
|
||||
@@ -1139,7 +1156,7 @@ export const getListToolRatingsUrl = (id: number,) => {
|
||||
/**
|
||||
* @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),
|
||||
{
|
||||
@@ -1176,7 +1193,7 @@ const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listToolRatings>>, TError, TData> & { queryKey: QueryKey }
|
||||
return { queryKey, queryFn, enabled: id !== null && id !== undefined, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listToolRatings>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type ListToolRatingsQueryResult = NonNullable<Awaited<ReturnType<typeof listToolRatings>>>
|
||||
@@ -1196,7 +1213,7 @@ export function useListToolRatings<TData = Awaited<ReturnType<typeof listToolRat
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1217,21 +1234,21 @@ export const getCreateRatingUrl = (id: number,) => {
|
||||
* @summary Submit a rating for a tool
|
||||
*/
|
||||
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),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
ratingInput,)
|
||||
body: JSON.stringify(ratingInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof createRating>>, TError,{id: number;data: BodyType<RatingInput>}, TContext> => {
|
||||
@@ -1288,7 +1305,7 @@ export const getGetAnalyticsSummaryUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
@@ -1345,7 +1362,7 @@ export function useGetAnalyticsSummary<TData = Awaited<ReturnType<typeof getAnal
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1360,7 +1377,7 @@ export const getGetTopToolsUrl = (params?: GetTopToolsParams,) => {
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1372,7 +1389,7 @@ export const getGetTopToolsUrl = (params?: GetTopToolsParams,) => {
|
||||
/**
|
||||
* @summary Top-rated tools
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -1429,7 +1446,7 @@ export function useGetTopTools<TData = Awaited<ReturnType<typeof getTopTools>>,
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1449,7 +1466,7 @@ export const getGetAnalyticsByCategoryUrl = () => {
|
||||
/**
|
||||
* @summary Rating statistics grouped by category
|
||||
*/
|
||||
export const getAnalyticsByCategory = async ( options?: RequestInit): Promise<CategoryStats[]> => {
|
||||
export const getAnalyticsByCategory = async ( options?: Parameters<typeof customFetch>[1]): Promise<CategoryStats[]> => {
|
||||
|
||||
return customFetch<CategoryStats[]>(getGetAnalyticsByCategoryUrl(),
|
||||
{
|
||||
@@ -1506,7 +1523,7 @@ export function useGetAnalyticsByCategory<TData = Awaited<ReturnType<typeof getA
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1521,7 +1538,7 @@ export const getGetRatingDistributionUrl = (params?: GetRatingDistributionParams
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1533,7 +1550,7 @@ export const getGetRatingDistributionUrl = (params?: GetRatingDistributionParams
|
||||
/**
|
||||
* @summary Distribution of rating scores across the platform
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -1590,7 +1607,7 @@ export function useGetRatingDistribution<TData = Awaited<ReturnType<typeof getRa
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1610,7 +1627,7 @@ export const getListCategoriesUrl = () => {
|
||||
/**
|
||||
* @summary List all distinct tool categories
|
||||
*/
|
||||
export const listCategories = async ( options?: RequestInit): Promise<string[]> => {
|
||||
export const listCategories = async ( options?: Parameters<typeof customFetch>[1]): Promise<string[]> => {
|
||||
|
||||
return customFetch<string[]>(getListCategoriesUrl(),
|
||||
{
|
||||
@@ -1667,7 +1684,7 @@ export function useListCategories<TData = Awaited<ReturnType<typeof listCategori
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1687,7 +1704,7 @@ export const getListAllFeaturesUrl = () => {
|
||||
/**
|
||||
* @summary List all distinct feature strings across all tools
|
||||
*/
|
||||
export const listAllFeatures = async ( options?: RequestInit): Promise<string[]> => {
|
||||
export const listAllFeatures = async ( options?: Parameters<typeof customFetch>[1]): Promise<string[]> => {
|
||||
|
||||
return customFetch<string[]>(getListAllFeaturesUrl(),
|
||||
{
|
||||
@@ -1744,7 +1761,7 @@ export function useListAllFeatures<TData = Awaited<ReturnType<typeof listAllFeat
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1764,7 +1781,7 @@ export const getListAllTagsUrl = () => {
|
||||
/**
|
||||
* @summary List all distinct tag strings across all tools
|
||||
*/
|
||||
export const listAllTags = async ( options?: RequestInit): Promise<string[]> => {
|
||||
export const listAllTags = async ( options?: Parameters<typeof customFetch>[1]): Promise<string[]> => {
|
||||
|
||||
return customFetch<string[]>(getListAllTagsUrl(),
|
||||
{
|
||||
@@ -1821,7 +1838,7 @@ export function useListAllTags<TData = Awaited<ReturnType<typeof listAllTags>>,
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1841,7 +1858,7 @@ export const getGetAuthModeUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
@@ -1898,7 +1915,7 @@ export function useGetAuthMode<TData = Awaited<ReturnType<typeof getAuthMode>>,
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1918,7 +1935,7 @@ export const getGetCsrfTokenUrl = () => {
|
||||
/**
|
||||
* @summary Get a CSRF token for state-changing requests
|
||||
*/
|
||||
export const getCsrfToken = async ( options?: RequestInit): Promise<CsrfToken> => {
|
||||
export const getCsrfToken = async ( options?: Parameters<typeof customFetch>[1]): Promise<CsrfToken> => {
|
||||
|
||||
return customFetch<CsrfToken>(getGetCsrfTokenUrl(),
|
||||
{
|
||||
@@ -1975,7 +1992,7 @@ export function useGetCsrfToken<TData = Awaited<ReturnType<typeof getCsrfToken>>
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1995,21 +2012,21 @@ export const getLocalLoginUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
localLoginInput,)
|
||||
body: JSON.stringify(localLoginInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getLocalLoginMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -2066,7 +2083,7 @@ export const getGetMeUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
@@ -2123,7 +2140,7 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -2143,21 +2160,21 @@ export const getChangeMyPasswordUrl = () => {
|
||||
/**
|
||||
* @summary Change own password (local users only)
|
||||
*/
|
||||
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(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
changePasswordInput,)
|
||||
body: JSON.stringify(changePasswordInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getChangeMyPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -2214,7 +2231,7 @@ export const getGetPasswordRedirectUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
@@ -2271,7 +2288,7 @@ export function useGetPasswordRedirect<TData = Awaited<ReturnType<typeof getPass
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -2291,7 +2308,7 @@ export const getGetMePreferencesUrl = () => {
|
||||
/**
|
||||
* @summary Get current user's browse preferences
|
||||
*/
|
||||
export const getMePreferences = async ( options?: RequestInit): Promise<UserPreferences> => {
|
||||
export const getMePreferences = async ( options?: Parameters<typeof customFetch>[1]): Promise<UserPreferences> => {
|
||||
|
||||
return customFetch<UserPreferences>(getGetMePreferencesUrl(),
|
||||
{
|
||||
@@ -2348,7 +2365,7 @@ export function useGetMePreferences<TData = Awaited<ReturnType<typeof getMePrefe
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -2368,21 +2385,21 @@ export const getUpdateMePreferencesUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
...options,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
userPreferences,)
|
||||
body: JSON.stringify(userPreferences)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getUpdateMePreferencesMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -2439,7 +2456,7 @@ export const getGetMeWatchlistUrl = () => {
|
||||
/**
|
||||
* @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(),
|
||||
{
|
||||
@@ -2496,7 +2513,7 @@ export function useGetMeWatchlist<TData = Awaited<ReturnType<typeof getMeWatchli
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -2516,7 +2533,7 @@ export const getListUsersUrl = () => {
|
||||
/**
|
||||
* @summary List all local users (admin only)
|
||||
*/
|
||||
export const listUsers = async ( options?: RequestInit): Promise<User[]> => {
|
||||
export const listUsers = async ( options?: Parameters<typeof customFetch>[1]): Promise<User[]> => {
|
||||
|
||||
return customFetch<User[]>(getListUsersUrl(),
|
||||
{
|
||||
@@ -2573,7 +2590,7 @@ export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TErr
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -2593,21 +2610,21 @@ export const getCreateUserUrl = () => {
|
||||
/**
|
||||
* @summary Create a new local user (admin only)
|
||||
*/
|
||||
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(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
userCreateInput,)
|
||||
body: JSON.stringify(userCreateInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getCreateUserMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
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> => {
|
||||
@@ -2665,21 +2682,21 @@ export const getUpdateUserUrl = (id: number,) => {
|
||||
* @summary Update user role (admin only)
|
||||
*/
|
||||
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),
|
||||
{
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
userRoleUpdate,)
|
||||
body: JSON.stringify(userRoleUpdate)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext> => {
|
||||
@@ -2736,7 +2753,7 @@ export const getDeleteUserUrl = (id: number,) => {
|
||||
/**
|
||||
* @summary Delete a user (admin only)
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -2750,6 +2767,7 @@ export const deleteUser = async (id: number, options?: RequestInit): Promise<voi
|
||||
|
||||
|
||||
|
||||
|
||||
export const getDeleteUserMutationOptions = <TError = ErrorType<unknown>,
|
||||
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> => {
|
||||
@@ -2807,21 +2825,21 @@ export const getSetUserPasswordUrl = (id: number,) => {
|
||||
* @summary Set/reset a user's password (admin only, local users only)
|
||||
*/
|
||||
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),
|
||||
{
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
setPasswordInput,)
|
||||
body: JSON.stringify(setPasswordInput)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext> => {
|
||||
@@ -2873,7 +2891,7 @@ export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||
normalizedParams.append(key, value === null ? 'null' : String(value))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2885,7 +2903,7 @@ export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
|
||||
/**
|
||||
* @summary List audit log entries (admin only)
|
||||
*/
|
||||
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),
|
||||
{
|
||||
@@ -2942,7 +2960,7 @@ export function useListAuditLogs<TData = Awaited<ReturnType<typeof listAuditLogs
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"orval": "^8.9.1"
|
||||
"orval": "8.23.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Generated by orval v8.23.0 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
@@ -25,7 +25,7 @@ export const GetVersionResponse = zod.object({
|
||||
"version": zod.string(),
|
||||
"commitSha": 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({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -58,7 +58,7 @@ export const ListToolsResponseItem = zod.object({
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
"updatedAt": zod.coerce.date(),
|
||||
"ratingCount": zod.number(),
|
||||
"ratingCount": zod.int(),
|
||||
"avgUsefulness": zod.number().nullable(),
|
||||
"avgUsability": zod.number().nullable(),
|
||||
"avgCombined": zod.number().nullable()
|
||||
@@ -84,16 +84,8 @@ export const CreateToolBody = zod.object({
|
||||
"tags": zod.array(zod.string()).optional()
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @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(),
|
||||
export const CreateToolResponse = zod.object({
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -104,7 +96,31 @@ export const ListCompareToolsResponseItem = zod.object({
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": 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(),
|
||||
"avgUsability": 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
|
||||
*/
|
||||
export const GetToolRatingHistoryParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
"id": zod.coerce.number().int()
|
||||
})
|
||||
|
||||
export const GetToolRatingHistoryResponseItem = zod.object({
|
||||
@@ -132,11 +148,11 @@ export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryRespon
|
||||
* @summary Get a tool by ID
|
||||
*/
|
||||
export const GetToolParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
"id": zod.coerce.number().int()
|
||||
})
|
||||
|
||||
export const GetToolResponse = zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -147,7 +163,7 @@ export const GetToolResponse = zod.object({
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
"updatedAt": zod.coerce.date(),
|
||||
"ratingCount": zod.number(),
|
||||
"ratingCount": zod.int(),
|
||||
"avgUsefulness": zod.number().nullable(),
|
||||
"avgUsability": zod.number().nullable(),
|
||||
"avgCombined": zod.number().nullable()
|
||||
@@ -158,7 +174,7 @@ export const GetToolResponse = zod.object({
|
||||
* @summary Update a tool
|
||||
*/
|
||||
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({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -195,9 +211,11 @@ export const UpdateToolResponse = zod.object({
|
||||
* @summary Delete a tool
|
||||
*/
|
||||
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
|
||||
@@ -207,7 +225,7 @@ export const ListTrashedToolsQueryParams = zod.object({
|
||||
})
|
||||
|
||||
export const ListTrashedToolsResponseItem = zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -232,11 +250,11 @@ export const trashToolsBodyIdsMax = 500;
|
||||
|
||||
|
||||
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({
|
||||
"trashed": zod.number().optional()
|
||||
"trashed": zod.int().optional()
|
||||
})
|
||||
|
||||
|
||||
@@ -248,9 +266,11 @@ export const deleteTrashedToolsBodyIdsMax = 500;
|
||||
|
||||
|
||||
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
|
||||
@@ -260,11 +280,11 @@ export const restoreToolsBodyIdsMax = 500;
|
||||
|
||||
|
||||
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({
|
||||
"restored": zod.number().optional()
|
||||
"restored": zod.int().optional()
|
||||
})
|
||||
|
||||
|
||||
@@ -272,7 +292,7 @@ export const RestoreToolsResponse = zod.object({
|
||||
* @summary Permanently delete all trashed tools (admin)
|
||||
*/
|
||||
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
|
||||
*/
|
||||
export const ListToolRatingsParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
"id": zod.coerce.number().int()
|
||||
})
|
||||
|
||||
export const listToolRatingsResponseUsefulnessMax = 5;
|
||||
@@ -290,10 +310,10 @@ export const listToolRatingsResponseUsabilityMax = 5;
|
||||
|
||||
|
||||
export const ListToolRatingsResponseItem = zod.object({
|
||||
"id": zod.number(),
|
||||
"toolId": zod.number(),
|
||||
"usefulness": zod.number().min(1).max(listToolRatingsResponseUsefulnessMax),
|
||||
"usability": zod.number().min(1).max(listToolRatingsResponseUsabilityMax),
|
||||
"id": zod.int(),
|
||||
"toolId": zod.int(),
|
||||
"usefulness": zod.int().min(1).max(listToolRatingsResponseUsefulnessMax),
|
||||
"usability": zod.int().min(1).max(listToolRatingsResponseUsabilityMax),
|
||||
"comment": zod.string().nullish(),
|
||||
"reviewerName": zod.string().nullish(),
|
||||
"createdAt": zod.coerce.date()
|
||||
@@ -305,7 +325,7 @@ export const ListToolRatingsResponse = zod.array(ListToolRatingsResponseItem)
|
||||
* @summary Submit a rating for a tool
|
||||
*/
|
||||
export const CreateRatingParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
"id": zod.coerce.number().int()
|
||||
})
|
||||
|
||||
export const createRatingBodyUsefulnessMax = 5;
|
||||
@@ -315,25 +335,41 @@ export const createRatingBodyUsabilityMax = 5;
|
||||
|
||||
|
||||
export const CreateRatingBody = zod.object({
|
||||
"usefulness": zod.number().min(1).max(createRatingBodyUsefulnessMax),
|
||||
"usability": zod.number().min(1).max(createRatingBodyUsabilityMax),
|
||||
"usefulness": zod.int().min(1).max(createRatingBodyUsefulnessMax),
|
||||
"usability": zod.int().min(1).max(createRatingBodyUsabilityMax),
|
||||
"comment": 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
|
||||
*/
|
||||
export const GetAnalyticsSummaryResponse = zod.object({
|
||||
"totalTools": zod.number(),
|
||||
"totalRatings": zod.number(),
|
||||
"totalTools": zod.int(),
|
||||
"totalRatings": zod.int(),
|
||||
"avgUsefulness": zod.number().nullable(),
|
||||
"avgUsability": zod.number().nullable(),
|
||||
"avgCombined": zod.number().nullable(),
|
||||
"categoriesCount": zod.number(),
|
||||
"categoriesCount": zod.int(),
|
||||
"mostRatedTool": zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -344,7 +380,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
"updatedAt": zod.coerce.date(),
|
||||
"ratingCount": zod.number(),
|
||||
"ratingCount": zod.int(),
|
||||
"avgUsefulness": zod.number().nullable(),
|
||||
"avgUsability": zod.number().nullable(),
|
||||
"avgCombined": zod.number().nullable()
|
||||
@@ -356,13 +392,13 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
||||
* @summary Top-rated tools
|
||||
*/
|
||||
export const GetTopToolsQueryParams = zod.object({
|
||||
"limit": zod.coerce.number().optional(),
|
||||
"limit": zod.coerce.number().int().optional(),
|
||||
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
|
||||
})
|
||||
|
||||
export const GetTopToolsResponseItem = zod.object({
|
||||
"tool": zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -373,13 +409,13 @@ export const GetTopToolsResponseItem = zod.object({
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
"updatedAt": zod.coerce.date(),
|
||||
"ratingCount": zod.number(),
|
||||
"ratingCount": zod.int(),
|
||||
"avgUsefulness": zod.number().nullable(),
|
||||
"avgUsability": zod.number().nullable(),
|
||||
"avgCombined": zod.number().nullable()
|
||||
}),
|
||||
"score": zod.number(),
|
||||
"ratingCount": zod.number()
|
||||
"ratingCount": zod.int()
|
||||
})
|
||||
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
||||
|
||||
@@ -389,8 +425,8 @@ export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
||||
*/
|
||||
export const GetAnalyticsByCategoryResponseItem = zod.object({
|
||||
"category": zod.string(),
|
||||
"toolCount": zod.number(),
|
||||
"totalRatings": zod.number(),
|
||||
"toolCount": zod.int(),
|
||||
"totalRatings": zod.int(),
|
||||
"avgUsefulness": 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
|
||||
*/
|
||||
export const GetRatingDistributionQueryParams = zod.object({
|
||||
"toolId": zod.coerce.number().optional()
|
||||
"toolId": zod.coerce.number().int().optional()
|
||||
})
|
||||
|
||||
export const GetRatingDistributionResponse = zod.object({
|
||||
"usefulness": zod.array(zod.object({
|
||||
"score": zod.number(),
|
||||
"count": zod.number()
|
||||
"score": zod.int(),
|
||||
"count": zod.int()
|
||||
})),
|
||||
"usability": zod.array(zod.object({
|
||||
"score": zod.number(),
|
||||
"count": zod.number()
|
||||
"score": zod.int(),
|
||||
"count": zod.int()
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -501,6 +537,8 @@ export const ChangeMyPasswordBody = zod.object({
|
||||
"newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin)
|
||||
})
|
||||
|
||||
export const ChangeMyPasswordResponse = zod.void()
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get redirect URL for managing credentials in the identity provider
|
||||
@@ -516,7 +554,7 @@ export const GetPasswordRedirectResponse = zod.object({
|
||||
export const GetMePreferencesResponse = zod.object({
|
||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||
"watchlist": zod.array(zod.number()).optional()
|
||||
"watchlist": zod.array(zod.int()).optional()
|
||||
})
|
||||
|
||||
|
||||
@@ -526,13 +564,13 @@ export const GetMePreferencesResponse = zod.object({
|
||||
export const UpdateMePreferencesBody = zod.object({
|
||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||
"watchlist": zod.array(zod.number()).optional()
|
||||
"watchlist": zod.array(zod.int()).optional()
|
||||
})
|
||||
|
||||
export const UpdateMePreferencesResponse = zod.object({
|
||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||
"watchlist": zod.array(zod.number()).optional()
|
||||
"watchlist": zod.array(zod.int()).optional()
|
||||
})
|
||||
|
||||
|
||||
@@ -540,7 +578,7 @@ export const UpdateMePreferencesResponse = zod.object({
|
||||
* @summary Get current user's watchlist tools (premium)
|
||||
*/
|
||||
export const GetMeWatchlistResponseItem = zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string(),
|
||||
"category": zod.string(),
|
||||
@@ -551,7 +589,7 @@ export const GetMeWatchlistResponseItem = zod.object({
|
||||
"tags": zod.array(zod.string()).optional(),
|
||||
"createdAt": zod.coerce.date(),
|
||||
"updatedAt": zod.coerce.date(),
|
||||
"ratingCount": zod.number(),
|
||||
"ratingCount": zod.int(),
|
||||
"avgUsefulness": zod.number().nullable(),
|
||||
"avgUsability": zod.number().nullable(),
|
||||
"avgCombined": zod.number().nullable()
|
||||
@@ -565,7 +603,7 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
|
||||
export const listUsersResponseAuthProviderDefault = `local`;
|
||||
|
||||
export const ListUsersResponseItem = zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"username": zod.string(),
|
||||
"email": zod.string().nullish(),
|
||||
"role": zod.enum(['admin', 'user']),
|
||||
@@ -593,12 +631,24 @@ export const CreateUserBody = zod.object({
|
||||
"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)
|
||||
*/
|
||||
export const UpdateUserParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
"id": zod.coerce.number().int()
|
||||
})
|
||||
|
||||
export const UpdateUserBody = zod.object({
|
||||
@@ -609,7 +659,7 @@ export const UpdateUserBody = zod.object({
|
||||
export const updateUserResponseAuthProviderDefault = `local`;
|
||||
|
||||
export const UpdateUserResponse = zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"username": zod.string(),
|
||||
"email": zod.string().nullish(),
|
||||
"role": zod.enum(['admin', 'user']),
|
||||
@@ -623,15 +673,17 @@ export const UpdateUserResponse = zod.object({
|
||||
* @summary Delete a user (admin only)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
export const SetUserPasswordParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
"id": zod.coerce.number().int()
|
||||
})
|
||||
|
||||
export const setUserPasswordBodyPasswordMin = 6;
|
||||
@@ -642,20 +694,22 @@ export const SetUserPasswordBody = zod.object({
|
||||
"password": zod.string().min(setUserPasswordBodyPasswordMin)
|
||||
})
|
||||
|
||||
export const SetUserPasswordResponse = zod.void()
|
||||
|
||||
|
||||
/**
|
||||
* @summary List audit log entries (admin only)
|
||||
*/
|
||||
export const ListAuditLogsQueryParams = zod.object({
|
||||
"entityType": zod.coerce.string().optional(),
|
||||
"entityId": zod.coerce.number().optional(),
|
||||
"limit": zod.coerce.number().optional()
|
||||
"entityId": zod.coerce.number().int().optional(),
|
||||
"limit": zod.coerce.number().int().optional()
|
||||
})
|
||||
|
||||
export const ListAuditLogsResponseItem = zod.object({
|
||||
"id": zod.number(),
|
||||
"id": zod.int(),
|
||||
"entityType": zod.string(),
|
||||
"entityId": zod.number().nullish(),
|
||||
"entityId": zod.int().nullish(),
|
||||
"action": zod.string(),
|
||||
"userId": 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* 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.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
|
||||
+4
-4
@@ -13,13 +13,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"pg": "^8.20.0",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"pg": "8.22.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/pg": "^8.20.0",
|
||||
"drizzle-kit": "^0.31.10"
|
||||
"@types/pg": "8.20.3",
|
||||
"drizzle-kit": "0.31.10"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-17
@@ -10,24 +10,13 @@
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
"current",
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"current",
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
"prettier": "3.9.6",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-darwin-arm64": "^4.62.4",
|
||||
"@tailwindcss/oxide-darwin-arm64": "^4.3.3",
|
||||
"lightningcss-darwin-arm64": "^1.33.0"
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.62.4",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"lightningcss-darwin-arm64": "1.33.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2552
-1929
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user