Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca980fce09 | |||
| 8f2fd89847 | |||
| d47db6d386 | |||
| 70e72dac71 | |||
| 63aa14914f | |||
| 3de59ccaaf | |||
| a65e2eb92c | |||
| b886246808 | |||
| 3bb4598d04 | |||
| d23990e322 | |||
| 33cf261777 | |||
| 56f1edd613 | |||
| fdf2d741a1 | |||
| 9ceb11e7f9 | |||
| 6c92b6358d | |||
| 520f917723 | |||
| d1dd77bc1e | |||
| 0be45b6513 | |||
| 4b1274e34a | |||
| 2f66fff993 | |||
| bcae59626f | |||
| f851305d78 | |||
| 743b177c89 | |||
| 68a81ec775 | |||
| 63f0bdbab6 | |||
| 4648614556 | |||
| 53f01acb0c |
@@ -14,6 +14,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
|
- name: Security audit (fails on any prod or high/critical finding)
|
||||||
|
run: |
|
||||||
|
pnpm audit --prod
|
||||||
|
pnpm audit --audit-level high
|
||||||
|
|
||||||
- name: Install Docker CLI
|
- name: Install Docker CLI
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
@@ -30,34 +38,35 @@ jobs:
|
|||||||
FULL_SHA=$(git rev-parse HEAD)
|
FULL_SHA=$(git rev-parse HEAD)
|
||||||
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
||||||
DATE_STAMP=$(date -u +"%Y%m%d")
|
DATE_STAMP=$(date -u +"%Y%m%d")
|
||||||
|
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
||||||
|
TAGS="-t ${IMAGE}:latest"
|
||||||
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
VERSION="${{ gitea.ref_name }}"
|
VERSION="${{ gitea.ref_name }}"
|
||||||
VERSION_TAG="${{ gitea.ref_name }}"
|
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
|
||||||
else
|
|
||||||
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
|
||||||
VERSION_TAG="nightly-${DATE_STAMP}"
|
|
||||||
fi
|
fi
|
||||||
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
|
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
--build-arg COMMIT_SHA="$FULL_SHA" \
|
--build-arg COMMIT_SHA="$FULL_SHA" \
|
||||||
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
$TAGS .
|
$TAGS .
|
||||||
docker push "${IMAGE}:sha-${SHA}"
|
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
docker push "${IMAGE}:${VERSION_TAG}"
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
|
docker push "${IMAGE}:${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update k8s manifest in admin/apps
|
- name: Update k8s manifest in admin/apps
|
||||||
|
if: gitea.ref_type == 'tag'
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
NEWTAG="${{ gitea.ref_name }}"
|
||||||
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||||
cd /tmp/apps
|
cd /tmp/apps
|
||||||
cd apps/system/toolrate/overlays/k3s
|
cd apps/system/toolrate/overlays/k3s
|
||||||
sed -i "s|newTag: .*|newTag: sha-${SHA}|" kustomization.yaml
|
sed -i "s|newTag: .*|newTag: ${NEWTAG}|" kustomization.yaml
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
git config user.email "actions@git.kubebase.de"
|
git config user.email "actions@git.kubebase.de"
|
||||||
git add -A
|
git add -A
|
||||||
git diff --cached --quiet || git commit -m "chore: update toolrate image to sha-${SHA}"
|
git diff --cached --quiet || git commit -m "chore: update toolrate image to ${NEWTAG}"
|
||||||
git push
|
git push
|
||||||
|
|||||||
@@ -47,3 +47,6 @@ Thumbs.db
|
|||||||
# Replit
|
# Replit
|
||||||
.cache/
|
.cache/
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
|
||||||
|
/artifacts/toolrate/public/docs/
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
auto-install-peers=false
|
auto-install-peers=false
|
||||||
strict-peer-dependencies=false
|
strict-peer-dependencies=false
|
||||||
|
save-exact=true
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
modules = ["nodejs-24", "postgresql-16"]
|
|
||||||
|
|
||||||
[deployment]
|
|
||||||
router = "application"
|
|
||||||
deploymentTarget = "autoscale"
|
|
||||||
|
|
||||||
[deployment.postBuild]
|
|
||||||
args = ["pnpm", "store", "prune"]
|
|
||||||
env = { "CI" = "true" }
|
|
||||||
|
|
||||||
[workflows]
|
|
||||||
runButton = "Project"
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
stack = "PNPM_WORKSPACE"
|
|
||||||
expertMode = true
|
|
||||||
|
|
||||||
[postMerge]
|
|
||||||
path = "scripts/post-merge.sh"
|
|
||||||
timeoutMs = 20000
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8080
|
|
||||||
externalPort = 80
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8081
|
|
||||||
externalPort = 8081
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 26015
|
|
||||||
externalPort = 3000
|
|
||||||
|
|
||||||
[nix]
|
|
||||||
channel = "stable-25_05"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# The format of this file is identical to `.dockerignore`.
|
|
||||||
# It is used to reduce the size of deployed images to make the process of publishing faster.
|
|
||||||
|
|
||||||
# No need to store the pnpm store twice.
|
|
||||||
.local
|
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
FROM node:24-alpine AS builder
|
FROM node:24.18.1-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.26.1
|
RUN npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
# 1. Alle Projektdateien in den Container bringen
|
# 1. Alle Projektdateien in den Container bringen
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -17,10 +17,10 @@ ENV PORT=8080
|
|||||||
|
|
||||||
RUN pnpm -r --if-present run build
|
RUN pnpm -r --if-present run build
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24.18.1-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.26.1
|
RUN npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY lib/db/package.json lib/db/
|
COPY lib/db/package.json lib/db/
|
||||||
|
|||||||
+38
-35
@@ -2,10 +2,30 @@
|
|||||||
|
|
||||||
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
||||||
|
|
||||||
## Run & Operate
|
## Stack
|
||||||
|
|
||||||
- `pnpm --filter @workspace/api-server run dev` — run the API server (port 8080)
|
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
||||||
- `pnpm --filter @workspace/toolrate run dev` — run the frontend (port 26015)
|
- API: Express 5 + express-session + connect-pg-simple
|
||||||
|
- DB: PostgreSQL + Drizzle ORM
|
||||||
|
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
||||||
|
- API codegen: Orval (from OpenAPI spec)
|
||||||
|
- Build: esbuild (CJS bundle)
|
||||||
|
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `lib/api-spec` — OpenAPI contract (source of truth)
|
||||||
|
- `lib/api-zod` — generated Zod schemas from the spec
|
||||||
|
- `lib/api-client-react` — generated React Query hooks
|
||||||
|
- `lib/db` — Drizzle schema + DB access
|
||||||
|
- `artifacts/api-server` — Express backend
|
||||||
|
- `artifacts/toolrate` — React frontend
|
||||||
|
- `artifacts/mockup-sandbox` — component preview
|
||||||
|
|
||||||
|
## Run & develop
|
||||||
|
|
||||||
|
- `pnpm --filter @workspace/api-server run dev` — run the API server
|
||||||
|
- `pnpm --filter @workspace/toolrate run dev` — run the frontend
|
||||||
- `pnpm run typecheck` — full typecheck across all packages
|
- `pnpm run typecheck` — full typecheck across all packages
|
||||||
- `pnpm run build` — typecheck + build all packages
|
- `pnpm run build` — typecheck + build all packages
|
||||||
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
||||||
@@ -13,29 +33,16 @@ Community platform for listing and rating developer/productivity tools by useful
|
|||||||
|
|
||||||
## Required env vars
|
## Required env vars
|
||||||
|
|
||||||
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
|
- `DATABASE_URL` — Postgres connection string
|
||||||
- `SESSION_SECRET` — Session signing secret (already set)
|
- `SESSION_SECRET` — session signing secret
|
||||||
|
- `VOTER_SECRET` — secret for voter submissions
|
||||||
|
- `LOCAL_ADMIN_USERNAME` / `LOCAL_ADMIN_PASSWORD` — local admin login
|
||||||
|
|
||||||
## Keycloak (optional)
|
## Auth
|
||||||
|
|
||||||
Set these to enable login:
|
- Session-based auth (not JWT), sessions stored in Postgres via connect-pg-simple.
|
||||||
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
|
- Write operations (create/update/delete tools, submit ratings) require auth; reads are public.
|
||||||
- `KEYCLOAK_REALM` — realm name
|
- Login via local admin user (`LOCAL_ADMIN_*`). Keycloak/OIDC support exists as an optional legacy path.
|
||||||
- `KEYCLOAK_CLIENT_ID` — client ID
|
|
||||||
- `KEYCLOAK_CLIENT_SECRET` — client secret
|
|
||||||
- `APP_URL` — public base URL for OAuth callback (optional, auto-detected if omitted)
|
|
||||||
|
|
||||||
Without these, the app runs in read-only mode (browsing and viewing ratings works, submitting tools/ratings requires login).
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
|
||||||
- API: Express 5 + openid-client (Keycloak OIDC) + express-session + connect-pg-simple
|
|
||||||
- DB: PostgreSQL + Drizzle ORM
|
|
||||||
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
|
||||||
- API codegen: Orval (from OpenAPI spec)
|
|
||||||
- Build: esbuild (CJS bundle)
|
|
||||||
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
||||||
- `artifacts/api-server/src/routes/` — Express route handlers
|
- `artifacts/api-server/src/routes/` — Express route handlers
|
||||||
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
||||||
- `artifacts/api-server/src/routes/auth.ts` — Keycloak OIDC login/callback/logout/me
|
- `artifacts/api-server/src/routes/auth.ts` — login/logout/me
|
||||||
- `artifacts/toolrate/src/pages/` — frontend pages
|
- `artifacts/toolrate/src/pages/` — frontend pages
|
||||||
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
||||||
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
||||||
@@ -54,9 +61,9 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
|
|
||||||
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
||||||
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
||||||
- Write operations (create/update/delete tools, submit ratings) require auth. Reads are public.
|
- Write operations (create/update/delete tools, submit votes) require auth. Reads are public.
|
||||||
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
||||||
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
|
- Grafana can consume `/api/analytics/*` endpoints directly via the JSON datasource plugin.
|
||||||
|
|
||||||
## Product
|
## Product
|
||||||
|
|
||||||
@@ -66,16 +73,12 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
||||||
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
||||||
|
|
||||||
## User preferences
|
## Deployment
|
||||||
|
|
||||||
_Populate as you build — explicit user instructions worth remembering across sessions._
|
- Container image: `git.kubebase.de/admin/tool-evaluator:<tag>` (pushed by CI)
|
||||||
|
- Deployed to k3s via the `apps` repo (kustomize), served at `https://toolr.kubebase.de`
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
||||||
- Auth routes use PKCE — the code_verifier is stored in the session, not in-memory state.
|
- Auth uses PKCE — the `code_verifier` is stored in the session, not in-memory state.
|
||||||
- `trust proxy: 1` is set on Express so that session cookies work correctly behind Replit's reverse proxy.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
|
|
||||||
- See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
kind = "api"
|
|
||||||
previewPath = "/api" # TODO - should be excluded from preview in the first place
|
|
||||||
title = "API Server"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "3B4_FFSkEVBkAeYMFRJ2e"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8080
|
|
||||||
name = "API Server"
|
|
||||||
paths = ["/api"]
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/api-server run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
|
|
||||||
[services.production.build]
|
|
||||||
args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"]
|
|
||||||
|
|
||||||
[services.production.build.env]
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.run]
|
|
||||||
# we don't run through pnpm to make startup faster in production
|
|
||||||
args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"]
|
|
||||||
|
|
||||||
[services.production.run.env]
|
|
||||||
PORT = "8080"
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.health.startup]
|
|
||||||
path = "/api/healthz"
|
|
||||||
@@ -12,29 +12,30 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"zod": "catalog:",
|
"connect-pg-simple": "10.0.0",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"cookie-parser": "1.4.7",
|
||||||
"cookie-parser": "^1.4.7",
|
"cors": "2.8.6",
|
||||||
"cors": "^2.8.6",
|
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"express": "^5.2.1",
|
"express": "5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-rate-limit": "8.6.1",
|
||||||
"openid-client": "^5.7.1",
|
"express-session": "1.19.0",
|
||||||
"pino": "^9.14.0",
|
"openid-client": "6.8.4",
|
||||||
"pino-http": "^10.5.0"
|
"pino": "10.3.1",
|
||||||
|
"pino-http": "11.0.0",
|
||||||
|
"yaml": "catalog:",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/connect-pg-simple": "7.0.3",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cors": "2.8.19",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/express": "5.0.6",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/express-session": "^1.19.0",
|
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-pino": "^2.3.3",
|
"esbuild-plugin-pino": "2.3.3",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"thread-stream": "3.1.0"
|
"thread-stream": "4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
export const loginRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 10,
|
||||||
|
standardHeaders: "draft-7",
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: "Too many login attempts, please try again later." },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passwordRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 5,
|
||||||
|
standardHeaders: "draft-7",
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req: Request): string => String(req.session.user?.sub ?? req.ip ?? "unknown"),
|
||||||
|
message: { error: "Too many password attempts, please try again later." },
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
|
||||||
|
|
||||||
|
export function getCsrfToken(req: Request): string {
|
||||||
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = crypto.randomBytes(24).toString("hex");
|
||||||
|
}
|
||||||
|
return req.session.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csrfProtection(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provided = req.headers["x-csrf-token"];
|
||||||
|
const token = getCsrfToken(req);
|
||||||
|
if (typeof provided === "string" && provided && provided === token) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).json({ error: "CSRF token missing or invalid" });
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
|
|||||||
|
|
||||||
const TIER_FEATURES: Record<string, string[]> = {
|
const TIER_FEATURES: Record<string, string[]> = {
|
||||||
free: ["browse", "rate", "search"],
|
free: ["browse", "rate", "search"],
|
||||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"],
|
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "tool-import"],
|
||||||
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"],
|
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "tool-import", "sso", "audit-export", "api-access"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middleware/auth";
|
import { requireAdmin } from "../middleware/auth";
|
||||||
|
import { requireFeature } from "../middleware/feature";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { CreateToolBody } from "@workspace/api-zod";
|
||||||
|
|
||||||
const EvaluateBody = z.object({
|
const EvaluateBody = z.object({
|
||||||
toolId: z.coerce.number().int().positive(),
|
toolId: z.coerce.number().int().positive(),
|
||||||
@@ -219,4 +222,184 @@ router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promis
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ToolImportBody = z.object({
|
||||||
|
format: z.enum(["auto", "csv", "json", "yaml"]).default("auto"),
|
||||||
|
delimiter: z.enum(["auto", "comma", "semicolon", "tab"]).default("auto"),
|
||||||
|
data: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOOL_FIELDS = ["name", "description", "category", "websiteUrl", "iconUrl", "features", "tags"] as const;
|
||||||
|
|
||||||
|
function splitList(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => String(v).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(/[|;,]/)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTool(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of TOOL_FIELDS) {
|
||||||
|
if (key in raw) out[key] = raw[key];
|
||||||
|
}
|
||||||
|
if ("features" in out) out.features = splitList(out.features);
|
||||||
|
if ("tags" in out) out.tags = splitList(out.tags);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(data: string, delimiter: string): Record<string, unknown>[] {
|
||||||
|
let sep = ",";
|
||||||
|
if (delimiter === "semicolon") sep = ";";
|
||||||
|
else if (delimiter === "tab") sep = "\t";
|
||||||
|
else if (delimiter === "auto") {
|
||||||
|
const firstLine = data.split(/\r?\n/)[0] ?? "";
|
||||||
|
const candidates = [",", ";", "\t"];
|
||||||
|
const counts = candidates.map((c) => (firstLine.match(new RegExp(`\\${c === "\t" ? "\\t" : c}`, "g")) ?? []).length);
|
||||||
|
const max = Math.max(...counts);
|
||||||
|
if (max > 0) sep = candidates[counts.indexOf(max)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = data.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
const header = parseCsvLine(lines[0], sep);
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = parseCsvLine(lines[i], sep);
|
||||||
|
const row: Record<string, unknown> = {};
|
||||||
|
header.forEach((h, idx) => {
|
||||||
|
const key = h.trim();
|
||||||
|
if (key) row[key] = cells[idx] ?? "";
|
||||||
|
});
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line: string, sep: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let cur = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
cur += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === sep) {
|
||||||
|
cells.push(cur);
|
||||||
|
cur = "";
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(cur);
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/admin/tools/import",
|
||||||
|
requireAdmin,
|
||||||
|
requireFeature("tool-import"),
|
||||||
|
async (req, res): Promise<void> => {
|
||||||
|
const parsed = ToolImportBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { format, delimiter, data } = parsed.data;
|
||||||
|
|
||||||
|
let rawRows: Record<string, unknown>[] = [];
|
||||||
|
let effectiveFormat = format;
|
||||||
|
if (format === "auto") {
|
||||||
|
const trimmed = data.trimStart();
|
||||||
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
|
effectiveFormat = "json";
|
||||||
|
} else if (trimmed.includes(":") && !trimmed.startsWith("\"")) {
|
||||||
|
effectiveFormat = "yaml";
|
||||||
|
} else {
|
||||||
|
effectiveFormat = "csv";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (effectiveFormat === "json") {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (Array.isArray(json)) {
|
||||||
|
rawRows = json.filter((r) => r && typeof r === "object") as Record<string, unknown>[];
|
||||||
|
} else if (json && typeof json === "object") {
|
||||||
|
rawRows = [json as Record<string, unknown>];
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: "JSON data must be an object or an array of objects" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (effectiveFormat === "yaml") {
|
||||||
|
const yaml = parseYaml(data);
|
||||||
|
if (Array.isArray(yaml)) {
|
||||||
|
rawRows = yaml.filter((r) => r && typeof r === "object") as Record<string, unknown>[];
|
||||||
|
} else if (yaml && typeof yaml === "object") {
|
||||||
|
rawRows = [yaml as Record<string, unknown>];
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: "YAML data must be an object or a list of objects" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawRows = parseCsv(data, delimiter);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: `Failed to parse ${effectiveFormat} data: ${(err as Error).message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: { row: number; error: string }[] = [];
|
||||||
|
const validRows: { name: string; description: string; category: string; websiteUrl?: string; iconUrl?: string; features: string[]; tags: string[]; createdBy: string }[] = [];
|
||||||
|
const user = req.session.user!;
|
||||||
|
const createdBy = user.preferred_username || user.name || user.sub;
|
||||||
|
|
||||||
|
rawRows.forEach((raw, idx) => {
|
||||||
|
const normalized = normalizeTool(raw);
|
||||||
|
const check = CreateToolBody.safeParse(normalized);
|
||||||
|
if (!check.success) {
|
||||||
|
errors.push({ row: idx + 1, error: check.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validRows.push({
|
||||||
|
name: check.data.name,
|
||||||
|
description: check.data.description,
|
||||||
|
category: check.data.category,
|
||||||
|
websiteUrl: check.data.websiteUrl,
|
||||||
|
iconUrl: check.data.iconUrl,
|
||||||
|
features: check.data.features ?? [],
|
||||||
|
tags: check.data.tags ?? [],
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
if (validRows.length > 0) {
|
||||||
|
const inserted = await db.insert(toolsTable).values(validRows).returning({ id: toolsTable.id, name: toolsTable.name, category: toolsTable.category });
|
||||||
|
imported = inserted.length;
|
||||||
|
for (const tool of inserted) {
|
||||||
|
await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category, source: "import" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ imported, total: rawRows.length, errors });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
import { Router, type IRouter, type Request } from "express";
|
import { Router, type IRouter, type Request } from "express";
|
||||||
import { Issuer, generators, type Client } from "openid-client";
|
import {
|
||||||
|
discovery,
|
||||||
|
randomPKCECodeVerifier,
|
||||||
|
calculatePKCECodeChallenge,
|
||||||
|
randomState,
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
authorizationCodeGrant,
|
||||||
|
fetchUserInfo,
|
||||||
|
buildEndSessionUrl,
|
||||||
|
skipSubjectCheck,
|
||||||
|
type Configuration,
|
||||||
|
} from "openid-client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { eq, and, inArray, isNull } from "drizzle-orm";
|
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { getEntitlements, requireFeature } from "../middleware/feature";
|
import { getEntitlements, requireFeature } from "../middleware/feature";
|
||||||
|
import { getCsrfToken } from "../middleware/csrf";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
let cachedClient: Client | null = null;
|
let cachedConfig: Configuration | null = null;
|
||||||
|
|
||||||
function isOidcConfigured(): boolean {
|
function isOidcConfigured(): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
@@ -36,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClient(): Promise<Client | null> {
|
async function getClient(): Promise<Configuration | null> {
|
||||||
if (cachedClient) return cachedClient;
|
if (cachedConfig) return cachedConfig;
|
||||||
|
|
||||||
const keycloakUrl = process.env.KEYCLOAK_URL;
|
const keycloakUrl = process.env.KEYCLOAK_URL;
|
||||||
const realm = process.env.KEYCLOAK_REALM;
|
const realm = process.env.KEYCLOAK_REALM;
|
||||||
@@ -49,14 +63,9 @@ async function getClient(): Promise<Client | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
|
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
|
||||||
const issuer = await Issuer.discover(issuerUrl);
|
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
|
||||||
cachedClient = new issuer.Client({
|
return cachedConfig;
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
response_types: ["code"],
|
|
||||||
});
|
|
||||||
return cachedClient;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Failed to discover Keycloak issuer");
|
logger.error({ err }, "Failed to discover Keycloak issuer");
|
||||||
return null;
|
return null;
|
||||||
@@ -107,7 +116,11 @@ router.get("/auth/mode", (_req, res): void => {
|
|||||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
router.get("/auth/csrf", (req, res): void => {
|
||||||
|
res.json({ token: getCsrfToken(req) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
|
||||||
if (isOidcConfigured()) {
|
if (isOidcConfigured()) {
|
||||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||||
return;
|
return;
|
||||||
@@ -169,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeVerifier = generators.codeVerifier();
|
const codeVerifier = randomPKCECodeVerifier();
|
||||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||||
const state = generators.state();
|
const state = randomState();
|
||||||
|
|
||||||
req.session.codeVerifier = codeVerifier;
|
req.session.codeVerifier = codeVerifier;
|
||||||
req.session.oidcState = state;
|
req.session.oidcState = state;
|
||||||
@@ -180,7 +193,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||||
const url = client.authorizationUrl({
|
const url = buildAuthorizationUrl(client, {
|
||||||
scope: "openid email profile",
|
scope: "openid email profile",
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
@@ -188,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.redirect(url);
|
res.redirect(url.href);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/auth/callback", async (req, res): Promise<void> => {
|
router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||||
@@ -214,13 +227,13 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
|||||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = client.callbackParams(req);
|
const tokens = await authorizationCodeGrant(
|
||||||
const tokenSet = await client.callback(redirectUri, params, {
|
client,
|
||||||
code_verifier: codeVerifier,
|
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
|
||||||
state,
|
{ pkceCodeVerifier: codeVerifier, expectedState: state },
|
||||||
});
|
);
|
||||||
|
|
||||||
const userinfo = await client.userinfo(tokenSet.access_token!);
|
const userinfo = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
|
||||||
const dbUser = await upsertUserFromOidc(userinfo);
|
const dbUser = await upsertUserFromOidc(userinfo);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -252,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
|
|||||||
req.session.destroy(() => {});
|
req.session.destroy(() => {});
|
||||||
|
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
if (client && client.issuer.metadata.end_session_endpoint) {
|
if (client && client.serverMetadata().end_session_endpoint) {
|
||||||
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
|
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
|
||||||
res.redirect(logoutUrl);
|
res.redirect(logoutUrl.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +292,58 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/auth/password-redirect", async (req, res): Promise<void> => {
|
||||||
|
const client = await getClient();
|
||||||
|
if (!client) {
|
||||||
|
res.json({ url: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const realm = client.serverMetadata().issuer ?? "";
|
||||||
|
res.json({ url: `${realm}/account/password` });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChangePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dbUser.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
res.status(400).json({ error: "New password must differ from current password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
|
||||||
|
res.status(400).json({ error: "Current password is incorrect" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
|
||||||
|
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
||||||
|
|
||||||
async function resolveDbUser(u: SessionUser) {
|
async function resolveDbUser(u: SessionUser) {
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import usersRouter from "./users";
|
|||||||
import auditRouter from "./audit";
|
import auditRouter from "./audit";
|
||||||
import costsRouter from "./costs";
|
import costsRouter from "./costs";
|
||||||
import adminRouter from "./admin";
|
import adminRouter from "./admin";
|
||||||
|
import { csrfProtection } from "../middleware/csrf";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
router.use(csrfProtection);
|
||||||
|
|
||||||
router.use(authRouter);
|
router.use(authRouter);
|
||||||
router.use(healthRouter);
|
router.use(healthRouter);
|
||||||
router.use(toolsRouter);
|
router.use(toolsRouter);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
@@ -57,21 +57,23 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
||||||
|
|
||||||
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
|
||||||
if (category) {
|
if (category) {
|
||||||
query = query.where(eq(toolsTable.category, category));
|
conditions.push(eq(toolsTable.category, category));
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
||||||
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
||||||
}
|
}
|
||||||
if (tagList.length > 0) {
|
if (tagList.length > 0) {
|
||||||
query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
||||||
}
|
}
|
||||||
if (featureList.length > 0) {
|
if (featureList.length > 0) {
|
||||||
query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const query = db.select().from(toolsTable).where(and(...conditions));
|
||||||
|
|
||||||
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
||||||
|
|
||||||
const toolIds = tools.map((t) => t.id);
|
const toolIds = tools.map((t) => t.id);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { db, usersTable } from "@workspace/db";
|
import { db, usersTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middleware/auth";
|
import { requireAdmin } from "../middleware/auth";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
|
|||||||
tier: Tier.optional(),
|
tier: Tier.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SetPasswordSchema = z.object({
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = SetPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
tier: usersTable.tier,
|
tier: usersTable.tier,
|
||||||
|
authProvider: usersTable.authProvider,
|
||||||
createdAt: usersTable.createdAt,
|
createdAt: usersTable.createdAt,
|
||||||
})
|
})
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(usersTable)
|
.insert(usersTable)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
+1
@@ -14,5 +14,6 @@ declare module "express-session" {
|
|||||||
codeVerifier?: string;
|
codeVerifier?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
oidcState?: string;
|
oidcState?: string;
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
kind = "design"
|
|
||||||
previewPath = "/__mockup"
|
|
||||||
title = "Canvas"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "XegfDyZt7HqfW2Bb8Ghoy"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8081
|
|
||||||
name = "Component Preview Server"
|
|
||||||
paths = ["/__mockup"]
|
|
||||||
|
|
||||||
[services.env]
|
|
||||||
PORT = "8081"
|
|
||||||
BASE_PATH = "/__mockup"
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/mockup-sandbox run dev"
|
|
||||||
@@ -10,34 +10,34 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "2.1.15",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
"@radix-ui/react-menubar": "1.1.24",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "1.1.21",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "1.2.23",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "1.2.16",
|
||||||
"@replit/vite-plugin-cartographer": "catalog:",
|
"@replit/vite-plugin-cartographer": "catalog:",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
@@ -45,29 +45,30 @@
|
|||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "5.0.0",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"fast-glob": "^3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "10.0.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "7.84.0",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-is": "19.2.8",
|
||||||
"recharts": "^2.15.4",
|
"react-resizable-panels": "4.12.2",
|
||||||
"sonner": "^2.0.7",
|
"recharts": "3.10.1",
|
||||||
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "1.1.2",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as RechartsPrimitive from "recharts"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
RechartsPrimitive.TooltipContentProps &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean
|
||||||
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
)
|
)
|
||||||
ChartLegendContent.displayName = "ChartLegend"
|
ChartLegendContent.displayName = "ChartLegend"
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
function getPayloadConfigFromPayload(
|
function getPayloadConfigFromPayload(
|
||||||
config: ChartConfig,
|
config: ChartConfig,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
kind = "web"
|
|
||||||
previewPath = "/"
|
|
||||||
title = "ToolRate — Tool-Bewertungsplattform"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "artifacts/toolrate"
|
|
||||||
router = "path"
|
|
||||||
|
|
||||||
[[integratedSkills]]
|
|
||||||
name = "react-vite"
|
|
||||||
version = "1.0.0"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
name = "web"
|
|
||||||
paths = [ "/" ]
|
|
||||||
localPort = 26015
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/toolrate run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
build = [ "pnpm", "--filter", "@workspace/toolrate", "run", "build" ]
|
|
||||||
publicDir = "artifacts/toolrate/dist/public"
|
|
||||||
serve = "static"
|
|
||||||
|
|
||||||
[[services.production.rewrites]]
|
|
||||||
from = "/*"
|
|
||||||
to = "/index.html"
|
|
||||||
|
|
||||||
[services.env]
|
|
||||||
PORT = "26015"
|
|
||||||
BASE_PATH = "/"
|
|
||||||
@@ -4,44 +4,44 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --config vite.config.ts --host 0.0.0.0",
|
"dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
|
||||||
"build": "vite build --config vite.config.ts",
|
"build": "node ../../scripts/src/generate-docs.mjs && vite build --config vite.config.ts",
|
||||||
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.4",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.1.5",
|
"@radix-ui/react-checkbox": "1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.4",
|
"@radix-ui/react-collapsible": "1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.7",
|
"@radix-ui/react-context-menu": "2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.7",
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.7",
|
"@radix-ui/react-hover-card": "1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.3",
|
"@radix-ui/react-label": "2.1.15",
|
||||||
"@radix-ui/react-menubar": "^1.1.7",
|
"@radix-ui/react-menubar": "1.1.24",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.7",
|
"@radix-ui/react-popover": "1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.3",
|
"@radix-ui/react-progress": "1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.2.4",
|
"@radix-ui/react-radio-group": "1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
"@radix-ui/react-select": "2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.3",
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.2.4",
|
"@radix-ui/react-slider": "1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.1.4",
|
"@radix-ui/react-switch": "1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.4",
|
"@radix-ui/react-tabs": "1.1.21",
|
||||||
"@radix-ui/react-toast": "^1.2.7",
|
"@radix-ui/react-toast": "1.2.23",
|
||||||
"@radix-ui/react-toggle": "^1.1.3",
|
"@radix-ui/react-toggle": "1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "1.2.16",
|
||||||
"@replit/vite-plugin-cartographer": "catalog:",
|
"@replit/vite-plugin-cartographer": "catalog:",
|
||||||
"@replit/vite-plugin-dev-banner": "catalog:",
|
"@replit/vite-plugin-dev-banner": "catalog:",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-virtual": "catalog:",
|
"@tanstack/react-virtual": "catalog:",
|
||||||
@@ -52,29 +52,32 @@
|
|||||||
"@workspace/api-client-react": "workspace:*",
|
"@workspace/api-client-react": "workspace:*",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"dompurify": "catalog:",
|
||||||
|
"embla-carousel-react": "8.6.0",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "26.3.6",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"marked": "catalog:",
|
||||||
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "10.0.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "7.84.0",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "17.0.11",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "5.7.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-is": "19.2.8",
|
||||||
"recharts": "^2.15.2",
|
"react-resizable-panels": "4.12.2",
|
||||||
"sonner": "^2.0.7",
|
"recharts": "3.10.1",
|
||||||
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "1.1.2",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Switch, Route, Router as WouterRouter } from "wouter";
|
import { Switch, Route, Router as WouterRouter } from "wouter";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
|
import { loadCsrfToken } from "@/lib/csrf";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ import Trash from "@/pages/trash";
|
|||||||
import Compare from "@/pages/compare";
|
import Compare from "@/pages/compare";
|
||||||
import Watchlist from "@/pages/watchlist";
|
import Watchlist from "@/pages/watchlist";
|
||||||
import Login from "@/pages/login";
|
import Login from "@/pages/login";
|
||||||
|
import Docs from "@/pages/docs";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -44,12 +47,16 @@ function Router() {
|
|||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
<Route path="/admin/redundancy" component={Redundancy} />
|
<Route path="/admin/redundancy" component={Redundancy} />
|
||||||
<Route path="/trash" component={Trash} />
|
<Route path="/trash" component={Trash} />
|
||||||
|
<Route path="/docs/*?" component={Docs} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCsrfToken();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import {
|
import {
|
||||||
useListTools,
|
useListTools,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
getListAllTagsQueryKey,
|
getListAllTagsQueryKey,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
type ToolWithStats,
|
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -23,11 +23,13 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
|
import { ToolImportDialog } from "@/components/tool-import-dialog";
|
||||||
|
import { Search, Trash2, ExternalLink, Pencil, Star, Upload } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
export function AdminToolsTab() {
|
export function AdminToolsTab() {
|
||||||
const { isAdmin, isLoading: authLoading } = useAuth();
|
const { t } = useTranslation();
|
||||||
|
const { isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ export function AdminToolsTab() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [confirmTrash, setConfirmTrash] = useState(false);
|
const [confirmTrash, setConfirmTrash] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setSearch(searchInput), 300);
|
const t = setTimeout(() => setSearch(searchInput), 300);
|
||||||
@@ -82,20 +85,20 @@ export function AdminToolsTab() {
|
|||||||
{ data: { ids } },
|
{ data: { ids } },
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
toast({ title: "Tools moved to trash", description: `${res.trashed ?? ids.length} tool(s) moved to trash.` });
|
toast({ title: t("adminTools.toastMoved"), description: t("adminTools.toastMovedSub", { count: res.trashed ?? ids.length }) });
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setConfirmTrash(false);
|
setConfirmTrash(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to move to trash", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("adminTools.toastMoveFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authLoading && !isAdmin) {
|
if (!authLoading && !isAdmin) {
|
||||||
return <p className="text-sm text-muted-foreground py-8 text-center">Admin access required.</p>;
|
return <p className="text-sm text-muted-foreground py-8 text-center">{t("adminTools.accessRequired")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIds = [...selected];
|
const selectedIds = [...selected];
|
||||||
@@ -104,17 +107,27 @@ export function AdminToolsTab() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<CardTitle>All Tools</CardTitle>
|
<CardTitle>{t("adminTools.allTools")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`}
|
{selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{hasFeature("tool-import") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setImportOpen(true)}
|
||||||
|
data-testid="button-tool-import"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" /> {t("adminTools.importButton")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-8 w-56"
|
className="pl-8 w-56"
|
||||||
placeholder="Search tools…"
|
placeholder={t("adminTools.searchPlaceholder")}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -125,7 +138,7 @@ export function AdminToolsTab() {
|
|||||||
disabled={selectedIds.length === 0 || trash.isPending}
|
disabled={selectedIds.length === 0 || trash.isPending}
|
||||||
onClick={() => setConfirmTrash(true)}
|
onClick={() => setConfirmTrash(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" /> Move to trash ({selectedIds.length})
|
<Trash2 className="w-4 h-4 mr-2" /> {t("adminTools.moveToTrash", { count: selectedIds.length })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -135,7 +148,7 @@ export function AdminToolsTab() {
|
|||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
) : allTools.length === 0 ? (
|
) : allTools.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-8 text-center">{search ? "No tools match your search." : "No tools yet."}</p>
|
<p className="text-sm text-muted-foreground py-8 text-center">{search ? t("adminTools.noMatch") : t("adminTools.noToolsYet")}</p>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -144,58 +157,58 @@ export function AdminToolsTab() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.size === allTools.length && allTools.length > 0}
|
checked={selected.size === allTools.length && allTools.length > 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label={t("adminTools.selectAll")}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t("adminTools.name")}</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>{t("adminTools.category")}</TableHead>
|
||||||
<TableHead>Rating</TableHead>
|
<TableHead>{t("adminTools.rating")}</TableHead>
|
||||||
<TableHead>Created by</TableHead>
|
<TableHead>{t("adminTools.createdBy")}</TableHead>
|
||||||
<TableHead>Created at</TableHead>
|
<TableHead>{t("adminTools.createdAt")}</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">{t("adminTools.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{allTools.map((t: ToolWithStats) => (
|
{allTools.map((tool) => (
|
||||||
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
|
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.has(t.id)}
|
checked={selected.has(tool.id)}
|
||||||
onCheckedChange={() => toggle(t.id)}
|
onCheckedChange={() => toggle(tool.id)}
|
||||||
aria-label={`Select ${t.name}`}
|
aria-label={t("adminTools.selectName", { name: tool.name })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{t.category}</Badge>
|
<Badge variant="outline">{tool.category}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{t.ratingCount > 0 ? (
|
{tool.ratingCount > 0 ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
|
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
|
||||||
{t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount})
|
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "—"} ({tool.ratingCount})
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{t.createdBy ?? "—"}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{tool.createdBy ?? "—"}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{format(new Date(t.createdAt), "dd.MM.yyyy")}
|
{format(new Date(tool.createdAt), "dd.MM.yyyy")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||||
<Link href={`/tools/${t.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
|
<Link href={`/tools/${tool.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||||
<Link href={`/tools/${t.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
|
<Link href={`/tools/${tool.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => handleTrash([t.id])}
|
onClick={() => handleTrash([tool.id])}
|
||||||
disabled={trash.isPending}
|
disabled={trash.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
@@ -212,22 +225,31 @@ export function AdminToolsTab() {
|
|||||||
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
|
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
|
<AlertDialogTitle>{t("adminTools.confirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.
|
{t("adminTools.confirmSub")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => handleTrash(selectedIds)}
|
onClick={() => handleTrash(selectedIds)}
|
||||||
>
|
>
|
||||||
Move to trash
|
{t("adminTools.moveToTrashAction")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<ToolImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onImported={() => {
|
||||||
|
toast({ title: t("adminTools.toastImportDone") });
|
||||||
|
invalidate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,7 +24,8 @@ interface CategoryComboboxProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
|
export function CategoryCombobox({ value, onChange, placeholder }: CategoryComboboxProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState(value);
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
(c) => c.toLowerCase() === inputValue.toLowerCase()
|
(c) => c.toLowerCase() === inputValue.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolvedPlaceholder = placeholder ?? t("category.placeholder");
|
||||||
|
|
||||||
function select(val: string) {
|
function select(val: string) {
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setInputValue(val);
|
setInputValue(val);
|
||||||
@@ -66,7 +70,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
data-testid="button-category-combobox"
|
data-testid="button-category-combobox"
|
||||||
>
|
>
|
||||||
<span className={cn(!value && "text-muted-foreground")}>
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
{value || placeholder}
|
{value || resolvedPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -74,7 +78,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or enter new category..."
|
placeholder={t("category.searchPlaceholder")}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setInputValue(v);
|
setInputValue(v);
|
||||||
@@ -84,10 +88,10 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{filtered.length === 0 && !showCreateOption && (
|
{filtered.length === 0 && !showCreateOption && (
|
||||||
<CommandEmpty>No categories found.</CommandEmpty>
|
<CommandEmpty>{t("category.noCategories")}</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
{filtered.length > 0 && (
|
{filtered.length > 0 && (
|
||||||
<CommandGroup heading="Known categories">
|
<CommandGroup heading={t("category.knownCategories")}>
|
||||||
{filtered.map((cat) => (
|
{filtered.map((cat) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={cat}
|
key={cat}
|
||||||
@@ -104,13 +108,13 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
{showCreateOption && (
|
{showCreateOption && (
|
||||||
<CommandGroup heading="Create new">
|
<CommandGroup heading={t("category.createNew")}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onSelect={() => select(inputValue.trim())}
|
onSelect={() => select(inputValue.trim())}
|
||||||
data-testid="item-category-create-new"
|
data-testid="item-category-create-new"
|
||||||
>
|
>
|
||||||
<span className="text-primary font-medium">+ Create</span>
|
<span className="text-primary font-medium">{t("category.create")}</span>
|
||||||
<span className="ml-2 text-muted-foreground">“{inputValue.trim()}”</span>
|
<span className="ml-2 text-muted-foreground">“{inputValue.trim()}”</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Maximize2, Minimize2 } from "lucide-react";
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type Density = "cozy" | "compact";
|
export type Density = "cozy" | "compact";
|
||||||
|
|
||||||
@@ -10,8 +11,13 @@ export function DensityToggle({
|
|||||||
value: Density;
|
value: Density;
|
||||||
onValueChange: (d: Density) => void;
|
onValueChange: (d: Density) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const densityLabel = value === "compact" ? t("common.densityCompact") : t("common.densityComfortable");
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2" title={`Density: ${value === "compact" ? "Compact" : "Comfortable"}`}>
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={t("common.densityTitle", { density: densityLabel })}
|
||||||
|
>
|
||||||
<Maximize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
<Maximize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
<Slider
|
<Slider
|
||||||
className="w-16"
|
className="w-16"
|
||||||
@@ -20,7 +26,7 @@ export function DensityToggle({
|
|||||||
step={1}
|
step={1}
|
||||||
value={[value === "compact" ? 1 : 0]}
|
value={[value === "compact" ? 1 : 0]}
|
||||||
onValueChange={([v]) => onValueChange(v === 1 ? "compact" : "cozy")}
|
onValueChange={([v]) => onValueChange(v === 1 ? "compact" : "cozy")}
|
||||||
aria-label="List density"
|
aria-label={t("common.listDensity")}
|
||||||
/>
|
/>
|
||||||
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function FilterPopover({
|
|||||||
step={0.5}
|
step={0.5}
|
||||||
value={[minRating ?? 0]}
|
value={[minRating ?? 0]}
|
||||||
onValueChange={([v]) => onChange({ minRating: v })}
|
onValueChange={([v]) => onChange({ minRating: v })}
|
||||||
aria-label="Minimum rating"
|
aria-label={t("filter.minRating")}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
|
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
|
||||||
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
|
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export function GuideHelp({
|
||||||
|
guide,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
guide: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-1.5 text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
href={`/docs/handbook/${guide}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={`${t("common.help")}: ${label}`}
|
||||||
|
data-testid={`guide-${guide}`}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
{t("common.help")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label} — {t("common.guideTooltip")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Trash2, Bookmark, Search } from "lucide-react";
|
import { LayoutDashboard, Wrench, BarChart3, FileText, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth();
|
const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
|
||||||
const { data: version } = useGetVersion({
|
const { data: version } = useGetVersion({
|
||||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||||
});
|
});
|
||||||
@@ -38,10 +38,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
const mainLinks = [
|
const mainLinks = [
|
||||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||||
{ href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
|
|
||||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
{ href: "/docs", label: t("nav.docs"), icon: FileText },
|
||||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function PasswordInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<"input">, "type">) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
<Input type={visible ? "text" : "password"} className="pr-9" {...props} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className="absolute right-0 top-0 flex h-9 w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={visible ? "Hide password" : "Show password"}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
|
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import { useTheme, type Theme } from "@/hooks/use-theme";
|
import { useTheme, type Theme } from "@/hooks/use-theme";
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
const isDark = resolvedTheme === "dark";
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
@@ -25,8 +27,8 @@ export function ThemeToggle() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9"
|
className="h-9 w-9"
|
||||||
onClick={quickToggle}
|
onClick={quickToggle}
|
||||||
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
title={isDark ? t("theme.switchLight") : t("theme.switchDark")}
|
||||||
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
aria-label={isDark ? t("theme.switchLight") : t("theme.switchDark")}
|
||||||
data-testid="button-theme-quick-toggle"
|
data-testid="button-theme-quick-toggle"
|
||||||
>
|
>
|
||||||
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
||||||
@@ -37,27 +39,27 @@ export function ThemeToggle() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9"
|
className="h-9 w-9"
|
||||||
aria-label="Choose theme"
|
aria-label={t("theme.chooseTitle")}
|
||||||
title="Choose theme"
|
title={t("theme.chooseTitle")}
|
||||||
data-testid="button-theme-menu"
|
data-testid="button-theme-menu"
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" sideOffset={6}>
|
<DropdownMenuContent align="end" sideOffset={6}>
|
||||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("theme.title")}</DropdownMenuLabel>
|
||||||
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
||||||
<DropdownMenuRadioItem value="light">
|
<DropdownMenuRadioItem value="light">
|
||||||
<Sun className="w-4 h-4 mr-2" />
|
<Sun className="w-4 h-4 mr-2" />
|
||||||
Light
|
{t("theme.light")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="dark">
|
<DropdownMenuRadioItem value="dark">
|
||||||
<Moon className="w-4 h-4 mr-2" />
|
<Moon className="w-4 h-4 mr-2" />
|
||||||
Dark
|
{t("theme.dark")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="system">
|
<DropdownMenuRadioItem value="system">
|
||||||
<Monitor className="w-4 h-4 mr-2" />
|
<Monitor className="w-4 h-4 mr-2" />
|
||||||
System
|
{t("theme.system")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
type Format = "auto" | "csv" | "json" | "yaml";
|
||||||
|
type Delimiter = "auto" | "comma" | "semicolon" | "tab";
|
||||||
|
|
||||||
|
export function ToolImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onImported: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const importTools = useImportTools();
|
||||||
|
const [format, setFormat] = useState<Format>("auto");
|
||||||
|
const [delimiter, setDelimiter] = useState<Delimiter>("auto");
|
||||||
|
const [data, setData] = useState("");
|
||||||
|
const [result, setResult] = useState<ToolImportResponse | null>(null);
|
||||||
|
|
||||||
|
function handleFile(file: File) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setData(String(reader.result ?? ""));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
importTools.mutate(
|
||||||
|
{ data: { format, delimiter, data } },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setResult(res);
|
||||||
|
if (res.imported > 0) onImported();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(open: boolean) {
|
||||||
|
if (!importTools.isPending) {
|
||||||
|
onOpenChange(open);
|
||||||
|
if (!open) {
|
||||||
|
setData("");
|
||||||
|
setResult(null);
|
||||||
|
setFormat("auto");
|
||||||
|
setDelimiter("auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = data.trim().length > 0 && !importTools.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{t("adminTools.importTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("adminTools.importSub")}</p>
|
||||||
|
|
||||||
|
<Tabs value={format} onValueChange={(v) => setFormat(v as Format)}>
|
||||||
|
<TabsList className="w-full grid grid-cols-4">
|
||||||
|
<TabsTrigger value="auto">{t("adminTools.importFormatAuto")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="csv">CSV</TabsTrigger>
|
||||||
|
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||||
|
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="auto" className="text-xs text-muted-foreground pt-2">
|
||||||
|
{t("adminTools.importFormatAutoSub")}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="csv" className="pt-2 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("adminTools.importDelimiter")}</Label>
|
||||||
|
<Select value={delimiter} onValueChange={(v) => setDelimiter(v as Delimiter)}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">{t("adminTools.importDelimiterAuto")}</SelectItem>
|
||||||
|
<SelectItem value="comma">{t("adminTools.importDelimiterComma")}</SelectItem>
|
||||||
|
<SelectItem value="semicolon">{t("adminTools.importDelimiterSemicolon")}</SelectItem>
|
||||||
|
<SelectItem value="tab">{t("adminTools.importDelimiterTab")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("adminTools.importCsvSub")}</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="json" className="pt-2 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("adminTools.importJsonSub")}</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="yaml" className="pt-2 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("adminTools.importYamlSub")}</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={() => document.getElementById("tool-import-file")?.click()}>
|
||||||
|
<FileUp className="w-4 h-4" />
|
||||||
|
{t("adminTools.importFile")}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
id="tool-import-file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json,.yaml,.yml,.txt,text/plain,application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleFile(f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={data}
|
||||||
|
onChange={(e) => setData(e.target.value)}
|
||||||
|
placeholder={t("adminTools.importTextareaPlaceholder")}
|
||||||
|
className="min-h-[220px] font-mono text-sm"
|
||||||
|
data-testid="input-tool-import-data"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{importTools.isError && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<span>{importTools.error?.data?.error ?? importTools.error?.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{t("adminTools.importDone", { count: result.imported, total: result.total })}</span>
|
||||||
|
</div>
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-500" />
|
||||||
|
{t("adminTools.importErrors", { count: result.errors.length })}
|
||||||
|
</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-md border bg-muted/40 divide-y divide-border">
|
||||||
|
{result.errors.map((err, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground shrink-0">
|
||||||
|
{t("adminTools.importErrorRow", { row: err.row })}:
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground break-words">{err.error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleClose(false)} disabled={importTools.isPending}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleImport} disabled={!canSubmit}>
|
||||||
|
{importTools.isPending ? t("adminTools.importing") : t("adminTools.importButton")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ToolWithStats } from "@workspace/api-client-react";
|
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -6,6 +7,7 @@ import { RatingStars } from "@/components/rating-stars";
|
|||||||
import { MiniBars } from "@/components/mini-bars";
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
|
||||||
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -22,7 +24,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-muted-foreground">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</span>
|
<span className="text-muted-foreground">{t("browse.reviewCount", { count: tool.ratingCount })}</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
||||||
</span>
|
</span>
|
||||||
@@ -47,8 +49,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
href={`/tools/${tool.id}`}
|
href={`/tools/${tool.id}`}
|
||||||
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
|
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
|
||||||
>
|
>
|
||||||
View details
|
{t("common.viewDetails")}
|
||||||
</Link>
|
</Link> </div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
RechartsPrimitive.TooltipContentProps &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean
|
||||||
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bookmark, LogIn, LogOut, Trash2, KeyRound } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function initials(name?: string | null): string {
|
function initials(name?: string | null): string {
|
||||||
@@ -32,6 +39,17 @@ export function UserMenu() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const changePassword = useChangeMyPassword();
|
||||||
|
const { data: passwordRedirect } = useGetPasswordRedirect({
|
||||||
|
query: { queryKey: getGetPasswordRedirectQueryKey(), enabled: isAuthenticated && !!user && !user.isLocal },
|
||||||
|
});
|
||||||
|
|
||||||
const showWatchlist = hasFeature("watchlist");
|
const showWatchlist = hasFeature("watchlist");
|
||||||
const showTrash = hasFeature("trash");
|
const showTrash = hasFeature("trash");
|
||||||
@@ -39,9 +57,9 @@ export function UserMenu() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:gap-0">
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:hidden" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,12 +69,12 @@ export function UserMenu() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-2"
|
className="w-full gap-2 group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center"
|
||||||
onClick={() => login(location)}
|
onClick={() => login(location)}
|
||||||
data-testid="button-login"
|
data-testid="button-login"
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
{t("auth.signIn")}
|
<span className="group-data-[collapsible=icon]:hidden">{t("auth.signIn")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,12 +85,13 @@ export function UserMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<>
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-3 px-3 py-2 h-auto"
|
className="w-full justify-start gap-3 px-3 py-2 h-auto group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1"
|
||||||
data-testid="button-user-menu"
|
data-testid="button-user-menu"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
@@ -80,7 +99,7 @@ export function UserMenu() {
|
|||||||
{initials(displayName)}
|
{initials(displayName)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="min-w-0 flex-1 text-left">
|
<span className="min-w-0 flex-1 text-left group-data-[collapsible=icon]:hidden">
|
||||||
<span className="block text-sm font-medium truncate">{displayName}</span>
|
<span className="block text-sm font-medium truncate">{displayName}</span>
|
||||||
<span className="block text-[11px] text-muted-foreground truncate">
|
<span className="block text-[11px] text-muted-foreground truncate">
|
||||||
{user.email ?? tier}
|
{user.email ?? tier}
|
||||||
@@ -122,6 +141,16 @@ export function UserMenu() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPwOpen(true);
|
||||||
|
}}
|
||||||
|
data-testid="button-change-password"
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-2 h-4 w-4" />
|
||||||
|
{t("auth.changePassword")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -132,5 +161,98 @@ export function UserMenu() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||||
|
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
||||||
|
<GuideHelp guide="konto" label={t("auth.changePassword")} />
|
||||||
|
</DialogHeader>
|
||||||
|
{!user?.isLocal ? (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.oidcPasswordHint")}</p>
|
||||||
|
{passwordRedirect?.url && (
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<a href={passwordRedirect.url} target="_blank" rel="noreferrer">
|
||||||
|
<KeyRound className="w-4 h-4 mr-2" />
|
||||||
|
{t("auth.manageInIdp")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.currentPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
data-testid="input-current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.newPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder={t("auth.passwordMinLength")}
|
||||||
|
data-testid="input-new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.confirmPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
data-testid="input-confirm-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPwOpen(false)}>{t("common.cancel")}</Button>
|
||||||
|
{user?.isLocal && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast({ title: t("auth.pwMismatch"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
toast({ title: t("auth.pwTooShort"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePassword.mutate(
|
||||||
|
{ data: { currentPassword, newPassword } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: t("auth.pwChanged") });
|
||||||
|
setPwOpen(false);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetPasswordRedirectQueryKey() });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const code = (err.data as { error?: string } | null)?.error;
|
||||||
|
if (code === "oidc") {
|
||||||
|
toast({ title: t("auth.oidcPasswordHint"), variant: "destructive" });
|
||||||
|
} else {
|
||||||
|
toast({ title: t("auth.pwChangeFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||||
|
data-testid="button-submit-change-password"
|
||||||
|
>
|
||||||
|
{changePassword.isPending ? "…" : t("auth.save")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { LayoutGrid, List, Rows3 } from "lucide-react";
|
import { LayoutGrid, List, Rows3 } from "lucide-react";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type ViewMode = "grid" | "table" | "rows";
|
export type ViewMode = "grid" | "table" | "rows";
|
||||||
|
|
||||||
const OPTIONS: { value: ViewMode; label: string; Icon: typeof LayoutGrid }[] = [
|
const OPTIONS: { value: ViewMode; labelKey: string; Icon: typeof LayoutGrid }[] = [
|
||||||
{ value: "grid", label: "Grid", Icon: LayoutGrid },
|
{ value: "grid", labelKey: "common.viewGrid", Icon: LayoutGrid },
|
||||||
{ value: "table", label: "Table", Icon: List },
|
{ value: "table", labelKey: "common.viewTable", Icon: List },
|
||||||
{ value: "rows", label: "Rows", Icon: Rows3 },
|
{ value: "rows", labelKey: "common.viewRows", Icon: Rows3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ViewToggle({
|
export function ViewToggle({
|
||||||
@@ -16,6 +17,7 @@ export function ViewToggle({
|
|||||||
value: ViewMode;
|
value: ViewMode;
|
||||||
onValueChange: (v: ViewMode) => void;
|
onValueChange: (v: ViewMode) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
@@ -25,10 +27,16 @@ export function ViewToggle({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="View mode"
|
aria-label={t("common.viewMode")}
|
||||||
>
|
>
|
||||||
{OPTIONS.map(({ value: v, label, Icon }) => (
|
{OPTIONS.map(({ value: v, labelKey, Icon }) => (
|
||||||
<ToggleGroupItem key={v} value={v} aria-label={label} title={label} data-testid={`view-${v}`}>
|
<ToggleGroupItem
|
||||||
|
key={v}
|
||||||
|
value={v}
|
||||||
|
aria-label={t(labelKey)}
|
||||||
|
title={t(labelKey)}
|
||||||
|
data-testid={`view-${v}`}
|
||||||
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"trash": "Papierkorb",
|
"trash": "Papierkorb",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"redundancy": "Redundanz",
|
"redundancy": "Redundanz",
|
||||||
|
"docs": "Hilfe",
|
||||||
"search": "Tools suchen…"
|
"search": "Tools suchen…"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -24,7 +25,18 @@
|
|||||||
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
||||||
"notAuthenticated": "Nicht angemeldet",
|
"notAuthenticated": "Nicht angemeldet",
|
||||||
"loginSubtitle": "Melde dich bei deinem Konto an",
|
"loginSubtitle": "Melde dich bei deinem Konto an",
|
||||||
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren"
|
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren",
|
||||||
|
"changePassword": "Passwort ändern",
|
||||||
|
"currentPassword": "Aktuelles Passwort",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"passwordMinLength": "mind. 6 Zeichen",
|
||||||
|
"pwMismatch": "Passwörter stimmen nicht überein",
|
||||||
|
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
||||||
|
"pwChanged": "Passwort geändert",
|
||||||
|
"pwChangeFailed": "Passwort konnte nicht geändert werden",
|
||||||
|
"oidcPasswordHint": "Ihr Passwort wird vom Identity-Provider (Keycloak) verwaltet.",
|
||||||
|
"manageInIdp": "In Keycloak verwalten"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -42,7 +54,18 @@
|
|||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"language": "Sprache"
|
"language": "Sprache",
|
||||||
|
"viewDetails": "Details ansehen",
|
||||||
|
"guideTooltip": "Anleitung in der Dokumentation",
|
||||||
|
"help": "Hilfe",
|
||||||
|
"viewMode": "Ansichtsmodus",
|
||||||
|
"viewGrid": "Raster",
|
||||||
|
"viewTable": "Tabelle",
|
||||||
|
"viewRows": "Zeilen",
|
||||||
|
"listDensity": "Listendichte",
|
||||||
|
"densityCompact": "Kompakt",
|
||||||
|
"densityComfortable": "Komfortabel",
|
||||||
|
"densityTitle": "Dichte: {{density}}"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Willkommen bei toolr",
|
"welcome": "Willkommen bei toolr",
|
||||||
@@ -69,6 +92,8 @@
|
|||||||
"category": "Kategorie",
|
"category": "Kategorie",
|
||||||
"rating": "Bewertung",
|
"rating": "Bewertung",
|
||||||
"reviews": "Bewertungen",
|
"reviews": "Bewertungen",
|
||||||
|
"reviewCount_one": "{{count}} Bewertung",
|
||||||
|
"reviewCount_other": "{{count}} Bewertungen",
|
||||||
"noToolsFound": "Keine Tools gefunden",
|
"noToolsFound": "Keine Tools gefunden",
|
||||||
"noToolsMatch": "Wir konnten keine Tools finden, die zu deinen Filtern passen. Versuche, die Suchkriterien anzupassen, oder füge ein neues Tool hinzu.",
|
"noToolsMatch": "Wir konnten keine Tools finden, die zu deinen Filtern passen. Versuche, die Suchkriterien anzupassen, oder füge ein neues Tool hinzu.",
|
||||||
"addTool": "Tool hinzufügen",
|
"addTool": "Tool hinzufügen",
|
||||||
@@ -92,7 +117,9 @@
|
|||||||
"features": "Funktionen",
|
"features": "Funktionen",
|
||||||
"minRating": "Mindestbewertung",
|
"minRating": "Mindestbewertung",
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
"clearFilters": "Filter zurücksetzen"
|
"clearFilters": "Filter zurücksetzen",
|
||||||
|
"removeFeature": "Funktion entfernen: {{feature}}",
|
||||||
|
"removeMinRating": "Mindestbewertung entfernen"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"ratingBreakdown": "Bewertungsaufschlüsselung",
|
"ratingBreakdown": "Bewertungsaufschlüsselung",
|
||||||
@@ -117,7 +144,70 @@
|
|||||||
"relatedTools": "Ähnliche Tools",
|
"relatedTools": "Ähnliche Tools",
|
||||||
"costs": "Kosten",
|
"costs": "Kosten",
|
||||||
"addCost": "Kosten hinzufügen",
|
"addCost": "Kosten hinzufügen",
|
||||||
"recentRatings": "Letzte Bewertungen"
|
"recentRatings": "Letzte Bewertungen",
|
||||||
|
"deleteConfirmTitle": "Dieses Tool löschen?",
|
||||||
|
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
|
||||||
|
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
|
||||||
|
"deleting": "Löschen…",
|
||||||
|
"deleteAction": "Löschen",
|
||||||
|
"invalidToolId": "Ungültige Tool-ID",
|
||||||
|
"backToBrowse": "Zurück zur Übersicht",
|
||||||
|
"keyFeatures": "Wichtige Funktionen",
|
||||||
|
"toolNotFound": "Tool nicht gefunden.",
|
||||||
|
"similarTools": "Ähnliche Tools",
|
||||||
|
"noSimilarTools": "Keine ähnlichen Tools gefunden.",
|
||||||
|
"linkTool": "Tool verknüpfen",
|
||||||
|
"linkSimilarTool": "Ähnliches Tool verknüpfen",
|
||||||
|
"linkSimilarToolSub": "Verknüpfe dieses Tool manuell mit einem anderen Tool.",
|
||||||
|
"toolId": "Tool-ID",
|
||||||
|
"toolIdPlaceholder": "Ziel-Tool-ID eingeben",
|
||||||
|
"relationType": "Beziehungstyp",
|
||||||
|
"relationSimilar": "Ähnlich",
|
||||||
|
"relationReplaces": "Ersetzt",
|
||||||
|
"relationSupersededBy": "Ersetzt durch",
|
||||||
|
"notesOptional": "Notizen (optional)",
|
||||||
|
"notesPlaceholder": "Warum sind diese Tools verwandt?",
|
||||||
|
"createLink": "Verknüpfung erstellen",
|
||||||
|
"noCostInfo": "Noch keine Kosteninformationen hinzugefügt.",
|
||||||
|
"costDialogSub": "Verwalte die Lizenzkosteninformationen für dieses Tool.",
|
||||||
|
"licenseType": "Lizenztyp",
|
||||||
|
"licenseFree": "Kostenlos",
|
||||||
|
"licenseSubscription": "Abonnement",
|
||||||
|
"licenseOneTime": "Einmalig",
|
||||||
|
"licenseUsageBased": "Nutzungsbasiert",
|
||||||
|
"billingPeriod": "Abrechnungszeitraum",
|
||||||
|
"billingMonthly": "Monatlich",
|
||||||
|
"billingQuarterly": "Quartalsweise",
|
||||||
|
"billingYearly": "Jährlich",
|
||||||
|
"costLabel": "Kosten",
|
||||||
|
"currency": "Währung",
|
||||||
|
"costNotes": "Notizen",
|
||||||
|
"costNotesPlaceholder": "Abrechnungsdetails, Vertragsinformationen…",
|
||||||
|
"commentOptional": "Kommentar (optional)",
|
||||||
|
"commentLabel": "Kommentar",
|
||||||
|
"commentPlaceholder": "Was denkst du über dieses Tool?",
|
||||||
|
"nameOptional": "Name (optional)",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"anonymousPlaceholder": "Anonym",
|
||||||
|
"anonymousEngineer": "Anonymer Ingenieur",
|
||||||
|
"beFirstToReview": "Sei der Erste, der seine Gedanken zu diesem Tool teilt.",
|
||||||
|
"shareExperience": "Teile deine Erfahrung mit {{name}}",
|
||||||
|
"score": "Punktestand",
|
||||||
|
"toastRelationCreated": "Beziehung erstellt",
|
||||||
|
"toastRelationFailed": "Beziehung konnte nicht erstellt werden",
|
||||||
|
"toastRelationDeleted": "Beziehung gelöscht",
|
||||||
|
"toastRelationDeleteFailed": "Beziehung konnte nicht gelöscht werden",
|
||||||
|
"toastCostUpdated": "Kosten aktualisiert",
|
||||||
|
"toastCostAdded": "Kosten hinzugefügt",
|
||||||
|
"toastCostFailed": "Kosten konnten nicht gespeichert werden",
|
||||||
|
"toastCostDeleted": "Kosten gelöscht",
|
||||||
|
"toastCostDeleteFailed": "Kosten konnten nicht gelöscht werden",
|
||||||
|
"toastRatingSubmitted": "Bewertung abgesendet",
|
||||||
|
"toastRatingThanks": "Danke für dein Feedback!",
|
||||||
|
"toastRatingFailed": "Bewertung konnte nicht abgesendet werden",
|
||||||
|
"toastToolDeleted": "Tool gelöscht",
|
||||||
|
"toastDeleteFailed": "Löschen fehlgeschlagen",
|
||||||
|
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten."
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"title": "Tools vergleichen",
|
"title": "Tools vergleichen",
|
||||||
@@ -160,17 +250,277 @@
|
|||||||
"deletePermanently": "Endgültig löschen",
|
"deletePermanently": "Endgültig löschen",
|
||||||
"emptyTrash": "Papierkorb leeren",
|
"emptyTrash": "Papierkorb leeren",
|
||||||
"deletedAt": "Gelöscht am",
|
"deletedAt": "Gelöscht am",
|
||||||
"deletedBy": "Gelöscht von"
|
"deletedBy": "Gelöscht von",
|
||||||
|
"name": "Name",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"selectName": "{{name}} auswählen",
|
||||||
|
"restoreAction": "Wiederherstellen",
|
||||||
|
"deleteAction": "Löschen",
|
||||||
|
"deleteConfirmTitle": "{{count}} Tool(s) endgültig löschen?",
|
||||||
|
"deleteConfirmSub": "Dies entfernt die ausgewählten Tools dauerhaft inklusive aller Bewertungen, Kosten und Beziehungen. Das kann nicht rückgängig gemacht werden.",
|
||||||
|
"emptyConfirmTitle": "Papierkorb leeren?",
|
||||||
|
"emptyConfirmSub": "Dies entfernt alle {{count}} Tool(s) im Papierkorb dauerhaft inklusive ihrer Bewertungen, Kosten und Beziehungen. Das kann nicht rückgängig gemacht werden.",
|
||||||
|
"toastRestored": "Tools wiederhergestellt",
|
||||||
|
"toastRestoredSub": "{{count}} Tool(s) wiederhergestellt.",
|
||||||
|
"toastRestoreFailed": "Wiederherstellen fehlgeschlagen",
|
||||||
|
"toastDeleted": "Tools gelöscht",
|
||||||
|
"toastDeletedSub": "{{count}} Tool(s) endgültig entfernt.",
|
||||||
|
"toastDeleteFailed": "Löschen fehlgeschlagen",
|
||||||
|
"toastEmptied": "Papierkorb geleert",
|
||||||
|
"toastEmptiedSub": "{{count}} Tool(s) endgültig entfernt.",
|
||||||
|
"toastEmptyFailed": "Papierkorb konnte nicht geleert werden",
|
||||||
|
"searchPlaceholder": "Tools suchen…",
|
||||||
|
"toolCount": "{{count}} Tool(s)"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"text": "Diese Seite existiert nicht.",
|
"text": "Diese Seite existiert nicht.",
|
||||||
"backHome": "Zurück zur Startseite"
|
"backHome": "Zurück zur Startseite"
|
||||||
},
|
},
|
||||||
|
"docs": {
|
||||||
|
"title": "Dokumentation",
|
||||||
|
"subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.",
|
||||||
|
"backToIndex": "Alle Releases",
|
||||||
|
"noDocs": "Keine Dokumentation für diesen Pfad verfügbar.",
|
||||||
|
"version": "Version",
|
||||||
|
"latest": "Aktuell",
|
||||||
|
"repo": "Repository",
|
||||||
|
"nav": "Dokumentation",
|
||||||
|
"guides": "Handbuch",
|
||||||
|
"endpoints": "Endpunkte",
|
||||||
|
"schemas": "Datenmodelle",
|
||||||
|
"releases": "Release-Notes",
|
||||||
|
"reference": "API-Referenz",
|
||||||
|
"referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.",
|
||||||
|
"onThisPage": "Auf dieser Seite",
|
||||||
|
"searchPlaceholder": "Doku durchsuchen…",
|
||||||
|
"backToApp": "Zur App",
|
||||||
|
"field": "Feld",
|
||||||
|
"type": "Typ",
|
||||||
|
"required": "Pflicht",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"status": "Status",
|
||||||
|
"schema": "Schema",
|
||||||
|
"parameter": "Parameter",
|
||||||
|
"noResults": "Keine Treffer",
|
||||||
|
"fields": "Felder",
|
||||||
|
"name": "Name",
|
||||||
|
"in": "In",
|
||||||
|
"requestBody": "Request-Body"
|
||||||
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"navigate": "Navigation",
|
"navigate": "Navigation",
|
||||||
"recent": "Zuletzt besucht",
|
"recent": "Zuletzt besucht",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
||||||
"startTyping": "Beginne zu tippen, um Tools zu suchen."
|
"startTyping": "Beginne zu tippen, um Tools zu suchen."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"accessRequired": "Admin-Zugriff erforderlich",
|
||||||
|
"accessRequiredSub": "Du benötigst Admin-Rechte, um diese Seite anzusehen.",
|
||||||
|
"goHome": "Zur Startseite",
|
||||||
|
"panel": "Admin-Bereich",
|
||||||
|
"panelSub": "Benutzer verwalten und Systemänderungen einsehen.",
|
||||||
|
"redundancyDashboard": "Redundanz-Dashboard",
|
||||||
|
"tabUsers": "Benutzer",
|
||||||
|
"tabTools": "Tools",
|
||||||
|
"tabAudit": "Audit-Log",
|
||||||
|
"tabSystem": "System",
|
||||||
|
"localUsers": "Lokale Benutzer",
|
||||||
|
"localUsersSub": "Konten für die lokale Authentifizierung verwalten.",
|
||||||
|
"addUser": "Benutzer hinzufügen",
|
||||||
|
"noUsersYet": "Noch keine Benutzer.",
|
||||||
|
"auditLog": "Audit-Log",
|
||||||
|
"auditLogSub": "Alle vom System protokollierten Erstellungs-, Aktualisierungs- und Löschvorgänge.",
|
||||||
|
"by": "von",
|
||||||
|
"noAuditEntries": "Noch keine Audit-Einträge.",
|
||||||
|
"system": "System",
|
||||||
|
"systemSub": "Build-Informationen der aktuell laufenden Bereitstellung.",
|
||||||
|
"version": "Version",
|
||||||
|
"commit": "Commit",
|
||||||
|
"buildDate": "Build-Datum",
|
||||||
|
"trashRetention": "Papierkorb-Aufbewahrung",
|
||||||
|
"days": "Tage",
|
||||||
|
"keepForever": "Für immer behalten",
|
||||||
|
"createNewUser": "Neuen Benutzer erstellen",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"emailOptional": "E-Mail (optional)",
|
||||||
|
"role": "Rolle",
|
||||||
|
"roleUser": "Benutzer",
|
||||||
|
"roleAdmin": "Admin",
|
||||||
|
"plan": "Plan",
|
||||||
|
"planFree": "Kostenlos",
|
||||||
|
"planPremium": "Premium",
|
||||||
|
"planEnterprise": "Enterprise",
|
||||||
|
"creating": "Erstelle…",
|
||||||
|
"createUser": "Benutzer erstellen",
|
||||||
|
"editUser": "Benutzer bearbeiten — {{username}}",
|
||||||
|
"setPassword": "Passwort festlegen",
|
||||||
|
"resetsPassword": "Setzt das Passwort des Benutzers sofort zurück.",
|
||||||
|
"idpManaged": "Das Passwort wird vom Identity-Provider (Keycloak) verwaltet. Setze es dort zurück.",
|
||||||
|
"setting": "Setze…",
|
||||||
|
"deleteUser": "Benutzer löschen",
|
||||||
|
"deleteUserConfirm": "Bist du sicher, dass du {{username}} löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||||
|
"minPasswordPlaceholder": "min. 6 Zeichen",
|
||||||
|
"usernamePlaceholder": "benutzername",
|
||||||
|
"emailPlaceholder": "user@example.com",
|
||||||
|
"toastUserCreated": "Benutzer erstellt",
|
||||||
|
"toastUserCreatedSub": "{{username}} wurde erstellt.",
|
||||||
|
"toastUserCreateFailed": "Benutzer konnte nicht erstellt werden",
|
||||||
|
"toastUserUpdated": "Benutzer aktualisiert",
|
||||||
|
"toastUserUpdateFailed": "Benutzer konnte nicht aktualisiert werden",
|
||||||
|
"toastPwTooShort": "Passwort zu kurz",
|
||||||
|
"toastPwTooShortSub": "Mindestens 6 Zeichen.",
|
||||||
|
"toastPwUpdated": "Passwort aktualisiert",
|
||||||
|
"toastPwUpdatedSub": "Passwort für {{username}} wurde festgelegt.",
|
||||||
|
"toastPwSetFailed": "Passwort konnte nicht festgelegt werden",
|
||||||
|
"toastUserDeleted": "Benutzer gelöscht",
|
||||||
|
"toastUserDeletedSub": "{{username}} wurde entfernt.",
|
||||||
|
"toastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden"
|
||||||
|
},
|
||||||
|
"adminTools": {
|
||||||
|
"accessRequired": "Admin-Zugriff erforderlich.",
|
||||||
|
"allTools": "Alle Tools",
|
||||||
|
"toolCount": "{{count}} Tool(s)",
|
||||||
|
"selected": "{{count}} ausgewählt",
|
||||||
|
"searchPlaceholder": "Tools suchen…",
|
||||||
|
"moveToTrash": "In den Papierkorb verschieben ({{count}})",
|
||||||
|
"noMatch": "Keine Tools entsprechen deiner Suche.",
|
||||||
|
"noToolsYet": "Noch keine Tools.",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"selectName": "{{name}} auswählen",
|
||||||
|
"name": "Name",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"createdBy": "Erstellt von",
|
||||||
|
"createdAt": "Erstellt am",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"confirmTitle": "{{count}} Tool(s) in den Papierkorb verschieben?",
|
||||||
|
"confirmSub": "Die ausgewählten Tools werden aus allen öffentlichen Ansichten entfernt und in den Papierkorb verschoben, wo sie wiederhergestellt oder endgültig gelöscht werden können.",
|
||||||
|
"moveToTrashAction": "In den Papierkorb verschieben",
|
||||||
|
"toastMoved": "Tools in den Papierkorb verschoben",
|
||||||
|
"toastMovedSub": "{{count}} Tool(s) in den Papierkorb verschoben.",
|
||||||
|
"toastMoveFailed": "Verschieben in den Papierkorb fehlgeschlagen",
|
||||||
|
"importTitle": "Tools importieren",
|
||||||
|
"importSub": "Mehrere Tools auf einmal importieren — als CSV, JSON oder YAML (Premium-Feature).",
|
||||||
|
"importButton": "Importieren",
|
||||||
|
"importFormatAuto": "Auto",
|
||||||
|
"importFormatAutoSub": "Das Format wird automatisch erkannt (CSV, JSON oder YAML).",
|
||||||
|
"importDelimiter": "CSV-Trenner",
|
||||||
|
"importDelimiterAuto": "Automatisch",
|
||||||
|
"importDelimiterComma": "Komma",
|
||||||
|
"importDelimiterSemicolon": "Semikolon",
|
||||||
|
"importDelimiterTab": "Tabulator",
|
||||||
|
"importCsvSub": "Spalten: name, description, category, websiteUrl, iconUrl, features, tags. Features/Tags durch | getrennt. Erste Zeile enthält die Spaltennamen.",
|
||||||
|
"importJsonSub": "JSON-Objekt oder Array von Objekten mit den Feldern name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||||
|
"importYamlSub": "YAML-Liste von Objekten (oder ein einzelnes Objekt) mit den Feldern name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||||
|
"importFile": "Datei laden",
|
||||||
|
"importTextareaPlaceholder": "name;description;category;features;tags\nMein Tool;Eine tolle Beschreibung;Monitoring;Deployment|Open Source;devops",
|
||||||
|
"importing": "Wird importiert…",
|
||||||
|
"importDone": "{{count}} von {{total}} Tools erfolgreich importiert.",
|
||||||
|
"importErrors": "{{count}} Zeilen mit Fehlern:",
|
||||||
|
"importErrorRow": "Zeile {{row}}",
|
||||||
|
"toastImportDone": "Tools erfolgreich importiert"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Plattform-Analysen",
|
||||||
|
"subtitle": "Makro-Einblicke in die Leistung der Tools und das Engagement der Community.",
|
||||||
|
"totalToolsIndexed": "Indexierte Tools gesamt",
|
||||||
|
"totalRatingsCast": "Abgegebene Bewertungen gesamt",
|
||||||
|
"activeCategories": "Aktive Kategorien",
|
||||||
|
"avgGlobalScore": "Ø Globaler Punktestand",
|
||||||
|
"top8Tools": "Top 8 Tools nach kombiniertem Punktestand",
|
||||||
|
"top8ToolsSub": "Die bestbewerteten Tools der Plattform",
|
||||||
|
"toolsByCategory": "Tools nach Kategorie",
|
||||||
|
"toolsByCategorySub": "Verteilung der Tools auf Kategorien",
|
||||||
|
"globalRatingDistribution": "Globale Bewertungsverteilung",
|
||||||
|
"globalRatingDistributionSub": "Wie Benutzer über alle Tools abstimmen",
|
||||||
|
"radarTools": "Tools"
|
||||||
|
},
|
||||||
|
"redundancy": {
|
||||||
|
"title": "Tool-Analyse & Empfehlungen",
|
||||||
|
"subtitle": "Automatische Redundanz-Erkennung mit Kosten- und Bewertungsvergleich. Admins können manuell bestätigen, welches Tool die bessere Wahl ist.",
|
||||||
|
"toolsComparisons": "{{tools}} Tools, {{pairs}} Vergleiche",
|
||||||
|
"totalMonthly": "/Monat gesamt",
|
||||||
|
"reviews": "Bewertungen",
|
||||||
|
"features": "Features",
|
||||||
|
"comparisonsTitle": "Vergleiche & Empfehlungen",
|
||||||
|
"vs": "gegen",
|
||||||
|
"noTools": "Keine Tools gefunden.",
|
||||||
|
"perMonth": "/Monat",
|
||||||
|
"toastEvalSaved": "Auswertung gespeichert",
|
||||||
|
"toastEvalFailed": "Auswertung konnte nicht gespeichert werden"
|
||||||
|
},
|
||||||
|
"toolForm": {
|
||||||
|
"backToBrowse": "Zurück zur Übersicht",
|
||||||
|
"backToTool": "Zurück zum Tool",
|
||||||
|
"addTitle": "Neues Tool hinzufügen",
|
||||||
|
"addSubtitle": "Reiche ein Tool ein, das du nutzt, damit die Community es bewerten kann.",
|
||||||
|
"editTitle": "Tool bearbeiten",
|
||||||
|
"editSubtitle": "Aktualisiere Tool-Details und Metadaten.",
|
||||||
|
"signInRequired": "Anmeldung erforderlich",
|
||||||
|
"signInRequiredSub": "Du musst angemeldet sein, um ein Tool einzureichen.",
|
||||||
|
"toolDetails": "Tool-Details",
|
||||||
|
"toolDetailsNewSub": "Gib die grundlegenden Informationen zum Tool an.",
|
||||||
|
"toolDetailsEditSub": "Ändere die Tool-Informationen unten.",
|
||||||
|
"name": "Name",
|
||||||
|
"nameMin": "Name muss mindestens 2 Zeichen haben",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"categoryRequired": "Kategorie ist erforderlich",
|
||||||
|
"websiteUrl": "Website-URL",
|
||||||
|
"websiteUrlOptional": "Website-URL (optional)",
|
||||||
|
"iconUrl": "Icon-/Logo-URL",
|
||||||
|
"iconUrlOptional": "Icon-/Logo-URL (optional)",
|
||||||
|
"iconPreview": "Icon-Vorschau",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"descriptionMin": "Beschreibung muss mindestens 10 Zeichen haben",
|
||||||
|
"invalidUrl": "Muss eine gültige URL sein",
|
||||||
|
"features": "Features",
|
||||||
|
"featuresNewSub": "Liste die wichtigsten Funktionen auf. Vorhandene Features anderer Tools sind auswählbar.",
|
||||||
|
"featuresEditSub": "Wichtige Funktionen dieses Tools. Vorhandene Features anderer Tools sind auswählbar.",
|
||||||
|
"addFeature": "Feature hinzufügen",
|
||||||
|
"noFeatures": "Keine Features hinzugefügt.",
|
||||||
|
"featurePlaceholder": "z. B. Echtzeit-Zusammenarbeit",
|
||||||
|
"tags": "Tags",
|
||||||
|
"tagsNewSub": "Schlüsselwörter, um dieses Tool zu finden. Vorhandene Tags anderer Tools sind auswählbar.",
|
||||||
|
"tagsEditSub": "Schlüsselwörter für dieses Tool. Vorhandene Tags anderer Tools sind auswählbar.",
|
||||||
|
"addTag": "Tag hinzufügen",
|
||||||
|
"tag": "Tag",
|
||||||
|
"namePlaceholder": "z. B. React, Next.js, Postgres",
|
||||||
|
"urlPlaceholder": "https://…",
|
||||||
|
"logoPlaceholder": "https://example.com/logo.png",
|
||||||
|
"descriptionPlaceholderNew": "Was macht dieses Tool? Warum nutzen es Menschen?",
|
||||||
|
"descriptionPlaceholderEdit": "Was macht dieses Tool?",
|
||||||
|
"addingTool": "Füge Tool hinzu…",
|
||||||
|
"submitTool": "Tool einreichen",
|
||||||
|
"saving": "Speichere…",
|
||||||
|
"saveChanges": "Änderungen speichern",
|
||||||
|
"toastAdded": "Tool erfolgreich hinzugefügt",
|
||||||
|
"toastAddedSub": "Dein Tool ist jetzt zur Bewertung verfügbar.",
|
||||||
|
"toastAddFailed": "Tool konnte nicht hinzugefügt werden",
|
||||||
|
"toastUpdated": "Tool aktualisiert",
|
||||||
|
"toastUpdatedSub": "Änderungen erfolgreich gespeichert.",
|
||||||
|
"toastUpdateFailed": "Tool konnte nicht aktualisiert werden",
|
||||||
|
"tagsHelp": "Schlüsselwörter, die helfen, dieses Tool zu finden. Vorhandene Tags anderer Tools sind auswählbar.",
|
||||||
|
"tagPlaceholder": "Tag"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"placeholder": "Kategorie auswählen oder eingeben…",
|
||||||
|
"searchPlaceholder": "Kategorie suchen oder neue eingeben…",
|
||||||
|
"noCategories": "Keine Kategorien gefunden.",
|
||||||
|
"knownCategories": "Bekannte Kategorien",
|
||||||
|
"createNew": "Neu erstellen",
|
||||||
|
"create": "+ Erstellen"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Design",
|
||||||
|
"light": "Hell",
|
||||||
|
"dark": "Dunkel",
|
||||||
|
"system": "System",
|
||||||
|
"chooseTitle": "Design auswählen",
|
||||||
|
"switchLight": "Zum hellen Design wechseln",
|
||||||
|
"switchDark": "Zum dunklen Design wechseln"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"trash": "Trash",
|
"trash": "Trash",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"redundancy": "Redundancy",
|
"redundancy": "Redundancy",
|
||||||
|
"docs": "Help",
|
||||||
"search": "Search tools…"
|
"search": "Search tools…"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -24,7 +25,18 @@
|
|||||||
"invalidCredentials": "Invalid username or password",
|
"invalidCredentials": "Invalid username or password",
|
||||||
"notAuthenticated": "Not authenticated",
|
"notAuthenticated": "Not authenticated",
|
||||||
"loginSubtitle": "Sign in to your account",
|
"loginSubtitle": "Sign in to your account",
|
||||||
"loginDescription": "Enter your credentials to continue"
|
"loginDescription": "Enter your credentials to continue",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"passwordMinLength": "min. 6 characters",
|
||||||
|
"pwMismatch": "Passwords do not match",
|
||||||
|
"pwTooShort": "Password must be at least 8 characters",
|
||||||
|
"pwChanged": "Password changed",
|
||||||
|
"pwChangeFailed": "Could not change password",
|
||||||
|
"oidcPasswordHint": "Your password is managed by the identity provider (Keycloak).",
|
||||||
|
"manageInIdp": "Manage in Keycloak"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -42,7 +54,18 @@
|
|||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"viewDetails": "View details",
|
||||||
|
"guideTooltip": "Guide in the documentation",
|
||||||
|
"help": "Help",
|
||||||
|
"viewMode": "View mode",
|
||||||
|
"viewGrid": "Grid",
|
||||||
|
"viewTable": "Table",
|
||||||
|
"viewRows": "Rows",
|
||||||
|
"listDensity": "List density",
|
||||||
|
"densityCompact": "Compact",
|
||||||
|
"densityComfortable": "Comfortable",
|
||||||
|
"densityTitle": "Density: {{density}}"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to toolr",
|
"welcome": "Welcome to toolr",
|
||||||
@@ -69,6 +92,8 @@
|
|||||||
"category": "Category",
|
"category": "Category",
|
||||||
"rating": "Rating",
|
"rating": "Rating",
|
||||||
"reviews": "Reviews",
|
"reviews": "Reviews",
|
||||||
|
"reviewCount_one": "{{count}} review",
|
||||||
|
"reviewCount_other": "{{count}} reviews",
|
||||||
"noToolsFound": "No tools found",
|
"noToolsFound": "No tools found",
|
||||||
"noToolsMatch": "We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.",
|
"noToolsMatch": "We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.",
|
||||||
"addTool": "Add Tool",
|
"addTool": "Add Tool",
|
||||||
@@ -92,7 +117,9 @@
|
|||||||
"features": "Features",
|
"features": "Features",
|
||||||
"minRating": "Min. rating",
|
"minRating": "Min. rating",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"clearFilters": "Clear filters"
|
"clearFilters": "Clear filters",
|
||||||
|
"removeFeature": "Remove feature: {{feature}}",
|
||||||
|
"removeMinRating": "Remove min rating"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"ratingBreakdown": "Rating Breakdown",
|
"ratingBreakdown": "Rating Breakdown",
|
||||||
@@ -117,7 +144,70 @@
|
|||||||
"relatedTools": "Related Tools",
|
"relatedTools": "Related Tools",
|
||||||
"costs": "Costs",
|
"costs": "Costs",
|
||||||
"addCost": "Add Cost",
|
"addCost": "Add Cost",
|
||||||
"recentRatings": "Recent Ratings"
|
"recentRatings": "Recent Ratings",
|
||||||
|
"deleteConfirmTitle": "Delete this tool?",
|
||||||
|
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
|
||||||
|
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"deleteAction": "Delete",
|
||||||
|
"invalidToolId": "Invalid Tool ID",
|
||||||
|
"backToBrowse": "Back to browse",
|
||||||
|
"keyFeatures": "Key Features",
|
||||||
|
"toolNotFound": "Tool not found.",
|
||||||
|
"similarTools": "Similar Tools",
|
||||||
|
"noSimilarTools": "No similar tools found.",
|
||||||
|
"linkTool": "Link Tool",
|
||||||
|
"linkSimilarTool": "Link Similar Tool",
|
||||||
|
"linkSimilarToolSub": "Manually link this tool to another tool.",
|
||||||
|
"toolId": "Tool ID",
|
||||||
|
"toolIdPlaceholder": "Enter target tool ID",
|
||||||
|
"relationType": "Relation Type",
|
||||||
|
"relationSimilar": "Similar",
|
||||||
|
"relationReplaces": "Replaces",
|
||||||
|
"relationSupersededBy": "Superseded By",
|
||||||
|
"notesOptional": "Notes (Optional)",
|
||||||
|
"notesPlaceholder": "Why are these tools related?",
|
||||||
|
"createLink": "Create Link",
|
||||||
|
"noCostInfo": "No cost information added yet.",
|
||||||
|
"costDialogSub": "Manage license cost information for this tool.",
|
||||||
|
"licenseType": "License Type",
|
||||||
|
"licenseFree": "Free",
|
||||||
|
"licenseSubscription": "Subscription",
|
||||||
|
"licenseOneTime": "One-Time",
|
||||||
|
"licenseUsageBased": "Usage-Based",
|
||||||
|
"billingPeriod": "Billing Period",
|
||||||
|
"billingMonthly": "Monthly",
|
||||||
|
"billingQuarterly": "Quarterly",
|
||||||
|
"billingYearly": "Yearly",
|
||||||
|
"costLabel": "Cost",
|
||||||
|
"currency": "Currency",
|
||||||
|
"costNotes": "Notes",
|
||||||
|
"costNotesPlaceholder": "Billing details, contract info...",
|
||||||
|
"commentOptional": "Comment (Optional)",
|
||||||
|
"commentLabel": "Comment",
|
||||||
|
"commentPlaceholder": "What do you think about this tool?",
|
||||||
|
"nameOptional": "Name (Optional)",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"anonymousPlaceholder": "Anonymous",
|
||||||
|
"anonymousEngineer": "Anonymous Engineer",
|
||||||
|
"beFirstToReview": "Be the first to share your thoughts on this tool.",
|
||||||
|
"shareExperience": "Share your experience with {{name}}",
|
||||||
|
"score": "Score",
|
||||||
|
"toastRelationCreated": "Relation created",
|
||||||
|
"toastRelationFailed": "Failed to create relation",
|
||||||
|
"toastRelationDeleted": "Relation deleted",
|
||||||
|
"toastRelationDeleteFailed": "Failed to delete relation",
|
||||||
|
"toastCostUpdated": "Cost updated",
|
||||||
|
"toastCostAdded": "Cost added",
|
||||||
|
"toastCostFailed": "Failed to save cost",
|
||||||
|
"toastCostDeleted": "Cost deleted",
|
||||||
|
"toastCostDeleteFailed": "Failed to delete cost",
|
||||||
|
"toastRatingSubmitted": "Rating submitted",
|
||||||
|
"toastRatingThanks": "Thank you for your feedback!",
|
||||||
|
"toastRatingFailed": "Failed to submit rating",
|
||||||
|
"toastToolDeleted": "Tool deleted",
|
||||||
|
"toastDeleteFailed": "Failed to delete",
|
||||||
|
"unexpectedError": "An unexpected error occurred."
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"title": "Compare Tools",
|
"title": "Compare Tools",
|
||||||
@@ -160,17 +250,277 @@
|
|||||||
"deletePermanently": "Delete permanently",
|
"deletePermanently": "Delete permanently",
|
||||||
"emptyTrash": "Empty trash",
|
"emptyTrash": "Empty trash",
|
||||||
"deletedAt": "Deleted at",
|
"deletedAt": "Deleted at",
|
||||||
"deletedBy": "Deleted by"
|
"deletedBy": "Deleted by",
|
||||||
|
"name": "Name",
|
||||||
|
"category": "Category",
|
||||||
|
"actions": "Actions",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"selectName": "Select {{name}}",
|
||||||
|
"restoreAction": "Restore",
|
||||||
|
"deleteAction": "Delete",
|
||||||
|
"deleteConfirmTitle": "Delete {{count}} tool(s) permanently?",
|
||||||
|
"deleteConfirmSub": "This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.",
|
||||||
|
"emptyConfirmTitle": "Empty the trash?",
|
||||||
|
"emptyConfirmSub": "This permanently removes all {{count}} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.",
|
||||||
|
"toastRestored": "Tools restored",
|
||||||
|
"toastRestoredSub": "{{count}} tool(s) restored.",
|
||||||
|
"toastRestoreFailed": "Failed to restore",
|
||||||
|
"toastDeleted": "Tools deleted",
|
||||||
|
"toastDeletedSub": "{{count}} tool(s) permanently removed.",
|
||||||
|
"toastDeleteFailed": "Failed to delete",
|
||||||
|
"toastEmptied": "Trash emptied",
|
||||||
|
"toastEmptiedSub": "{{count}} tool(s) permanently removed.",
|
||||||
|
"toastEmptyFailed": "Failed to empty trash",
|
||||||
|
"searchPlaceholder": "Search tools…",
|
||||||
|
"toolCount": "{{count}} tool(s)"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"text": "This page doesn't exist.",
|
"text": "This page doesn't exist.",
|
||||||
"backHome": "Back to Home"
|
"backHome": "Back to Home"
|
||||||
},
|
},
|
||||||
|
"docs": {
|
||||||
|
"title": "Documentation",
|
||||||
|
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
|
||||||
|
"backToIndex": "All releases",
|
||||||
|
"noDocs": "No documentation available for this path.",
|
||||||
|
"version": "Version",
|
||||||
|
"latest": "Latest",
|
||||||
|
"repo": "Repository",
|
||||||
|
"nav": "Documentation",
|
||||||
|
"guides": "Guides",
|
||||||
|
"endpoints": "Endpoints",
|
||||||
|
"schemas": "Data models",
|
||||||
|
"releases": "Release notes",
|
||||||
|
"reference": "API reference",
|
||||||
|
"referenceIntro": "Generated automatically from the OpenAPI spec — all endpoints and data fields of the current version.",
|
||||||
|
"onThisPage": "On this page",
|
||||||
|
"searchPlaceholder": "Search docs…",
|
||||||
|
"backToApp": "Back to app",
|
||||||
|
"field": "Field",
|
||||||
|
"type": "Type",
|
||||||
|
"required": "Required",
|
||||||
|
"description": "Description",
|
||||||
|
"status": "Status",
|
||||||
|
"schema": "Schema",
|
||||||
|
"parameter": "Parameter",
|
||||||
|
"noResults": "No results",
|
||||||
|
"fields": "Fields",
|
||||||
|
"name": "Name",
|
||||||
|
"in": "In",
|
||||||
|
"requestBody": "Request Body"
|
||||||
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"navigate": "Navigate",
|
"navigate": "Navigate",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"noResults": "No tools found for \"{{query}}\".",
|
"noResults": "No tools found for \"{{query}}\".",
|
||||||
"startTyping": "Start typing to search tools."
|
"startTyping": "Start typing to search tools."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"accessRequired": "Admin Access Required",
|
||||||
|
"accessRequiredSub": "You need admin rights to view this page.",
|
||||||
|
"goHome": "Go Home",
|
||||||
|
"panel": "Admin Panel",
|
||||||
|
"panelSub": "Manage users and review system changes.",
|
||||||
|
"redundancyDashboard": "Redundancy Dashboard",
|
||||||
|
"tabUsers": "Users",
|
||||||
|
"tabTools": "Tools",
|
||||||
|
"tabAudit": "Audit Log",
|
||||||
|
"tabSystem": "System",
|
||||||
|
"localUsers": "Local Users",
|
||||||
|
"localUsersSub": "Manage accounts for local authentication.",
|
||||||
|
"addUser": "Add User",
|
||||||
|
"noUsersYet": "No users yet.",
|
||||||
|
"auditLog": "Audit Log",
|
||||||
|
"auditLogSub": "All create, update and delete operations tracked by the system.",
|
||||||
|
"by": "by",
|
||||||
|
"noAuditEntries": "No audit entries yet.",
|
||||||
|
"system": "System",
|
||||||
|
"systemSub": "Build information of the currently live deployment.",
|
||||||
|
"version": "Version",
|
||||||
|
"commit": "Commit",
|
||||||
|
"buildDate": "Build date",
|
||||||
|
"trashRetention": "Trash retention",
|
||||||
|
"days": "days",
|
||||||
|
"keepForever": "Keep forever",
|
||||||
|
"createNewUser": "Create New User",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"emailOptional": "Email (Optional)",
|
||||||
|
"role": "Role",
|
||||||
|
"roleUser": "User",
|
||||||
|
"roleAdmin": "Admin",
|
||||||
|
"plan": "Plan",
|
||||||
|
"planFree": "Free",
|
||||||
|
"planPremium": "Premium",
|
||||||
|
"planEnterprise": "Enterprise",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"createUser": "Create User",
|
||||||
|
"editUser": "Edit User — {{username}}",
|
||||||
|
"setPassword": "Set Password",
|
||||||
|
"resetsPassword": "Resets the user's password immediately.",
|
||||||
|
"idpManaged": "Password is managed by the identity provider (Keycloak). Reset it there.",
|
||||||
|
"setting": "Setting…",
|
||||||
|
"deleteUser": "Delete User",
|
||||||
|
"deleteUserConfirm": "Are you sure you want to delete {{username}}? This cannot be undone.",
|
||||||
|
"minPasswordPlaceholder": "min. 6 characters",
|
||||||
|
"usernamePlaceholder": "username",
|
||||||
|
"emailPlaceholder": "user@example.com",
|
||||||
|
"toastUserCreated": "User created",
|
||||||
|
"toastUserCreatedSub": "{{username}} has been created.",
|
||||||
|
"toastUserCreateFailed": "Failed to create user",
|
||||||
|
"toastUserUpdated": "User updated",
|
||||||
|
"toastUserUpdateFailed": "Failed to update user",
|
||||||
|
"toastPwTooShort": "Password too short",
|
||||||
|
"toastPwTooShortSub": "Minimum 6 characters.",
|
||||||
|
"toastPwUpdated": "Password updated",
|
||||||
|
"toastPwUpdatedSub": "Password for {{username}} has been set.",
|
||||||
|
"toastPwSetFailed": "Failed to set password",
|
||||||
|
"toastUserDeleted": "User deleted",
|
||||||
|
"toastUserDeletedSub": "{{username}} has been removed.",
|
||||||
|
"toastUserDeleteFailed": "Failed to delete user"
|
||||||
|
},
|
||||||
|
"adminTools": {
|
||||||
|
"accessRequired": "Admin access required.",
|
||||||
|
"allTools": "All Tools",
|
||||||
|
"toolCount": "{{count}} tool(s)",
|
||||||
|
"selected": "{{count}} selected",
|
||||||
|
"searchPlaceholder": "Search tools…",
|
||||||
|
"moveToTrash": "Move to trash ({{count}})",
|
||||||
|
"noMatch": "No tools match your search.",
|
||||||
|
"noToolsYet": "No tools yet.",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"selectName": "Select {{name}}",
|
||||||
|
"name": "Name",
|
||||||
|
"category": "Category",
|
||||||
|
"rating": "Rating",
|
||||||
|
"createdBy": "Created by",
|
||||||
|
"createdAt": "Created at",
|
||||||
|
"actions": "Actions",
|
||||||
|
"confirmTitle": "Move {{count}} tool(s) to trash?",
|
||||||
|
"confirmSub": "The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.",
|
||||||
|
"moveToTrashAction": "Move to trash",
|
||||||
|
"toastMoved": "Tools moved to trash",
|
||||||
|
"toastMovedSub": "{{count}} tool(s) moved to trash.",
|
||||||
|
"toastMoveFailed": "Failed to move to trash",
|
||||||
|
"importTitle": "Import tools",
|
||||||
|
"importSub": "Import multiple tools at once — as CSV, JSON or YAML (premium feature).",
|
||||||
|
"importButton": "Import",
|
||||||
|
"importFormatAuto": "Auto",
|
||||||
|
"importFormatAutoSub": "The format is detected automatically (CSV, JSON or YAML).",
|
||||||
|
"importDelimiter": "CSV delimiter",
|
||||||
|
"importDelimiterAuto": "Auto-detect",
|
||||||
|
"importDelimiterComma": "Comma",
|
||||||
|
"importDelimiterSemicolon": "Semicolon",
|
||||||
|
"importDelimiterTab": "Tab",
|
||||||
|
"importCsvSub": "Columns: name, description, category, websiteUrl, iconUrl, features, tags. Separate features/tags with |. The first line holds the column names.",
|
||||||
|
"importJsonSub": "A JSON object or array of objects with the fields name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||||
|
"importYamlSub": "A YAML list of objects (or a single object) with the fields name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||||
|
"importFile": "Upload file",
|
||||||
|
"importTextareaPlaceholder": "name;description;category;features;tags\nMy Tool;A great description;Monitoring;Deployment|Open Source;devops",
|
||||||
|
"importing": "Importing…",
|
||||||
|
"importDone": "{{count}} of {{total}} tools imported successfully.",
|
||||||
|
"importErrors": "{{count}} rows with errors:",
|
||||||
|
"importErrorRow": "Row {{row}}",
|
||||||
|
"toastImportDone": "Tools imported successfully"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Platform Analytics",
|
||||||
|
"subtitle": "Macro-level insights into tool performance and community engagement.",
|
||||||
|
"totalToolsIndexed": "Total Tools Indexed",
|
||||||
|
"totalRatingsCast": "Total Ratings Cast",
|
||||||
|
"activeCategories": "Active Categories",
|
||||||
|
"avgGlobalScore": "Avg Global Score",
|
||||||
|
"top8Tools": "Top 8 Tools by Combined Score",
|
||||||
|
"top8ToolsSub": "Highest rated tools across the platform",
|
||||||
|
"toolsByCategory": "Tools by Category",
|
||||||
|
"toolsByCategorySub": "Distribution of tools across categories",
|
||||||
|
"globalRatingDistribution": "Global Rating Distribution",
|
||||||
|
"globalRatingDistributionSub": "How users are voting across all tools",
|
||||||
|
"radarTools": "Tools"
|
||||||
|
},
|
||||||
|
"redundancy": {
|
||||||
|
"title": "Tool Analysis & Recommendations",
|
||||||
|
"subtitle": "Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.",
|
||||||
|
"toolsComparisons": "{{tools}} tools, {{pairs}} comparisons",
|
||||||
|
"totalMonthly": "/mo total",
|
||||||
|
"reviews": "reviews",
|
||||||
|
"features": "features",
|
||||||
|
"comparisonsTitle": "Comparisons & Recommendations",
|
||||||
|
"vs": "vs",
|
||||||
|
"noTools": "No tools found.",
|
||||||
|
"perMonth": "/mo",
|
||||||
|
"toastEvalSaved": "Evaluation saved",
|
||||||
|
"toastEvalFailed": "Failed to save evaluation"
|
||||||
|
},
|
||||||
|
"toolForm": {
|
||||||
|
"backToBrowse": "Back to browse",
|
||||||
|
"backToTool": "Back to tool",
|
||||||
|
"addTitle": "Add a New Tool",
|
||||||
|
"addSubtitle": "Submit a tool you use to let the community rate and review it.",
|
||||||
|
"editTitle": "Edit Tool",
|
||||||
|
"editSubtitle": "Update tool details and metadata.",
|
||||||
|
"signInRequired": "Sign in required",
|
||||||
|
"signInRequiredSub": "You must be signed in to submit a tool.",
|
||||||
|
"toolDetails": "Tool Details",
|
||||||
|
"toolDetailsNewSub": "Provide the basic information about the tool.",
|
||||||
|
"toolDetailsEditSub": "Modify the tool information below.",
|
||||||
|
"name": "Name",
|
||||||
|
"nameMin": "Name must be at least 2 characters",
|
||||||
|
"category": "Category",
|
||||||
|
"categoryRequired": "Category is required",
|
||||||
|
"websiteUrl": "Website URL",
|
||||||
|
"websiteUrlOptional": "Website URL (Optional)",
|
||||||
|
"iconUrl": "Icon / Logo URL",
|
||||||
|
"iconUrlOptional": "Icon / Logo URL (Optional)",
|
||||||
|
"iconPreview": "icon preview",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionMin": "Description must be at least 10 characters",
|
||||||
|
"invalidUrl": "Must be a valid URL",
|
||||||
|
"features": "Features",
|
||||||
|
"featuresNewSub": "List key capabilities. Existing features from other tools are selectable.",
|
||||||
|
"featuresEditSub": "Key capabilities of this tool. Existing features from other tools are selectable.",
|
||||||
|
"addFeature": "Add Feature",
|
||||||
|
"noFeatures": "No features added.",
|
||||||
|
"featurePlaceholder": "e.g. Real-time collaboration",
|
||||||
|
"tags": "Tags",
|
||||||
|
"tagsNewSub": "Keywords to help find this tool. Existing tags from other tools are selectable.",
|
||||||
|
"tagsEditSub": "Keywords for this tool. Existing tags from other tools are selectable.",
|
||||||
|
"addTag": "Add Tag",
|
||||||
|
"tag": "Tag",
|
||||||
|
"namePlaceholder": "e.g. React, Next.js, Postgres",
|
||||||
|
"urlPlaceholder": "https://...",
|
||||||
|
"logoPlaceholder": "https://example.com/logo.png",
|
||||||
|
"descriptionPlaceholderNew": "What does this tool do? Why do people use it?",
|
||||||
|
"descriptionPlaceholderEdit": "What does this tool do?",
|
||||||
|
"addingTool": "Adding Tool...",
|
||||||
|
"submitTool": "Submit Tool",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saveChanges": "Save Changes",
|
||||||
|
"toastAdded": "Tool added successfully",
|
||||||
|
"toastAddedSub": "Your tool is now available for review.",
|
||||||
|
"toastAddFailed": "Failed to add tool",
|
||||||
|
"toastUpdated": "Tool updated",
|
||||||
|
"toastUpdatedSub": "Changes saved successfully.",
|
||||||
|
"toastUpdateFailed": "Failed to update tool",
|
||||||
|
"tagsHelp": "Keywords to help find this tool. Existing tags from other tools are selectable.",
|
||||||
|
"tagPlaceholder": "Tag"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"placeholder": "Select or type a category...",
|
||||||
|
"searchPlaceholder": "Search or enter new category...",
|
||||||
|
"noCategories": "No categories found.",
|
||||||
|
"knownCategories": "Known categories",
|
||||||
|
"createNew": "Create new",
|
||||||
|
"create": "+ Create"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"system": "System",
|
||||||
|
"chooseTitle": "Choose theme",
|
||||||
|
"switchLight": "Switch to light theme",
|
||||||
|
"switchDark": "Switch to dark theme"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,25 @@
|
|||||||
*/
|
*/
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
|
/* Documentation heading anchors (mkdocs style "¶" links) */
|
||||||
|
.docs-prose :is(h1, h2, h3, h4) {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-prose .docs-anchor::after {
|
||||||
|
content: "¶";
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-prose :is(h1, h2, h3, h4):hover .docs-anchor::after {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide ugly search cancel button in Chrome until we can style it properly */
|
/* Hide ugly search cancel button in Chrome until we can style it properly */
|
||||||
input[type="search"]::-webkit-search-cancel-button {
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { setCsrfTokenGetter } from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
setCsrfTokenGetter(() => token);
|
||||||
|
|
||||||
|
export function getCsrfToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCsrfToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/csrf`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { token?: string };
|
||||||
|
token = data.token ?? null;
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useCreateUser,
|
useCreateUser,
|
||||||
useUpdateUser,
|
useUpdateUser,
|
||||||
useDeleteUser,
|
useDeleteUser,
|
||||||
|
useSetUserPassword,
|
||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
useGetVersion,
|
useGetVersion,
|
||||||
getListUsersQueryKey,
|
getListUsersQueryKey,
|
||||||
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
|
|||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
@@ -27,15 +29,18 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
|
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { AdminToolsTab } from "@/components/admin-tools-tab";
|
import { AdminToolsTab } from "@/components/admin-tools-tab";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { user, isAdmin, isLoading: authLoading } = useAuth();
|
const { user, isAdmin, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null);
|
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
|
||||||
|
const [editPassword, setEditPassword] = useState("");
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||||
|
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
@@ -58,15 +63,16 @@ export default function Admin() {
|
|||||||
const createUser = useCreateUser();
|
const createUser = useCreateUser();
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const deleteUser = useDeleteUser();
|
const deleteUser = useDeleteUser();
|
||||||
|
const setUserPassword = useSetUserPassword();
|
||||||
|
|
||||||
if (!authLoading && !isAdmin) {
|
if (!authLoading && !isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
<h2 className="text-2xl font-bold">Admin Access Required</h2>
|
<h2 className="text-2xl font-bold">{t("admin.accessRequired")}</h2>
|
||||||
<p className="text-muted-foreground">You need admin rights to view this page.</p>
|
<p className="text-muted-foreground">{t("admin.accessRequiredSub")}</p>
|
||||||
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
|
<Button variant="outline" onClick={() => setLocation("/")}>{t("admin.goHome")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
@@ -78,7 +84,7 @@ export default function Admin() {
|
|||||||
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
|
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "User created", description: `${newUsername} has been created.` });
|
toast({ title: t("admin.toastUserCreated"), description: t("admin.toastUserCreatedSub", { username: newUsername }) });
|
||||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setNewUsername("");
|
setNewUsername("");
|
||||||
@@ -87,7 +93,7 @@ export default function Admin() {
|
|||||||
setNewRole("user");
|
setNewRole("user");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("admin.toastUserCreateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -99,12 +105,32 @@ export default function Admin() {
|
|||||||
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
|
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "User updated" });
|
toast({ title: t("admin.toastUserUpdated") });
|
||||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setEditUser(null);
|
setEditUser(null);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("admin.toastUserUpdateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetUserPassword = () => {
|
||||||
|
if (!editUser || !editPassword) return;
|
||||||
|
if (editPassword.length < 6) {
|
||||||
|
toast({ title: t("admin.toastPwTooShort"), description: t("admin.toastPwTooShortSub"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserPassword.mutate(
|
||||||
|
{ id: editUser.id, data: { password: editPassword } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: t("admin.toastPwUpdated"), description: t("admin.toastPwUpdatedSub", { username: editUser.username }) });
|
||||||
|
setEditPassword("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: t("admin.toastPwSetFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -116,12 +142,12 @@ export default function Admin() {
|
|||||||
{ id: deleteConfirm.id },
|
{ id: deleteConfirm.id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` });
|
toast({ title: t("admin.toastUserDeleted"), description: t("admin.toastUserDeletedSub", { username: deleteConfirm.username }) });
|
||||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("admin.toastUserDeleteFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -139,11 +165,11 @@ export default function Admin() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("admin.panel")}</h1>
|
||||||
<p className="text-muted-foreground">Manage users and review system changes.</p>
|
<p className="text-muted-foreground">{t("admin.panelSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||||
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> Redundancy Dashboard</Link>
|
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> {t("admin.redundancyDashboard")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,16 +177,16 @@ export default function Admin() {
|
|||||||
<Tabs defaultValue="users">
|
<Tabs defaultValue="users">
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-4">
|
||||||
<TabsTrigger value="users" className="gap-2">
|
<TabsTrigger value="users" className="gap-2">
|
||||||
<Users className="w-4 h-4" /> Users
|
<Users className="w-4 h-4" /> {t("admin.tabUsers")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tools" className="gap-2">
|
<TabsTrigger value="tools" className="gap-2">
|
||||||
<Wrench className="w-4 h-4" /> Tools
|
<Wrench className="w-4 h-4" /> {t("admin.tabTools")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="audit" className="gap-2">
|
<TabsTrigger value="audit" className="gap-2">
|
||||||
<ScrollText className="w-4 h-4" /> Audit Log
|
<ScrollText className="w-4 h-4" /> {t("admin.tabAudit")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="system" className="gap-2">
|
<TabsTrigger value="system" className="gap-2">
|
||||||
<Server className="w-4 h-4" /> System
|
<Server className="w-4 h-4" /> {t("admin.tabSystem")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -168,11 +194,11 @@ export default function Admin() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Local Users</CardTitle>
|
<CardTitle>{t("admin.localUsers")}</CardTitle>
|
||||||
<CardDescription>Manage accounts for local authentication.</CardDescription>
|
<CardDescription>{t("admin.localUsersSub")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add User
|
<Plus className="w-4 h-4 mr-2" /> {t("admin.addUser")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -206,7 +232,7 @@ export default function Admin() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,7 +250,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!users || users.length === 0) && (
|
{(!users || users.length === 0) && (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noUsersYet")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -239,8 +265,8 @@ export default function Admin() {
|
|||||||
<TabsContent value="audit">
|
<TabsContent value="audit">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Audit Log</CardTitle>
|
<CardTitle>{t("admin.auditLog")}</CardTitle>
|
||||||
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
|
<CardDescription>{t("admin.auditLogSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingLogs ? (
|
{loadingLogs ? (
|
||||||
@@ -265,8 +291,7 @@ export default function Admin() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
|
<span>{t("admin.by")} <span className="font-medium text-foreground">{log.username}</span></span> {log.changes && (
|
||||||
{log.changes && (
|
|
||||||
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
||||||
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
|
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
|
||||||
</span>
|
</span>
|
||||||
@@ -275,7 +300,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!auditLogs || auditLogs.length === 0) && (
|
{(!auditLogs || auditLogs.length === 0) && (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noAuditEntries")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,17 +311,17 @@ export default function Admin() {
|
|||||||
<TabsContent value="system">
|
<TabsContent value="system">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>System</CardTitle>
|
<CardTitle>{t("admin.system")}</CardTitle>
|
||||||
<CardDescription>Build information of the currently live deployment.</CardDescription>
|
<CardDescription>{t("admin.systemSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-sm text-muted-foreground">Version</span>
|
<span className="text-sm text-muted-foreground">{t("admin.version")}</span>
|
||||||
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
|
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-sm text-muted-foreground">Commit</span>
|
<span className="text-sm text-muted-foreground">{t("admin.commit")}</span>
|
||||||
{versionInfo?.commitSha ? (
|
{versionInfo?.commitSha ? (
|
||||||
<a
|
<a
|
||||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
|
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
|
||||||
@@ -312,17 +337,17 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-sm text-muted-foreground">Build date</span>
|
<span className="text-sm text-muted-foreground">{t("admin.buildDate")}</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
|
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-sm text-muted-foreground">Trash retention</span>
|
<span className="text-sm text-muted-foreground">{t("admin.trashRetention")}</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{(versionInfo?.trashRetentionDays ?? 0) > 0
|
{(versionInfo?.trashRetentionDays ?? 0) > 0
|
||||||
? `${versionInfo?.trashRetentionDays} days`
|
? `${versionInfo?.trashRetentionDays} ${t("admin.days")}`
|
||||||
: "Keep forever"}
|
: t("admin.keepForever")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,64 +360,64 @@ export default function Admin() {
|
|||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New User</DialogTitle>
|
<DialogTitle>{t("admin.createNewUser")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Username</Label>
|
<Label>{t("admin.username")}</Label>
|
||||||
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
|
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Password</Label>
|
<Label>{t("admin.password")}</Label>
|
||||||
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email (Optional)</Label>
|
<Label>{t("admin.emailOptional")}</Label>
|
||||||
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
|
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Role</Label>
|
<Label>{t("admin.role")}</Label>
|
||||||
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="user">User</SelectItem>
|
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Plan</Label>
|
<Label>{t("admin.plan")}</Label>
|
||||||
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
||||||
<SelectItem value="premium">Premium</SelectItem>
|
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
||||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
|
||||||
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
|
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
|
||||||
{createUser.isPending ? "Creating…" : "Create User"}
|
{createUser.isPending ? t("admin.creating") : t("admin.createUser")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
<DialogTitle>{t("admin.editUser", { username: editUser?.username })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Role</Label>
|
<Label>{t("admin.role")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editUser?.role || "user"}
|
value={editUser?.role || "user"}
|
||||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||||
@@ -401,13 +426,13 @@ export default function Admin() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="user">User</SelectItem>
|
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Plan</Label>
|
<Label>{t("admin.plan")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editUser?.tier || "free"}
|
value={editUser?.tier || "free"}
|
||||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
||||||
@@ -416,21 +441,46 @@ export default function Admin() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
||||||
<SelectItem value="premium">Premium</SelectItem>
|
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
||||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{editUser?.authProvider !== "oidc" ? (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label>{t("admin.setPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e) => setEditPassword(e.target.value)}
|
||||||
|
placeholder={t("admin.minPasswordPlaceholder")}
|
||||||
|
data-testid="input-set-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("admin.resetsPassword")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
|
||||||
|
{t("admin.idpManaged")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>{t("common.cancel")}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpdateUser}
|
onClick={handleUpdateUser}
|
||||||
disabled={updateUser.isPending}
|
disabled={updateUser.isPending}
|
||||||
>
|
>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{editUser?.authProvider !== "oidc" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSetUserPassword}
|
||||||
|
disabled={setUserPassword.isPending || !editPassword}
|
||||||
|
>
|
||||||
|
{setUserPassword.isPending ? t("admin.setting") : t("admin.setPassword")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -438,15 +488,15 @@ export default function Admin() {
|
|||||||
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete User</DialogTitle>
|
<DialogTitle>{t("admin.deleteUser")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
|
{t("admin.deleteUserConfirm", { username: deleteConfirm?.username })}
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>{t("common.cancel")}</Button>
|
||||||
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
|
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
|
||||||
{deleteUser.isPending ? "Deleting…" : "Delete"}
|
{deleteUser.isPending ? t("detail.deleting") : t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useGetRatingDistribution,
|
useGetRatingDistribution,
|
||||||
GetTopToolsMetric
|
GetTopToolsMetric
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
||||||
|
|
||||||
export default function Analytics() {
|
export default function Analytics() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||||
|
|
||||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
||||||
@@ -48,8 +50,10 @@ export default function Analytics() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Platform Analytics</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
|
{t("analytics.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top KPI Cards */}
|
{/* Top KPI Cards */}
|
||||||
@@ -57,7 +61,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Total Tools Indexed</span>
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalToolsIndexed")}</span>
|
||||||
<Layers className="w-4 h-4 text-muted-foreground" />
|
<Layers className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -69,7 +73,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Total Ratings Cast</span>
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalRatingsCast")}</span>
|
||||||
<Activity className="w-4 h-4 text-muted-foreground" />
|
<Activity className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -81,7 +85,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Active Categories</span>
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.activeCategories")}</span>
|
||||||
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -93,7 +97,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Avg Global Score</span>
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.avgGlobalScore")}</span>
|
||||||
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -108,8 +112,8 @@ export default function Analytics() {
|
|||||||
{/* Top Tools Chart */}
|
{/* Top Tools Chart */}
|
||||||
<Card className="col-span-1 lg:col-span-2">
|
<Card className="col-span-1 lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top 8 Tools by Combined Score</CardTitle>
|
<CardTitle>{t("analytics.top8Tools")}</CardTitle>
|
||||||
<CardDescription>Highest rated tools across the platform</CardDescription>
|
<CardDescription>{t("analytics.top8ToolsSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingTopTools ? (
|
{loadingTopTools ? (
|
||||||
@@ -136,8 +140,8 @@ export default function Analytics() {
|
|||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Tools by Category</CardTitle>
|
<CardTitle>{t("analytics.toolsByCategory")}</CardTitle>
|
||||||
<CardDescription>Distribution of tools across categories</CardDescription>
|
<CardDescription>{t("analytics.toolsByCategorySub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingCategories ? (
|
{loadingCategories ? (
|
||||||
@@ -149,7 +153,7 @@ export default function Analytics() {
|
|||||||
<PolarGrid stroke="hsl(var(--border))" />
|
<PolarGrid stroke="hsl(var(--border))" />
|
||||||
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
||||||
<Radar name="Tools" dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
|
<Radar name={t("analytics.radarTools")} dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
|
||||||
<RechartsTooltip />
|
<RechartsTooltip />
|
||||||
</RadarChart>
|
</RadarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -161,8 +165,8 @@ export default function Analytics() {
|
|||||||
{/* Rating Distributions */}
|
{/* Rating Distributions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Global Rating Distribution</CardTitle>
|
<CardTitle>{t("analytics.globalRatingDistribution")}</CardTitle>
|
||||||
<CardDescription>How users are voting across all tools</CardDescription>
|
<CardDescription>{t("analytics.globalRatingDistributionSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingDistribution ? (
|
{loadingDistribution ? (
|
||||||
@@ -170,7 +174,7 @@ export default function Analytics() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usefulness")}</span>
|
||||||
<div className="h-[110px] w-full">
|
<div className="h-[110px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
@@ -184,7 +188,7 @@ export default function Analytics() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usability")}</span>
|
||||||
<div className="h-[110px] w-full">
|
<div className="h-[110px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export default function Compare() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("compare.title")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Usefulness</TableCell>
|
<TableCell className="font-medium">{t("detail.usefulness")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
||||||
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
||||||
@@ -149,7 +151,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Usability</TableCell>
|
<TableCell className="font-medium">{t("detail.usability")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
||||||
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
||||||
@@ -173,7 +175,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Features</TableCell>
|
<TableCell className="font-medium">{t("filter.features")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className="align-top">
|
<TableCell key={t.id} className="align-top">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -186,7 +188,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Tags</TableCell>
|
<TableCell className="font-medium">{t("filter.tags")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className="align-top">
|
<TableCell key={t.id} className="align-top">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
|
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -22,9 +22,19 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-8 pb-8">
|
<div className="space-y-8 pb-8">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("home.welcome")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Banner */}
|
{/* Stats Banner */}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { loadCsrfToken } from "@/lib/csrf";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Wrench, AlertCircle } from "lucide-react";
|
import { Wrench, AlertCircle } from "lucide-react";
|
||||||
@@ -32,6 +34,7 @@ export default function Login() {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
|
void loadCsrfToken();
|
||||||
setLocation(returnTo);
|
setLocation(returnTo);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -76,9 +79,8 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
<Input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -11,6 +12,7 @@ import { customFetch } from "@workspace/api-client-react";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function RedundancyPage() {
|
export default function RedundancyPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [data, setData] = useState<any[] | null>(null);
|
const [data, setData] = useState<any[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -28,10 +30,10 @@ export default function RedundancyPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
|
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
|
||||||
});
|
});
|
||||||
toast({ title: "Evaluation saved" });
|
toast({ title: t("redundancy.toastEvalSaved") });
|
||||||
setData(await customFetch<any[]>("/api/admin/redundancy"));
|
setData(await customFetch<any[]>("/api/admin/redundancy"));
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Failed to save evaluation", variant: "destructive" });
|
toast({ title: t("redundancy.toastEvalFailed"), variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +46,10 @@ export default function RedundancyPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
||||||
<h1 className="text-3xl font-bold">Tool Analysis & Recommendations</h1>
|
<h1 className="text-3xl font-bold">{t("redundancy.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.
|
{t("redundancy.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -61,12 +63,12 @@ export default function RedundancyPage() {
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{group.tools.length} tools, {group.pairs.length} comparisons</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{t("redundancy.toolsComparisons", { tools: group.tools.length, pairs: group.pairs.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
{group.totalMonthlyCost > 0 && (
|
{group.totalMonthlyCost > 0 && (
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<DollarSign className="w-3 h-3" />
|
<DollarSign className="w-3 h-3" />
|
||||||
{group.totalMonthlyCost.toFixed(2)}/mo total
|
{group.totalMonthlyCost.toFixed(2)}{t("redundancy.totalMonthly")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,12 +84,12 @@ export default function RedundancyPage() {
|
|||||||
<span className="font-medium">{tool.name}</span>
|
<span className="font-medium">{tool.name}</span>
|
||||||
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
|
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
|
||||||
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
|
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
|
||||||
{tool.totalMonthly.toFixed(2)}/mo
|
{tool.totalMonthly.toFixed(2)}{t("redundancy.perMonth")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
|
||||||
<span>{tool.ratingCount} reviews</span>
|
<span>{tool.ratingCount} {t("redundancy.reviews")}</span>
|
||||||
{tool.avgCombined != null && (
|
{tool.avgCombined != null && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -104,7 +106,7 @@ export default function RedundancyPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
|
<Badge variant="outline" className="shrink-0">{tool.features.length} {t("redundancy.features")}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -114,7 +116,7 @@ export default function RedundancyPage() {
|
|||||||
|
|
||||||
{group.pairs.length > 0 && (
|
{group.pairs.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Comparisons & Recommendations</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">{t("redundancy.comparisonsTitle")}</h3>
|
||||||
{group.pairs.map((pair: any, i: number) => (
|
{group.pairs.map((pair: any, i: number) => (
|
||||||
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
|
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -126,11 +128,11 @@ export default function RedundancyPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"} ★
|
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"} ★
|
||||||
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""}
|
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center shrink-0">
|
<div className="text-center shrink-0">
|
||||||
<div className="text-xs text-muted-foreground font-medium">vs</div>
|
<div className="text-xs text-muted-foreground font-medium">{t("redundancy.vs")}</div>
|
||||||
<div className="flex items-center gap-1 justify-center mt-0.5">
|
<div className="flex items-center gap-1 justify-center mt-0.5">
|
||||||
<Progress value={pair.overlap} className="w-12 h-1.5" />
|
<Progress value={pair.overlap} className="w-12 h-1.5" />
|
||||||
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
|
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
|
||||||
@@ -142,7 +144,7 @@ export default function RedundancyPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"} ★
|
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"} ★
|
||||||
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""}
|
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +190,7 @@ export default function RedundancyPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground py-8 text-center">No tools found.</p>
|
<p className="text-muted-foreground py-8 text-center">{t("redundancy.noTools")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
import { recordRecentTool } from "@/lib/recent-tools";
|
import { recordRecentTool } from "@/lib/recent-tools";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
usefulness: z.number().min(1).max(5),
|
usefulness: z.number().min(1).max(5),
|
||||||
@@ -119,24 +120,24 @@ export default function ToolDetail() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
|
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
|
||||||
});
|
});
|
||||||
toast({ title: "Relation created" });
|
toast({ title: t("detail.toastRelationCreated") });
|
||||||
setLinkDialogOpen(false);
|
setLinkDialogOpen(false);
|
||||||
setLinkToolId("");
|
setLinkToolId("");
|
||||||
setLinkNotes("");
|
setLinkNotes("");
|
||||||
setLinkType("similar");
|
setLinkType("similar");
|
||||||
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast({ title: "Failed to create relation", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("detail.toastRelationFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteRelation(relationId: number) {
|
async function handleDeleteRelation(relationId: number) {
|
||||||
try {
|
try {
|
||||||
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
|
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
|
||||||
toast({ title: "Relation deleted" });
|
toast({ title: t("detail.toastRelationDeleted") });
|
||||||
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Failed to delete relation", variant: "destructive" });
|
toast({ title: t("detail.toastRelationDeleteFailed"), variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +162,12 @@ export default function ToolDetail() {
|
|||||||
if (costAmount) body.cost = costAmount;
|
if (costAmount) body.cost = costAmount;
|
||||||
try {
|
try {
|
||||||
await customFetch(url, { method, body: JSON.stringify(body) });
|
await customFetch(url, { method, body: JSON.stringify(body) });
|
||||||
toast({ title: editCost ? "Cost updated" : "Cost added" });
|
toast({ title: editCost ? t("detail.toastCostUpdated") : t("detail.toastCostAdded") });
|
||||||
setCostDialogOpen(false);
|
setCostDialogOpen(false);
|
||||||
resetCostForm();
|
resetCostForm();
|
||||||
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast({ title: "Failed to save cost", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("detail.toastCostFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +193,10 @@ export default function ToolDetail() {
|
|||||||
async function handleDeleteCost(costId: number) {
|
async function handleDeleteCost(costId: number) {
|
||||||
try {
|
try {
|
||||||
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
|
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
|
||||||
toast({ title: "Cost deleted" });
|
toast({ title: t("detail.toastCostDeleted") });
|
||||||
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Failed to delete cost", variant: "destructive" });
|
toast({ title: t("detail.toastCostDeleteFailed"), variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +236,8 @@ export default function ToolDetail() {
|
|||||||
createRating.mutate({ id, data }, {
|
createRating.mutate({ id, data }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: "Rating submitted",
|
title: t("detail.toastRatingSubmitted"),
|
||||||
description: "Thank you for your feedback!",
|
description: t("detail.toastRatingThanks"),
|
||||||
});
|
});
|
||||||
setIsReviewFormOpen(false);
|
setIsReviewFormOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -250,8 +251,8 @@ export default function ToolDetail() {
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to submit rating",
|
title: t("detail.toastRatingFailed"),
|
||||||
description: error.data?.error || error.message || "An unexpected error occurred.",
|
description: error.data?.error || error.message || t("detail.unexpectedError"),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -262,9 +263,9 @@ export default function ToolDetail() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<h2 className="text-2xl font-bold">Invalid Tool ID</h2>
|
<h2 className="text-2xl font-bold">{t("detail.invalidToolId")}</h2>
|
||||||
<Button variant="link" asChild className="mt-4">
|
<Button variant="link" asChild className="mt-4">
|
||||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to tools</Link>
|
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -286,7 +287,7 @@ export default function ToolDetail() {
|
|||||||
{ id },
|
{ id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Tool deleted" });
|
toast({ title: t("detail.toastToolDeleted") });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
@@ -295,7 +296,7 @@ export default function ToolDetail() {
|
|||||||
setLocation("/tools");
|
setLocation("/tools");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" });
|
toast({ title: t("detail.toastDeleteFailed"), description: err.data?.error || err.message, variant: "destructive" });
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -307,7 +308,7 @@ export default function ToolDetail() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
|
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
@@ -406,7 +407,7 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
{tool.features && tool.features.length > 0 && (
|
{tool.features && tool.features.length > 0 && (
|
||||||
<div className="pt-6 border-t">
|
<div className="pt-6 border-t">
|
||||||
<h3 className="text-lg font-semibold mb-3">Key Features</h3>
|
<h3 className="text-lg font-semibold mb-3">{t("detail.keyFeatures")}</h3>
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{tool.features.map((feature, i) => (
|
{tool.features.map((feature, i) => (
|
||||||
<li key={i} className="flex items-start gap-2">
|
<li key={i} className="flex items-start gap-2">
|
||||||
@@ -421,17 +422,17 @@ export default function ToolDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-10">Tool not found.</div>
|
<div className="text-center py-10">{t("detail.toolNotFound")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Similar Tools Section */}
|
{/* Similar Tools Section */}
|
||||||
{tool && (
|
{tool && (
|
||||||
<div className="space-y-4 mt-8">
|
<div className="space-y-4 mt-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xl font-bold">Similar Tools</h3>
|
<h3 className="text-xl font-bold">{t("detail.similarTools")}</h3>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
|
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
|
||||||
<LinkIcon className="w-4 h-4" /> Link Tool
|
<LinkIcon className="w-4 h-4" /> {t("detail.linkTool")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +500,7 @@ export default function ToolDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Score: {item.score}</span>
|
<span>{t("detail.score")}: {item.score}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -508,7 +509,7 @@ export default function ToolDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
|
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-4">No similar tools found.</p>
|
<p className="text-sm text-muted-foreground py-4">{t("detail.noSimilarTools")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -517,44 +518,44 @@ export default function ToolDetail() {
|
|||||||
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Link Similar Tool</DialogTitle>
|
<DialogTitle>{t("detail.linkSimilarTool")}</DialogTitle>
|
||||||
<DialogDescription>Manually link this tool to another tool.</DialogDescription>
|
<DialogDescription>{t("detail.linkSimilarToolSub")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Tool ID</label>
|
<label className="text-sm font-medium">{t("detail.toolId")}</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Enter target tool ID"
|
placeholder={t("detail.toolIdPlaceholder")}
|
||||||
value={linkToolId}
|
value={linkToolId}
|
||||||
onChange={(e) => setLinkToolId(e.target.value)}
|
onChange={(e) => setLinkToolId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Relation Type</label>
|
<label className="text-sm font-medium">{t("detail.relationType")}</label>
|
||||||
<Select value={linkType} onValueChange={setLinkType}>
|
<Select value={linkType} onValueChange={setLinkType}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="similar">Similar</SelectItem>
|
<SelectItem value="similar">{t("detail.relationSimilar")}</SelectItem>
|
||||||
<SelectItem value="replaces">Replaces</SelectItem>
|
<SelectItem value="replaces">{t("detail.relationReplaces")}</SelectItem>
|
||||||
<SelectItem value="superseded_by">Superseded By</SelectItem>
|
<SelectItem value="superseded_by">{t("detail.relationSupersededBy")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Notes (Optional)</label>
|
<label className="text-sm font-medium">{t("detail.notesOptional")}</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Why are these tools related?"
|
placeholder={t("detail.notesPlaceholder")}
|
||||||
value={linkNotes}
|
value={linkNotes}
|
||||||
onChange={(e) => setLinkNotes(e.target.value)}
|
onChange={(e) => setLinkNotes(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>{t("common.cancel")}</Button>
|
||||||
<Button onClick={handleCreateRelation} disabled={!linkToolId}>Create Link</Button>
|
<Button onClick={handleCreateRelation} disabled={!linkToolId}>{t("detail.createLink")}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -585,7 +586,7 @@ export default function ToolDetail() {
|
|||||||
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
|
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold">
|
<div className="text-lg font-bold">
|
||||||
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : t("detail.licenseFree")}
|
||||||
</div>
|
</div>
|
||||||
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +606,7 @@ export default function ToolDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground py-4">No cost information added yet.</p>
|
<p className="text-sm text-muted-foreground py-4">{t("detail.noCostInfo")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -614,42 +615,44 @@ export default function ToolDetail() {
|
|||||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}</DialogTitle>
|
<DialogTitle>
|
||||||
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
|
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">License Type</label>
|
<label className="text-sm font-medium">{t("detail.licenseType")}</label>
|
||||||
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
|
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">{t("detail.licenseFree")}</SelectItem>
|
||||||
<SelectItem value="subscription">Subscription</SelectItem>
|
<SelectItem value="subscription">{t("detail.licenseSubscription")}</SelectItem>
|
||||||
<SelectItem value="one_time">One-Time</SelectItem>
|
<SelectItem value="one_time">{t("detail.licenseOneTime")}</SelectItem>
|
||||||
<SelectItem value="usage_based">Usage-Based</SelectItem>
|
<SelectItem value="usage_based">{t("detail.licenseUsageBased")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{costLicenseType === "subscription" && (
|
{costLicenseType === "subscription" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Billing Period</label>
|
<label className="text-sm font-medium">{t("detail.billingPeriod")}</label>
|
||||||
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
|
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="monthly">Monthly</SelectItem>
|
<SelectItem value="monthly">{t("detail.billingMonthly")}</SelectItem>
|
||||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
<SelectItem value="quarterly">{t("detail.billingQuarterly")}</SelectItem>
|
||||||
<SelectItem value="yearly">Yearly</SelectItem>
|
<SelectItem value="yearly">{t("detail.billingYearly")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Cost</label>
|
<label className="text-sm font-medium">{t("detail.costLabel")}</label>
|
||||||
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
|
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Currency</label>
|
<label className="text-sm font-medium">{t("detail.currency")}</label>
|
||||||
<Select value={costCurrency} onValueChange={setCostCurrency}>
|
<Select value={costCurrency} onValueChange={setCostCurrency}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -662,13 +665,13 @@ export default function ToolDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Notes</label>
|
<label className="text-sm font-medium">{t("detail.costNotes")}</label>
|
||||||
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
<Textarea placeholder={t("detail.costNotesPlaceholder")} value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>{t("common.cancel")}</Button>
|
||||||
<Button onClick={handleSaveCost}>Save</Button>
|
<Button onClick={handleSaveCost}>{t("common.save")}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -739,9 +742,9 @@ export default function ToolDetail() {
|
|||||||
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
|
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
|
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Line type="monotone" dataKey="combined" name="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="combined" name={t("detail.score")} stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
||||||
<Line type="monotone" dataKey="usefulness" name="Usefulness" stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
<Line type="monotone" dataKey="usefulness" name={t("detail.usefulness")} stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||||
<Line type="monotone" dataKey="usability" name="Usability" stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
<Line type="monotone" dataKey="usability" name={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,9 +766,12 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
{isReviewFormOpen && (
|
{isReviewFormOpen && (
|
||||||
<Card className="border-primary shadow-sm">
|
<Card className="border-primary shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle>{t("detail.addReview")}</CardTitle>
|
<div className="space-y-1.5">
|
||||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
<CardTitle>{t("detail.addReview")}</CardTitle>
|
||||||
|
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -776,7 +782,9 @@ export default function ToolDetail() {
|
|||||||
name="usefulness"
|
name="usefulness"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("detail.usefulness")}</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("detail.usefulness")}
|
||||||
|
</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -794,7 +802,9 @@ export default function ToolDetail() {
|
|||||||
name="usability"
|
name="usability"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("detail.usability")}</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("detail.usability")}
|
||||||
|
</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -814,10 +824,12 @@ export default function ToolDetail() {
|
|||||||
name="comment"
|
name="comment"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Comment (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("detail.commentOptional")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What do you think about this tool?"
|
placeholder={t("detail.commentPlaceholder")}
|
||||||
className="resize-none min-h-[100px]"
|
className="resize-none min-h-[100px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -832,9 +844,11 @@ export default function ToolDetail() {
|
|||||||
name="reviewerName"
|
name="reviewerName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("detail.nameOptional")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Anonymous" {...field} />
|
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -848,7 +862,7 @@ export default function ToolDetail() {
|
|||||||
onClick={() => setIsReviewFormOpen(false)}
|
onClick={() => setIsReviewFormOpen(false)}
|
||||||
disabled={createRating.isPending}
|
disabled={createRating.isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createRating.isPending}>
|
<Button type="submit" disabled={createRating.isPending}>
|
||||||
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||||
@@ -871,7 +885,7 @@ export default function ToolDetail() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">{rating.reviewerName || "Anonymous Engineer"}</span>
|
<span className="font-semibold">{rating.reviewerName || t("detail.anonymousEngineer")}</span>
|
||||||
<span className="text-muted-foreground text-sm ml-2">
|
<span className="text-muted-foreground text-sm ml-2">
|
||||||
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
||||||
</span>
|
</span>
|
||||||
@@ -901,7 +915,7 @@ export default function ToolDetail() {
|
|||||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
||||||
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||||
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
|
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
|
||||||
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
<p className="text-muted-foreground mt-1">{t("detail.beFirstToReview")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -914,23 +928,23 @@ export default function ToolDetail() {
|
|||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
|
<AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{hasTrash ? (
|
{hasTrash ? (
|
||||||
<>This will move <span className="font-medium">{tool?.name}</span> to the trash. It can be restored later.</>
|
<>{t("detail.deleteToTrash", { name: tool?.name })}</>
|
||||||
) : (
|
) : (
|
||||||
<>This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.</>
|
<>{t("detail.deletePermanent", { name: tool?.name })}</>
|
||||||
)}
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleteTool.isPending}
|
disabled={deleteTool.isPending}
|
||||||
>
|
>
|
||||||
{deleteTool.isPending ? "Deleting…" : "Delete"}
|
{deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -30,28 +30,35 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
|||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
|
||||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
|
||||||
description: z.string().min(10, "Description must be at least 10 characters"),
|
|
||||||
category: z.string().min(2, "Category is required"),
|
|
||||||
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
|
||||||
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
|
||||||
features: z.array(z.object({ value: z.string() })).optional(),
|
|
||||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
function buildToolSchema(t: (key: string) => string) {
|
||||||
|
return z.object({
|
||||||
|
name: z.string().min(2, t("toolForm.nameMin")),
|
||||||
|
description: z.string().min(10, t("toolForm.descriptionMin")),
|
||||||
|
category: z.string().min(2, t("toolForm.categoryRequired")),
|
||||||
|
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||||
|
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||||
|
features: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolEdit() {
|
export default function ToolEdit() {
|
||||||
const [match, params] = useRoute("/tools/:id/edit");
|
const [match, params] = useRoute("/tools/:id/edit");
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const id = parseInt(params?.id || "0", 10);
|
const id = parseInt(params?.id || "0", 10);
|
||||||
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const updateTool = useUpdateTool();
|
const updateTool = useUpdateTool();
|
||||||
const { isAuthenticated, isAdmin } = useAuth();
|
const { isAuthenticated, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const toolSchema = buildToolSchema(t);
|
||||||
|
|
||||||
const { data: tool, isLoading } = useGetTool(id, {
|
const { data: tool, isLoading } = useGetTool(id, {
|
||||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
||||||
});
|
});
|
||||||
@@ -106,7 +113,7 @@ export default function ToolEdit() {
|
|||||||
{ id, data: payload },
|
{ id, data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Tool updated", description: "Changes saved successfully." });
|
toast({ title: t("toolForm.toastUpdated"), description: t("toolForm.toastUpdatedSub") });
|
||||||
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
@@ -118,8 +125,8 @@ export default function ToolEdit() {
|
|||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to update tool",
|
title: t("toolForm.toastUpdateFailed"),
|
||||||
description: err.data?.error || err.message || "An unexpected error occurred.",
|
description: err.data?.error || err.message || t("detail.unexpectedError"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -136,13 +143,13 @@ export default function ToolEdit() {
|
|||||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
<Link href={`/tools/${id}`}>
|
<Link href={`/tools/${id}`}>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
|
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToTool")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.editTitle")}</h1>
|
||||||
<p className="text-muted-foreground">Update tool details and metadata.</p>
|
<p className="text-muted-foreground">{t("toolForm.editSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -155,12 +162,15 @@ export default function ToolEdit() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-1.5">
|
||||||
<Pencil className="w-5 h-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
Tool Details
|
<Pencil className="w-5 h-5 text-primary" />
|
||||||
</CardTitle>
|
{t("toolForm.toolDetails")}
|
||||||
<CardDescription>Modify the tool information below.</CardDescription>
|
</CardTitle>
|
||||||
|
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -171,9 +181,11 @@ export default function ToolEdit() {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.name")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Tool name" {...field} />
|
<Input placeholder={t("toolForm.name")} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -184,7 +196,9 @@ export default function ToolEdit() {
|
|||||||
name="category"
|
name="category"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Category</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.category")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -198,10 +212,12 @@ export default function ToolEdit() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="websiteUrl"
|
name="websiteUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Website URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.websiteUrlOptional")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="https://..." type="url" {...field} />
|
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -212,24 +228,26 @@ export default function ToolEdit() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="iconUrl"
|
name="iconUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.iconUrlOptional")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
<img
|
<img
|
||||||
src={field.value}
|
src={field.value}
|
||||||
alt="icon preview"
|
alt={t("toolForm.iconPreview")}
|
||||||
className="w-full h-full object-contain p-0.5"
|
className="w-full h-full object-contain p-0.5"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">img</span>
|
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder={t("toolForm.logoPlaceholder")}
|
||||||
type="url"
|
type="url"
|
||||||
{...field}
|
{...field}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
@@ -245,11 +263,13 @@ export default function ToolEdit() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.description")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What does this tool do?"
|
placeholder={t("toolForm.descriptionPlaceholderEdit")}
|
||||||
className="min-h-[120px] resize-none"
|
className="min-h-[120px] resize-none"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -262,11 +282,13 @@ export default function ToolEdit() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Features</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
{t("toolForm.features")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -281,7 +303,7 @@ export default function ToolEdit() {
|
|||||||
<FeatureInput
|
<FeatureInput
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder="e.g. Real-time collaboration"
|
placeholder={t("toolForm.featurePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Button
|
<Button
|
||||||
@@ -298,7 +320,7 @@ export default function ToolEdit() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{featureFields.length === 0 && (
|
{featureFields.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,11 +328,13 @@ export default function ToolEdit() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Tags</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
{t("toolForm.tags")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -323,7 +347,7 @@ export default function ToolEdit() {
|
|||||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
placeholder="Tag"
|
placeholder={t("toolForm.tag")}
|
||||||
className="pr-8 h-9 text-sm"
|
className="pr-8 h-9 text-sm"
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
@@ -347,10 +371,10 @@ export default function ToolEdit() {
|
|||||||
|
|
||||||
<div className="pt-6 border-t flex justify-end gap-3">
|
<div className="pt-6 border-t flex justify-end gap-3">
|
||||||
<Button type="button" variant="outline" asChild>
|
<Button type="button" variant="outline" asChild>
|
||||||
<Link href={`/tools/${id}`}>Cancel</Link>
|
<Link href={`/tools/${id}`}>{t("common.cancel")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
||||||
{updateTool.isPending ? "Saving…" : "Save Changes"}
|
{updateTool.isPending ? t("toolForm.saving") : t("toolForm.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -18,26 +18,33 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
|||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
|
||||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
|
||||||
description: z.string().min(10, "Description must be at least 10 characters"),
|
|
||||||
category: z.string().min(2, "Category is required"),
|
|
||||||
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
|
||||||
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
|
||||||
features: z.array(z.object({ value: z.string() })).optional(),
|
|
||||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
function buildToolSchema(t: (key: string) => string) {
|
||||||
|
return z.object({
|
||||||
|
name: z.string().min(2, t("toolForm.nameMin")),
|
||||||
|
description: z.string().min(10, t("toolForm.descriptionMin")),
|
||||||
|
category: z.string().min(2, t("toolForm.categoryRequired")),
|
||||||
|
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||||
|
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||||
|
features: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolNew() {
|
export default function ToolNew() {
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const createTool = useCreateTool();
|
const createTool = useCreateTool();
|
||||||
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
||||||
|
|
||||||
|
const toolSchema = buildToolSchema(t);
|
||||||
|
|
||||||
const form = useForm<ToolFormValues>({
|
const form = useForm<ToolFormValues>({
|
||||||
resolver: zodResolver(toolSchema),
|
resolver: zodResolver(toolSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -74,7 +81,7 @@ export default function ToolNew() {
|
|||||||
{ data: payload },
|
{ data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: (newTool) => {
|
onSuccess: (newTool) => {
|
||||||
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
toast({ title: t("toolForm.toastAdded"), description: t("toolForm.toastAddedSub") });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
@@ -84,7 +91,7 @@ export default function ToolNew() {
|
|||||||
setLocation(`/tools/${newTool.id}`);
|
setLocation(`/tools/${newTool.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
|
toast({ title: t("toolForm.toastAddFailed"), description: t("detail.unexpectedError"), variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -95,35 +102,38 @@ export default function ToolNew() {
|
|||||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
<Link href="/tools">
|
<Link href="/tools">
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to browse
|
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToBrowse")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
|
||||||
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
|
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!authLoading && !isAuthenticated && (
|
{!authLoading && !isAuthenticated && (
|
||||||
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
|
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
|
||||||
<LogIn className="w-5 h-5 text-primary shrink-0" />
|
<LogIn className="w-5 h-5 text-primary shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium">Sign in required</p>
|
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
|
||||||
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
|
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
||||||
Sign in
|
{t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-1.5">
|
||||||
<Wrench className="w-5 h-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
Tool Details
|
<Wrench className="w-5 h-5 text-primary" />
|
||||||
</CardTitle>
|
{t("toolForm.toolDetails")}
|
||||||
<CardDescription>Provide the basic information about the tool.</CardDescription>
|
</CardTitle>
|
||||||
|
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -134,9 +144,11 @@ export default function ToolNew() {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.name")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -148,7 +160,9 @@ export default function ToolNew() {
|
|||||||
name="category"
|
name="category"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Category</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.category")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -161,14 +175,16 @@ export default function ToolNew() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="websiteUrl"
|
name="websiteUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Website URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.websiteUrlOptional")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -180,23 +196,25 @@ export default function ToolNew() {
|
|||||||
name="iconUrl"
|
name="iconUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.iconUrlOptional")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
<img
|
<img
|
||||||
src={field.value}
|
src={field.value}
|
||||||
alt="icon preview"
|
alt={t("toolForm.iconPreview")}
|
||||||
className="w-full h-full object-contain p-0.5"
|
className="w-full h-full object-contain p-0.5"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">img</span>
|
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder={t("toolForm.logoPlaceholder")}
|
||||||
type="url"
|
type="url"
|
||||||
{...field}
|
{...field}
|
||||||
data-testid="input-tool-icon-url"
|
data-testid="input-tool-icon-url"
|
||||||
@@ -214,10 +232,12 @@ export default function ToolNew() {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("toolForm.description")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What does this tool do? Why do people use it?"
|
placeholder={t("toolForm.descriptionPlaceholderNew")}
|
||||||
className="min-h-[120px] resize-none"
|
className="min-h-[120px] resize-none"
|
||||||
{...field}
|
{...field}
|
||||||
data-testid="input-tool-description"
|
data-testid="input-tool-description"
|
||||||
@@ -231,8 +251,10 @@ export default function ToolNew() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Features</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
{t("toolForm.features")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -241,7 +263,7 @@ export default function ToolNew() {
|
|||||||
onClick={() => appendFeature({ value: "" })}
|
onClick={() => appendFeature({ value: "" })}
|
||||||
data-testid="button-add-feature"
|
data-testid="button-add-feature"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,7 +279,7 @@ export default function ToolNew() {
|
|||||||
<FeatureInput
|
<FeatureInput
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder="e.g. Real-time collaboration"
|
placeholder={t("toolForm.featurePlaceholder")}
|
||||||
data-testid={`input-feature-${index}`}
|
data-testid={`input-feature-${index}`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -276,7 +298,7 @@ export default function ToolNew() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{featureFields.length === 0 && (
|
{featureFields.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,8 +306,10 @@ export default function ToolNew() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Tags</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
{t("toolForm.tags")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -294,7 +318,7 @@ export default function ToolNew() {
|
|||||||
onClick={() => appendTag({ value: "" })}
|
onClick={() => appendTag({ value: "" })}
|
||||||
data-testid="button-add-tag"
|
data-testid="button-add-tag"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,7 +332,7 @@ export default function ToolNew() {
|
|||||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
placeholder="Tag"
|
placeholder={t("toolForm.tagPlaceholder")}
|
||||||
className="pr-8 h-9 text-sm"
|
className="pr-8 h-9 text-sm"
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
@@ -339,7 +363,7 @@ export default function ToolNew() {
|
|||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
data-testid="button-submit-tool"
|
data-testid="button-submit-tool"
|
||||||
>
|
>
|
||||||
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
|
{createTool.isPending ? t("toolForm.addingTool") : t("toolForm.submitTool")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal } from "lucide-react";
|
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal, Plus } from "lucide-react";
|
||||||
import { Link, useLocation, useSearch } from "wouter";
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -230,11 +230,16 @@ export default function ToolsBrowse() {
|
|||||||
<div className="space-y-6 pb-8">
|
<div className="space-y-6 pb-8">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("nav.browseTools")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -359,7 +364,7 @@ export default function ToolsBrowse() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
||||||
className="rounded-sm hover:bg-muted p-0.5"
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
aria-label={`Remove feature ${f}`}
|
aria-label={t("filter.removeFeature", { feature: f })}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -371,14 +376,14 @@ export default function ToolsBrowse() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setMinRating(null)}
|
onClick={() => setMinRating(null)}
|
||||||
className="rounded-sm hover:bg-muted p-0.5"
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
aria-label="Remove min rating"
|
aria-label={t("filter.removeMinRating")}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
|
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
|
||||||
Clear all
|
{t("common.clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -518,15 +523,15 @@ export default function ToolsBrowse() {
|
|||||||
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
|
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
|
{t("browse.comparePremiumSub")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Not now</AlertDialogCancel>
|
<AlertDialogCancel>{t("browse.notNow")}</AlertDialogCancel>
|
||||||
<AlertDialogAction asChild>
|
<AlertDialogAction asChild>
|
||||||
<a href="/admin?tab=plan">Upgrade to Premium</a>
|
<a href="/admin?tab=plan">{t("browse.upgradePremium")}</a>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
getListAllTagsQueryKey,
|
getListAllTagsQueryKey,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
type Tool,
|
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -91,12 +90,12 @@ export default function Trash() {
|
|||||||
{ data: { ids } },
|
{ data: { ids } },
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
toast({ title: "Tools restored", description: `${res.restored ?? ids.length} tool(s) restored.` });
|
toast({ title: t("trash.toastRestored"), description: t("trash.toastRestoredSub", { count: res.restored ?? ids.length }) });
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to restore", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("trash.toastRestoreFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -107,13 +106,13 @@ export default function Trash() {
|
|||||||
{ data: { ids } },
|
{ data: { ids } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Tools deleted", description: `${ids.length} tool(s) permanently removed.` });
|
toast({ title: t("trash.toastDeleted"), description: t("trash.toastDeletedSub", { count: ids.length }) });
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to delete", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("trash.toastDeleteFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -124,13 +123,13 @@ export default function Trash() {
|
|||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
toast({ title: "Trash emptied", description: `${res.deleted ?? 0} tool(s) permanently removed.` });
|
toast({ title: t("trash.toastEmptied"), description: t("trash.toastEmptiedSub", { count: res.deleted ?? 0 }) });
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setConfirmEmpty(false);
|
setConfirmEmpty(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({ title: "Failed to empty trash", description: err.data?.error ?? err.message, variant: "destructive" });
|
toast({ title: t("trash.toastEmptyFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -157,7 +156,9 @@ export default function Trash() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("trash.title")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,48 +228,48 @@ export default function Trash() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.size === trashed.length && trashed.length > 0}
|
checked={selected.size === trashed.length && trashed.length > 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label={t("trash.selectAll")}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t("trash.name")}</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>{t("trash.category")}</TableHead>
|
||||||
<TableHead>{t("trash.deletedAt")}</TableHead>
|
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||||
<TableHead>{t("trash.deletedBy")}</TableHead>
|
<TableHead>{t("trash.deletedBy")}</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">{t("trash.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{trashed.map((t: Tool) => (
|
{trashed.map((tool) => (
|
||||||
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
|
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.has(t.id)}
|
checked={selected.has(tool.id)}
|
||||||
onCheckedChange={() => toggle(t.id)}
|
onCheckedChange={() => toggle(tool.id)}
|
||||||
aria-label={`Select ${t.name}`}
|
aria-label={t("trash.selectName", { name: tool.name })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{t.category}</Badge>
|
<Badge variant="outline">{tool.category}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{t.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
|
{tool.deletedAt ? format(new Date(tool.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{tool.deletedBy ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
|
<Button variant="ghost" size="sm" onClick={() => handleRestore([tool.id])} disabled={restore.isPending}>
|
||||||
<RotateCcw className="w-3.5 h-3.5" /> Restore
|
<RotateCcw className="w-3.5 h-3.5" /> {t("trash.restoreAction")}
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => handleDeletePermanent([t.id])}
|
onClick={() => handleDeletePermanent([tool.id])}
|
||||||
disabled={deletePermanent.isPending}
|
disabled={deletePermanent.isPending}
|
||||||
>
|
>
|
||||||
<TrashIcon className="w-3.5 h-3.5" /> Delete
|
<TrashIcon className="w-3.5 h-3.5" /> {t("trash.deleteAction")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,13 +286,13 @@ export default function Trash() {
|
|||||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
|
<AlertDialogTitle>{t("trash.deleteConfirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
|
{t("trash.deleteConfirmSub")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => handleDeletePermanent(selectedIds)}
|
onClick={() => handleDeletePermanent(selectedIds)}
|
||||||
@@ -305,13 +306,13 @@ export default function Trash() {
|
|||||||
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
|
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
|
<AlertDialogTitle>{t("trash.emptyConfirmTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.
|
{t("trash.emptyConfirmSub", { count: trashed.length })}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={handleEmpty}
|
onClick={handleEmpty}
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export default function Watchlist() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("watchlist.title")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ export default function Watchlist() {
|
|||||||
{t("watchlist.emptySub")}
|
{t("watchlist.emptySub")}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="/tools">Browse tools</a>
|
<a href="/tools">{t("common.browseTools")}</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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,105 @@
|
|||||||
|
---
|
||||||
|
title: Administration
|
||||||
|
order: 13
|
||||||
|
---
|
||||||
|
|
||||||
|
# Administration
|
||||||
|
|
||||||
|
The **Admin** area (`/admin`) is exclusively accessible to admins.
|
||||||
|
Without the admin role, access is denied.
|
||||||
|
|
||||||
|
> At the top right, the **Redundancy dashboard** button leads to the automatic
|
||||||
|
> duplicate detection (see [Redundancy](/docs/handbook/redundanz)).
|
||||||
|
|
||||||
|
## "Users" tab
|
||||||
|
|
||||||
|
Management of local accounts.
|
||||||
|
|
||||||
|
- **Add user:** username (required), password (at least 6 characters),
|
||||||
|
email (optional), **role** (User/Admin), **plan** (Free/Premium/Enterprise).
|
||||||
|
- **Edit user:** set role, plan and (for local accounts) a new password.
|
||||||
|
For OIDC accounts, password management is offered in the identity provider
|
||||||
|
(e.g. Keycloak).
|
||||||
|
- **Delete user:** permanently removes the account (not for your own account).
|
||||||
|
|
||||||
|
API reference:
|
||||||
|
[`POST /users`](/docs/reference/endpoints/users#createUser),
|
||||||
|
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
|
||||||
|
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
|
||||||
|
|
||||||
|
## "Tools" tab
|
||||||
|
|
||||||
|
Central access to the tool catalog.
|
||||||
|
|
||||||
|
- **Search** for tools.
|
||||||
|
- View, edit or move individual tools to the trash.
|
||||||
|
- **Bulk action:** select multiple tools and move them to the trash
|
||||||
|
(confirmation dialog; soft-deleted tools are removed from all public views
|
||||||
|
and can be restored or permanently deleted).
|
||||||
|
- **Import** (premium): via the **Import** button at the top right, multiple
|
||||||
|
tools can be created at once. The format is detected automatically (CSV,
|
||||||
|
JSON or YAML); for CSV the delimiter can be chosen (auto-detect, comma,
|
||||||
|
semicolon or tab). Data can be pasted directly into the form or uploaded
|
||||||
|
as a file.
|
||||||
|
|
||||||
|
Supported fields: `name` (required), `description`, `category`, `websiteUrl`,
|
||||||
|
`iconUrl`, `features`, `tags` (`features`/`tags` are `|`-separated lists).
|
||||||
|
|
||||||
|
**CSV example:**
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name;description;category;features;tags
|
||||||
|
Grafana;Observability and dashboard platform;Monitoring;Dashboards|Alerts;devops
|
||||||
|
Prometheus;Time-series database;Monitoring;Metrics;devops
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "Grafana", "category": "Monitoring", "features": ["Dashboards"] },
|
||||||
|
{ "name": "Prometheus", "category": "Monitoring" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**YAML example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Grafana
|
||||||
|
category: Monitoring
|
||||||
|
features:
|
||||||
|
- Dashboards
|
||||||
|
- name: Prometheus
|
||||||
|
category: Monitoring
|
||||||
|
```
|
||||||
|
|
||||||
|
During import, individual faulty rows are skipped and listed with their row
|
||||||
|
number; correct rows are still created. Every import produces audit-log
|
||||||
|
entries.
|
||||||
|
|
||||||
|
## "Audit log" tab
|
||||||
|
|
||||||
|
Chronological log of all creation, change and deletion operations
|
||||||
|
(max. 100 entries): action, entity + ID, timestamp, executing person and
|
||||||
|
changed fields.
|
||||||
|
|
||||||
|
API reference: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
|
||||||
|
|
||||||
|
## "System" tab
|
||||||
|
|
||||||
|
Version information of the running instance:
|
||||||
|
|
||||||
|
- **Version** (e.g. `v0.8.1`),
|
||||||
|
- **Commit** (7-digit SHA, linked to the repository),
|
||||||
|
- **Build date**,
|
||||||
|
- **Trash retention** ("N days" or "Forever").
|
||||||
|
|
||||||
|
## Tool links (Admin)
|
||||||
|
|
||||||
|
On the detail page of a tool you can manage **links** as an admin
|
||||||
|
(own/"manual" as well as automatically detected ones):
|
||||||
|
|
||||||
|
- **Link tool:** dialog with tool ID, **relationship type**
|
||||||
|
(Similar / Replaces / Superseded by) and optional notes.
|
||||||
|
- Relationship types are displayed as badges on the detail page.
|
||||||
|
- Manual links can be removed again via the trash icon.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: Administration
|
||||||
|
order: 13
|
||||||
|
---
|
||||||
|
|
||||||
|
# Administration
|
||||||
|
|
||||||
|
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
|
||||||
|
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
|
||||||
|
|
||||||
|
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
|
||||||
|
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
|
||||||
|
|
||||||
|
## Tab „Nutzer"
|
||||||
|
|
||||||
|
Verwaltung der lokalen Konten.
|
||||||
|
|
||||||
|
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
|
||||||
|
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
|
||||||
|
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
|
||||||
|
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
|
||||||
|
(z. B. Keycloak) angeboten.
|
||||||
|
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
|
||||||
|
|
||||||
|
API-Referenz:
|
||||||
|
[`POST /users`](/docs/reference/endpoints/users#createUser),
|
||||||
|
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
|
||||||
|
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
|
||||||
|
|
||||||
|
## Tab „Tools"
|
||||||
|
|
||||||
|
Zentraler Zugriff auf den Tool-Katalog.
|
||||||
|
|
||||||
|
- **Suchen** nach Tools.
|
||||||
|
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
|
||||||
|
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
|
||||||
|
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
|
||||||
|
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
|
||||||
|
- **Importieren** (Premium): über die Schaltfläche **Importieren** oben rechts
|
||||||
|
können mehrere Tools gleichzeitig angelegt werden. Das Format wird automatisch
|
||||||
|
erkannt (CSV, JSON oder YAML); für CSV lässt sich der Trenner wählen
|
||||||
|
(automatisch, Komma, Semikolon oder Tabulator). Die Daten lassen sich direkt
|
||||||
|
ins Formular einfügen oder als Datei hochladen.
|
||||||
|
|
||||||
|
Unterstützte Felder: `name` (Pflicht), `description`, `category`, `websiteUrl`,
|
||||||
|
`iconUrl`, `features`, `tags` (`features`/`tags` sind mit `|` getrennte Listen).
|
||||||
|
|
||||||
|
**CSV-Beispiel:**
|
||||||
|
|
||||||
|
```csv
|
||||||
|
name;description;category;features;tags
|
||||||
|
Grafana;Observability- und Dashboard-Plattform;Monitoring;Dashboards|Alerts;devops
|
||||||
|
Prometheus;Zeitreihen-Datenbank;Monitoring;Metrics;devops
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON-Beispiel:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "Grafana", "category": "Monitoring", "features": ["Dashboards"] },
|
||||||
|
{ "name": "Prometheus", "category": "Monitoring" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**YAML-Beispiel:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Grafana
|
||||||
|
category: Monitoring
|
||||||
|
features:
|
||||||
|
- Dashboards
|
||||||
|
- name: Prometheus
|
||||||
|
category: Monitoring
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Import werden einzelne fehlerhafte Zeilen übersprungen und mit Zeilennummer
|
||||||
|
aufgelistet; korrekte Zeilen werden trotzdem angelegt. Jeder Import erzeugt
|
||||||
|
Audit-Log-Einträge.
|
||||||
|
|
||||||
|
## Tab „Audit-Log"
|
||||||
|
|
||||||
|
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
|
||||||
|
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
|
||||||
|
geänderte Felder.
|
||||||
|
|
||||||
|
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
|
||||||
|
|
||||||
|
## Tab „System"
|
||||||
|
|
||||||
|
Versionsinformationen der laufenden Instanz:
|
||||||
|
|
||||||
|
- **Version** (z. B. `v0.8.1`),
|
||||||
|
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
|
||||||
|
- **Build-Datum**,
|
||||||
|
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
|
||||||
|
|
||||||
|
## Tool-Verknüpfungen (Admin)
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
|
||||||
|
(eigene/„manual" sowie automatisch erkannte) verwalten:
|
||||||
|
|
||||||
|
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
|
||||||
|
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
|
||||||
|
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
|
||||||
|
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: Analytics
|
||||||
|
order: 10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
|
||||||
|
The **Analytics** area (`/analytics`) is a public dashboard with
|
||||||
|
metrics and charts based on all tools and ratings.
|
||||||
|
|
||||||
|
## Metrics (KPI cards)
|
||||||
|
|
||||||
|
- **Number of tools** — how many tools are recorded in the catalog.
|
||||||
|
- **Number of ratings** — how many ratings were submitted in total.
|
||||||
|
- **Active categories** — how many categories exist.
|
||||||
|
- **Average rating** — global combined value.
|
||||||
|
|
||||||
|
## Charts
|
||||||
|
|
||||||
|
| Chart | Content |
|
||||||
|
| --- | --- |
|
||||||
|
| **Top 8 tools** | Bar chart of the tools with the highest combined score (0–5) |
|
||||||
|
| **Tools per category** | Radar chart of the number of tools per category |
|
||||||
|
| **Score distribution** | Two horizontal bar charts (usefulness & usability) per star |
|
||||||
|
|
||||||
|
The charts are interactive (tooltips on hover).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
|
||||||
|
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
|
||||||
|
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
|
||||||
|
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: Analytics
|
||||||
|
order: 10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
|
||||||
|
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
|
||||||
|
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
|
||||||
|
|
||||||
|
## Kennzahlen (KPI-Karten)
|
||||||
|
|
||||||
|
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
|
||||||
|
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
|
||||||
|
- **Aktive Kategorien** — wie viele Kategorien existieren.
|
||||||
|
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
|
||||||
|
|
||||||
|
## Diagramme
|
||||||
|
|
||||||
|
| Diagramm | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (0–5) |
|
||||||
|
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
|
||||||
|
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
|
||||||
|
|
||||||
|
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
|
||||||
|
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
|
||||||
|
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
|
||||||
|
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
title: Rating
|
||||||
|
order: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rating
|
||||||
|
|
||||||
|
On the detail page of a tool you can share your experience. Click on
|
||||||
|
**Submit a rating** (requires an account).
|
||||||
|
|
||||||
|
## Form fields
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Usefulness** | Yes | 1–5 stars |
|
||||||
|
| **Usability** | Yes | 1–5 stars |
|
||||||
|
| **Comment** | No | Free text |
|
||||||
|
| **Name** | No | Defaults to "Anonymous" |
|
||||||
|
|
||||||
|
Next to the fields, the **? icon** links directly to the associated field
|
||||||
|
description in the [data model reference](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## What happens after submitting?
|
||||||
|
|
||||||
|
- Your rating is saved immediately and appears in the **rating list** of the
|
||||||
|
detail page.
|
||||||
|
- The **averages** (usefulness, usability, combined) and the **score
|
||||||
|
distribution** are updated.
|
||||||
|
- The **statistics** in the [Analytics](/docs/handbook/analytics) section are
|
||||||
|
recalculated.
|
||||||
|
|
||||||
|
## Statistic sections on the detail page
|
||||||
|
|
||||||
|
- **Rating overview:** usefulness & usability as an average with progress
|
||||||
|
bars.
|
||||||
|
- **Score distribution:** number of ratings per star (1★–5★).
|
||||||
|
- **History:** line chart of combined/individual values over time
|
||||||
|
(only visible once there are several ratings).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Submit a rating
|
||||||
|
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Ratings of a tool
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
title: Bewerten
|
||||||
|
order: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
|
||||||
|
**Bewertung abgeben** (erfordert ein Konto).
|
||||||
|
|
||||||
|
## Formularfelder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweise |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Nützlichkeit** | Ja | 1–5 Sterne |
|
||||||
|
| **Bedienbarkeit** | Ja | 1–5 Sterne |
|
||||||
|
| **Kommentar** | Nein | Freitext |
|
||||||
|
| **Name** | Nein | Standard „Anonym" |
|
||||||
|
|
||||||
|
Neben den Feldern führt das **?‑Icon** direkt zur zugehörigen Feldbeschreibung
|
||||||
|
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## Was passiert nach dem Abgeben?
|
||||||
|
|
||||||
|
- Deine Bewertung wird sofort gespeichert und erscheint in der
|
||||||
|
**Bewertungsliste** der Detailseite.
|
||||||
|
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
|
||||||
|
**Punkteverteilung** werden aktualisiert.
|
||||||
|
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
|
||||||
|
neu berechnet.
|
||||||
|
|
||||||
|
## Statistik-Bereiche auf der Detailseite
|
||||||
|
|
||||||
|
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
|
||||||
|
Fortschrittsbalken.
|
||||||
|
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
|
||||||
|
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
|
||||||
|
(erst ab mehreren Bewertungen sichtbar).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
|
||||||
|
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Data model
|
||||||
|
order: 17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Data model
|
||||||
|
|
||||||
|
This chapter explains the central data objects of toolr at the application
|
||||||
|
level. The complete, automatically generated reference of all fields,
|
||||||
|
types and constraints can be found in the
|
||||||
|
[API reference](/docs/reference/schemas/tool).
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
The heart of it all: a tool recorded in the catalog.
|
||||||
|
|
||||||
|
| Property | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Unique identifier |
|
||||||
|
| `name` | Display name |
|
||||||
|
| `description` | Description (what does the tool do?) |
|
||||||
|
| `category` | Category assignment |
|
||||||
|
| `websiteUrl` | Official website (optional) |
|
||||||
|
| `iconUrl` | Logo/icon URL (optional) |
|
||||||
|
| `features` | List of capabilities |
|
||||||
|
| `tags` | List of keywords |
|
||||||
|
| `createdAt` / `updatedAt` | Timestamps |
|
||||||
|
| `createdBy` | Person who created it |
|
||||||
|
| `deletedAt` / `deletedBy` | Soft deletion (trash) |
|
||||||
|
|
||||||
|
Input forms use the derived schemas
|
||||||
|
[`ToolInput`](/docs/reference/schemas/toolinput) and
|
||||||
|
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
|
||||||
|
Aggregated views are provided by [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
|
||||||
|
(e.g. with average rating).
|
||||||
|
|
||||||
|
## Rating (Bewertung)
|
||||||
|
|
||||||
|
A single rating for a tool:
|
||||||
|
|
||||||
|
- `usefulness` (Nützlichkeit, 1–5) and `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- optional `comment` and a display name (`reviewerName`)
|
||||||
|
- timestamp
|
||||||
|
|
||||||
|
Input schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## User & Auth
|
||||||
|
|
||||||
|
- [`User`](/docs/reference/schemas/user) — user account with role (User/Admin)
|
||||||
|
and plan (Free/Premium/Enterprise).
|
||||||
|
- [`AuthUser`](/docs/reference/schemas/authuser) — the logged-in profile
|
||||||
|
including `entitlements` (available features).
|
||||||
|
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — view and
|
||||||
|
density preferences as well as the `watchlist` (list of tool IDs).
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
The statistics endpoints provide aggregated data:
|
||||||
|
|
||||||
|
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — global
|
||||||
|
metrics (number of tools/ratings, categories, average).
|
||||||
|
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — an entry of the
|
||||||
|
top tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — number of tools per
|
||||||
|
category.
|
||||||
|
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
|
||||||
|
score distribution (usefulness & usability).
|
||||||
|
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — a value bucket.
|
||||||
|
|
||||||
|
## Additional
|
||||||
|
|
||||||
|
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — version, commit SHA,
|
||||||
|
build date and trash retention of the running instance.
|
||||||
|
- [`AuditLog`](/docs/reference/schemas/auditlog) — a log entry
|
||||||
|
(action, entity, timestamp, actor, changes).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Datenmodell
|
||||||
|
order: 17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Datenmodell
|
||||||
|
|
||||||
|
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
|
||||||
|
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
|
||||||
|
Typen und Constraints findest du in der
|
||||||
|
[API-Referenz](/docs/reference/schemas/tool).
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
Das Herzstück: ein im Katalog erfasstes Werkzeug.
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Eindeutige Kennung |
|
||||||
|
| `name` | Anzeigename |
|
||||||
|
| `description` | Beschreibung (Was macht das Tool?) |
|
||||||
|
| `category` | Kategorie-Zuordnung |
|
||||||
|
| `websiteUrl` | Offizielle Website (optional) |
|
||||||
|
| `iconUrl` | Logo-/Icon-URL (optional) |
|
||||||
|
| `features` | Liste von Fähigkeiten |
|
||||||
|
| `tags` | Liste von Schlagwörtern |
|
||||||
|
| `createdAt` / `updatedAt` | Zeitstempel |
|
||||||
|
| `createdBy` | Erstellende Person |
|
||||||
|
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
|
||||||
|
|
||||||
|
Eingabe-Formulare verwenden die abgeleiteten Schemas
|
||||||
|
[`ToolInput`](/docs/reference/schemas/toolinput) und
|
||||||
|
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
|
||||||
|
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
|
||||||
|
(z. B. mit Durchschnittsbewertung).
|
||||||
|
|
||||||
|
## Rating (Bewertung)
|
||||||
|
|
||||||
|
Eine einzelne Bewertung zu einem Tool:
|
||||||
|
|
||||||
|
- `usefulness` (Nützlichkeit, 1–5) und `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- optional `comment` und ein Anzeigename (`reviewerName`)
|
||||||
|
- Zeitstempel
|
||||||
|
|
||||||
|
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## User & Auth
|
||||||
|
|
||||||
|
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
|
||||||
|
und Tarif (Free/Premium/Enterprise).
|
||||||
|
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
|
||||||
|
inklusive `entitlements` (verfügbare Features).
|
||||||
|
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
|
||||||
|
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Die Statistik-Endpunkte liefern aggregierte Daten:
|
||||||
|
|
||||||
|
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
|
||||||
|
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
|
||||||
|
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
|
||||||
|
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
|
||||||
|
Kategorie.
|
||||||
|
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
|
||||||
|
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
|
||||||
|
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
|
||||||
|
|
||||||
|
## Weitere
|
||||||
|
|
||||||
|
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
|
||||||
|
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
|
||||||
|
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
|
||||||
|
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Getting Started
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
This page guides you through the most important workflows in toolr — from your
|
||||||
|
first visit to creating and rating a tool.
|
||||||
|
|
||||||
|
## 1. Sign in
|
||||||
|
|
||||||
|
Most actions (create a tool, rate, watchlist, compare) require
|
||||||
|
an account. Click **Sign in** in the bottom left corner. Depending on the
|
||||||
|
instance configuration you have two options:
|
||||||
|
|
||||||
|
- **Local accounts:** username + password. Access is created by an admin
|
||||||
|
(see [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** sign in with the configured identity provider (e.g.
|
||||||
|
Keycloak).
|
||||||
|
|
||||||
|
Which mode is active is shown by the
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode) endpoint. Details
|
||||||
|
can be found in the [Sign in & account](/docs/handbook/konto) section.
|
||||||
|
|
||||||
|
## 2. Find tools
|
||||||
|
|
||||||
|
Open the **Browse tools** section:
|
||||||
|
|
||||||
|
- **Search** — full-text search across name & description (shortcut `/`).
|
||||||
|
- **Filter** — by category, tags, features and minimum rating
|
||||||
|
(`minRating`).
|
||||||
|
- **Sort** — by newest, top-rated, most rated, name
|
||||||
|
(ascending/descending) or last update.
|
||||||
|
|
||||||
|
All options in detail: [Find & browse tools](/docs/handbook/tools-finden).
|
||||||
|
|
||||||
|
## 3. Create a tool
|
||||||
|
|
||||||
|
Go to **Add tool** and fill in the form. Details for each field can be found
|
||||||
|
in the [Create a tool](/docs/handbook/tool-anlegen) section and in the
|
||||||
|
[field reference](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
## 4. Rate
|
||||||
|
|
||||||
|
On the detail page of a tool you can submit **usefulness** and **usability**
|
||||||
|
(1–5 each) and optionally leave a comment. Your
|
||||||
|
rating is immediately reflected in the statistics.
|
||||||
|
See [Rating](/docs/handbook/bewerten).
|
||||||
|
|
||||||
|
## 5. Further reading
|
||||||
|
|
||||||
|
- [Compare tools](/docs/handbook/vergleichen)
|
||||||
|
- [Watchlist](/docs/handbook/watchlist)
|
||||||
|
- [Analytics](/docs/handbook/analytics)
|
||||||
|
- [Plans & permissions](/docs/handbook/plaene)
|
||||||
|
- [Administration](/docs/handbook/administration)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Erste Schritte
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Erste Schritte
|
||||||
|
|
||||||
|
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
|
||||||
|
Besuch bis zum Anlegen und Bewerten eines Tools.
|
||||||
|
|
||||||
|
## 1. Anmelden
|
||||||
|
|
||||||
|
Die meisten Aktionen (Tool anlegen, bewerten, Merkliste, Vergleichen) erfordern
|
||||||
|
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
|
||||||
|
Instanz hast du zwei Möglichkeiten:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
|
||||||
|
angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter (z. B.
|
||||||
|
Keycloak).
|
||||||
|
|
||||||
|
Welcher Modus aktiv ist, steht im Endpunkt
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
|
||||||
|
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
|
||||||
|
|
||||||
|
## 2. Tools finden
|
||||||
|
|
||||||
|
Öffne den Bereich **Tools durchsuchen**:
|
||||||
|
|
||||||
|
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
|
||||||
|
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
|
||||||
|
(`minRating`).
|
||||||
|
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
|
||||||
|
(auf-/absteigend) oder letztem Update.
|
||||||
|
|
||||||
|
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
|
||||||
|
|
||||||
|
## 3. Tool anlegen
|
||||||
|
|
||||||
|
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
|
||||||
|
findest du im Abschnitt [Tool anlegen](/docs/handbook/tool-anlegen) und in der
|
||||||
|
[Feld-Referenz](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
## 4. Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und **Bedienbarkeit**
|
||||||
|
(jeweils 1–5) vergeben und optional einen Kommentar hinterlassen. Deine
|
||||||
|
Bewertung fließt sofort in die Statistiken ein.
|
||||||
|
Siehe [Bewerten](/docs/handbook/bewerten).
|
||||||
|
|
||||||
|
## 5. Weiterführend
|
||||||
|
|
||||||
|
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||||
|
- [Merkliste](/docs/handbook/watchlist)
|
||||||
|
- [Analytics](/docs/handbook/analytics)
|
||||||
|
- [Pläne & Berechtigungen](/docs/handbook/plaene)
|
||||||
|
- [Administration](/docs/handbook/administration)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Overview
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Welcome to toolr
|
||||||
|
|
||||||
|
toolr is a platform for **discovering, rating and comparing development
|
||||||
|
tools**. Users maintain a shared catalog of tools, submit ratings
|
||||||
|
(usefulness & usability) and use statistics to make the right choice.
|
||||||
|
|
||||||
|
## What can you do with toolr?
|
||||||
|
|
||||||
|
| Function | Description | Visibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Browse tools** | Filter, sort and search the catalog | Everyone |
|
||||||
|
| **Create a tool** | Add a new tool with description, category, features & tags | Logged in |
|
||||||
|
| **Edit/delete tools** | Maintain your own tools (creator or admin) | Logged in |
|
||||||
|
| **Rate** | Submit usefulness & usability (1–5) plus a comment | Logged in |
|
||||||
|
| **Watchlist** | Save tools as favorites | Premium |
|
||||||
|
| **Compare** | View tools side by side | Premium |
|
||||||
|
| **Record costs** | Enter license and cost models per tool | Premium |
|
||||||
|
| **Analytics** | Statistics, top tools, distributions | Everyone |
|
||||||
|
| **Trash** | Restore soft-deleted tools or delete them permanently | Premium |
|
||||||
|
| **Import** | Create multiple tools via CSV, JSON or YAML | Admin (premium) |
|
||||||
|
| **Admin** | User management, audit log, system information | Admin |
|
||||||
|
| **Redundancy detection** | Automatic duplicate detection | Admin |
|
||||||
|
|
||||||
|
## How this documentation is organized
|
||||||
|
|
||||||
|
- **User Guide** (these pages): step-by-step instructions for all
|
||||||
|
functions — from the [Getting Started](/docs/handbook/getting-started) to
|
||||||
|
[Administration](/docs/handbook/administration).
|
||||||
|
- **API Reference**: automatically generated from the OpenAPI specification —
|
||||||
|
all [endpoints](/docs/reference/endpoints/tools) and
|
||||||
|
[data fields](/docs/reference/schemas/toolinput) of the current version.
|
||||||
|
- **Release Notes**: what is new in each [version](/docs/releases/v0.8.1).
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
The fastest way:
|
||||||
|
|
||||||
|
1. **Sign in** — without an account you can only browse
|
||||||
|
(see [Getting Started](/docs/handbook/getting-started#1-anmelden)).
|
||||||
|
2. **Find tools** — search, filters and sorting in the
|
||||||
|
[Browse tools](/docs/handbook/tools-finden) section.
|
||||||
|
3. **Create a tool** — via "Add tool"
|
||||||
|
([guide](/docs/handbook/tool-anlegen)).
|
||||||
|
4. **Rate** — on the detail page of a tool
|
||||||
|
([guide](/docs/handbook/bewerten)).
|
||||||
|
|
||||||
|
## Contact & source code
|
||||||
|
|
||||||
|
The source code is available at
|
||||||
|
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
|
||||||
|
you can reach it at any time via the repository icon in the top right corner.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Überblick
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Willkommen bei toolr
|
||||||
|
|
||||||
|
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||||
|
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
|
||||||
|
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
|
||||||
|
die richtige Wahl zu treffen.
|
||||||
|
|
||||||
|
## Was kannst du mit toolr tun?
|
||||||
|
|
||||||
|
| Funktion | Beschreibung | Sichtbarkeit |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
|
||||||
|
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
|
||||||
|
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
|
||||||
|
| **Bewerten** | Nützlichkeit & Bedienbarkeit (1–5) plus Kommentar vergeben | Angemeldet |
|
||||||
|
| **Merkliste** | Tools als Favoriten speichern | Premium |
|
||||||
|
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
|
||||||
|
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
|
||||||
|
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
|
||||||
|
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
|
||||||
|
| **Import** | Mehrere Tools per CSV, JSON oder YAML anlegen | Admin (Premium) |
|
||||||
|
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
|
||||||
|
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
|
||||||
|
|
||||||
|
## Wie diese Doku aufgebaut ist
|
||||||
|
|
||||||
|
- **User Guide** (diese Seiten): Schritt-für-Schritt-Anleitungen für alle
|
||||||
|
Funktionen — von den [Ersten Schritten](/docs/handbook/getting-started) bis
|
||||||
|
zur [Administration](/docs/handbook/administration).
|
||||||
|
- **API-Referenz**: automatisch aus der OpenAPI-Spezifikation generiert — alle
|
||||||
|
[Endpunkte](/docs/reference/endpoints/tools) und
|
||||||
|
[Datenfelder](/docs/reference/schemas/toolinput) der aktuellen Version.
|
||||||
|
- **Release-Notes**: Was ist in welcher [Version](/docs/releases/v0.8.1) neu.
|
||||||
|
|
||||||
|
## Der Einstieg
|
||||||
|
|
||||||
|
Der schnellste Weg:
|
||||||
|
|
||||||
|
1. **Anmelden** — ohne Konto kannst du nur stöbern
|
||||||
|
(siehe [Erste Schritte](/docs/handbook/getting-started#1-anmelden)).
|
||||||
|
2. **Tools finden** — Suche, Filter und Sortierung im Bereich
|
||||||
|
[Tools durchsuchen](/docs/handbook/tools-finden).
|
||||||
|
3. **Tool anlegen** — über „Tool hinzufügen"
|
||||||
|
([Anleitung](/docs/handbook/tool-anlegen)).
|
||||||
|
4. **Bewerten** — auf der Detailseite eines Tools
|
||||||
|
([Anleitung](/docs/handbook/bewerten)).
|
||||||
|
|
||||||
|
## Kontakt & Quellcode
|
||||||
|
|
||||||
|
Der Quellcode liegt unter
|
||||||
|
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
|
||||||
|
über das Repository-Icon oben rechts erreichst du ihn jederzeit.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Login & Account
|
||||||
|
order: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Login & Account
|
||||||
|
|
||||||
|
## Logging in
|
||||||
|
|
||||||
|
Click **Login** at the bottom left of the sidebar. Depending on the
|
||||||
|
configuration of the instance:
|
||||||
|
|
||||||
|
- **Local accounts:** enter username and password. The accounts are created
|
||||||
|
by an admin (see [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** you are redirected to the configured identity provider and
|
||||||
|
log in there.
|
||||||
|
|
||||||
|
The active mode is available at the endpoint
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
|
||||||
|
|
||||||
|
> You can reach the login page directly at `/login`. After a successful
|
||||||
|
> login you are redirected back to the page you originally requested.
|
||||||
|
|
||||||
|
## User profile
|
||||||
|
|
||||||
|
You can see your profile (avatar, name, email, plan) at the bottom left in
|
||||||
|
the user menu. There you have the following actions available:
|
||||||
|
|
||||||
|
- **Watchlist** — your saved tools (only with the corresponding plan).
|
||||||
|
- **Trash** — restorable, deleted tools (Premium/Enterprise).
|
||||||
|
- **Change password** — directly in toolr for local accounts; for OIDC
|
||||||
|
accounts, password management is offered in the identity provider.
|
||||||
|
- **Logout** — ends your session.
|
||||||
|
|
||||||
|
## Changing your password (local account)
|
||||||
|
|
||||||
|
1. Open the user menu at the bottom left.
|
||||||
|
2. Select **Change password**.
|
||||||
|
3. Enter the **current** and a **new** password (min. 6 characters) and
|
||||||
|
confirm it.
|
||||||
|
4. Save — the password takes effect immediately.
|
||||||
|
|
||||||
|
API reference: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
|
||||||
|
|
||||||
|
## Display settings
|
||||||
|
|
||||||
|
Using the buttons at the top right you can:
|
||||||
|
|
||||||
|
- switch the **language** (German / English),
|
||||||
|
- toggle the **theme** (Light / Dark / System),
|
||||||
|
- adjust the **list view** and **density** in the Browse tools section
|
||||||
|
(see [Finding & browsing tools](/docs/handbook/tools-finden)).
|
||||||
|
|
||||||
|
Your preferences (incl. watchlist) are saved at the endpoint
|
||||||
|
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
|
||||||
|
and updated via [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Anmelden & Konto
|
||||||
|
order: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Anmelden & Konto
|
||||||
|
|
||||||
|
## Anmelden
|
||||||
|
|
||||||
|
Klicke unten links in der Seitenleiste auf **Anmelden**. Je nach Konfiguration
|
||||||
|
der Instanz:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername und Passwort eingeben. Die Konten werden von
|
||||||
|
einem Admin angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Du wirst an den konfigurierten Identitätsanbieter
|
||||||
|
weitergeleitet und meldest dich dort an.
|
||||||
|
|
||||||
|
Der aktive Modus steht im Endpunkt
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
|
||||||
|
|
||||||
|
> Die Login-Seite erreichst du direkt unter `/login`. Nach erfolgreicher
|
||||||
|
> Anmeldung wirst du zur ursprünglich aufgerufenen Seite zurückgeleitet.
|
||||||
|
|
||||||
|
## Benutzerprofil
|
||||||
|
|
||||||
|
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
|
||||||
|
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
|
||||||
|
|
||||||
|
- **Merkliste** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
|
||||||
|
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
|
||||||
|
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
|
||||||
|
die Passwortverwaltung im Identitätsanbieter angeboten.
|
||||||
|
- **Abmelden** — beendet deine Sitzung.
|
||||||
|
|
||||||
|
## Passwort ändern (lokales Konto)
|
||||||
|
|
||||||
|
1. Öffne das Benutzermenü unten links.
|
||||||
|
2. Wähle **Passwort ändern**.
|
||||||
|
3. Gib das **aktuelle** sowie ein **neues** Passwort ein (mind. 6 Zeichen) und
|
||||||
|
bestätige es.
|
||||||
|
4. Speichern — das Passwort wird sofort übernommen.
|
||||||
|
|
||||||
|
API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
|
||||||
|
|
||||||
|
## Anzeigeeinstellungen
|
||||||
|
|
||||||
|
Über die Schaltflächen oben rechts kannst du:
|
||||||
|
|
||||||
|
- **Sprache** wechseln (Deutsch / Englisch),
|
||||||
|
- **Theme** umschalten (Hell / Dunkel / System),
|
||||||
|
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
|
||||||
|
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
|
||||||
|
|
||||||
|
Deine Präferenzen (inkl. Merkliste) werden im Endpunkt
|
||||||
|
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
|
||||||
|
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
|
||||||
|
aktualisiert.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: Recording Costs
|
||||||
|
order: 12
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recording Costs
|
||||||
|
|
||||||
|
On the detail page of a tool you can enter cost and license models so that
|
||||||
|
the total costs per tool become transparent.
|
||||||
|
|
||||||
|
> Costs is a **premium feature** (`costs`, Premium/Enterprise). Admins always
|
||||||
|
> have access.
|
||||||
|
|
||||||
|
## Adding costs
|
||||||
|
|
||||||
|
Click **Add costs** in the costs section of the detail page and fill out the
|
||||||
|
form:
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| **License type** | Free / Subscription / One-Time / Usage-Based |
|
||||||
|
| **Billing period** | Only for "Subscription": Monthly / Quarterly / Annually |
|
||||||
|
| **Costs** | Amount as a number |
|
||||||
|
| **Currency** | EUR / USD / GBP / CHF |
|
||||||
|
| **Notes** | Optional free text |
|
||||||
|
|
||||||
|
Saving creates the entry. Each cost entry is displayed as a card with
|
||||||
|
license badge, billing period, amount (`Amount Currency` or "Free") and
|
||||||
|
notes.
|
||||||
|
|
||||||
|
## Editing & deleting costs
|
||||||
|
|
||||||
|
Hovering over a cost card shows the **Edit** (pencil) and **Delete** (trash)
|
||||||
|
actions.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
The cost data is managed via the tool endpoints
|
||||||
|
(see [API reference](/docs/reference/endpoints/tools)).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: Kosten erfassen
|
||||||
|
order: 12
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kosten erfassen
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du Kosten- und Lizenzmodelle eintragen,
|
||||||
|
damit die Gesamtkosten je Tool transparent werden.
|
||||||
|
|
||||||
|
> Kosten ist ein **Premium-Feature** (`costs`, Premium/Enterprise). Admins
|
||||||
|
> haben immer Zugriff.
|
||||||
|
|
||||||
|
## Kosten hinzufügen
|
||||||
|
|
||||||
|
Klicke auf **Kosten hinzufügen** im Kosten-Bereich der Detailseite und fülle
|
||||||
|
das Formular aus:
|
||||||
|
|
||||||
|
| Feld | Hinweise |
|
||||||
|
| --- | --- |
|
||||||
|
| **Lizenztyp** | Free / Subscription / One-Time / Usage-Based |
|
||||||
|
| **Abrechnungszeitraum** | Nur für „Subscription": Monatlich / Quartalsweise / Jährlich |
|
||||||
|
| **Kosten** | Betrag als Zahl |
|
||||||
|
| **Währung** | EUR / USD / GBP / CHF |
|
||||||
|
| **Notizen** | Optionaler Freitext |
|
||||||
|
|
||||||
|
Speichern legt den Eintrag an. Jeder Kosten-Eintrag wird als Karte mit
|
||||||
|
Lizenz-Badge, Abrechnungszeitraum, Betrag (`Betrag Währung` bzw. „Free") und
|
||||||
|
Notizen angezeigt.
|
||||||
|
|
||||||
|
## Kosten bearbeiten & löschen
|
||||||
|
|
||||||
|
Beim Überfahren einer Kosten-Karte erscheinen die Aktionen **Bearbeiten**
|
||||||
|
(Bleistift) und **Löschen** (Papierkorb).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die Kosten-Daten werden über die Tool-Endpunkte verwaltet
|
||||||
|
(siehe [API-Referenz](/docs/reference/endpoints/tools)).
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: Trash
|
||||||
|
order: 15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trash
|
||||||
|
|
||||||
|
The **trash** (`/trash`) contains soft-deleted tools. With trash access
|
||||||
|
they can be restored; permanent deletion is reserved for admins.
|
||||||
|
|
||||||
|
> The trash is a **premium feature** (`trash`, Premium/Enterprise).
|
||||||
|
> Admins always have access.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
The trash can be reached via the user menu or the sidebar.
|
||||||
|
Without the `trash` permission, a hint about changing the plan appears.
|
||||||
|
|
||||||
|
## Restoring
|
||||||
|
|
||||||
|
- Select one or more tools (checkboxes).
|
||||||
|
- Click on **Restore (N)** — the tools appear again in all
|
||||||
|
public views.
|
||||||
|
|
||||||
|
> Restoring is available to anyone with trash access.
|
||||||
|
|
||||||
|
## Permanently delete (admin only)
|
||||||
|
|
||||||
|
- **Delete (N)** **permanently** removes the selected tools — including
|
||||||
|
all ratings, costs and links. This cannot be undone.
|
||||||
|
- **Empty trash** permanently removes all soft-deleted tools.
|
||||||
|
|
||||||
|
## Table
|
||||||
|
|
||||||
|
The trash lists: name, category, **Deleted on** (`tt.MM.jjjj HH:mm`),
|
||||||
|
**Deleted by** as well as actions (Restore; Delete admin only). The search
|
||||||
|
filters by name.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — list
|
||||||
|
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — restore
|
||||||
|
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — permanently delete (admin)
|
||||||
|
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — empty trash (admin)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Papierkorb
|
||||||
|
order: 15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Papierkorb
|
||||||
|
|
||||||
|
Der **Papierkorb** (`/trash`) enthält soft gelöschte Tools. Mit Papierkorb-Zugang
|
||||||
|
können sie wiederhergestellt werden; endgültiges Löschen ist Admins vorbehalten.
|
||||||
|
|
||||||
|
> Der Papierkorb ist ein **Premium-Feature** (`trash`, Premium/Enterprise).
|
||||||
|
> Admins haben immer Zugriff.
|
||||||
|
|
||||||
|
## Zugang
|
||||||
|
|
||||||
|
Der Papierkorb ist über das Benutzermenü oder die Seitenleiste erreichbar.
|
||||||
|
Ohne `trash`-Berechtigung erscheint ein Hinweis auf den Tarifwechsel.
|
||||||
|
|
||||||
|
## Wiederherstellen
|
||||||
|
|
||||||
|
- Markiere ein oder mehrere Tools (Checkboxen).
|
||||||
|
- Klicke auf **Wiederherstellen (N)** — die Tools erscheinen wieder in allen
|
||||||
|
öffentlichen Ansichten.
|
||||||
|
|
||||||
|
> Wiederherstellen steht jeder Person mit Papierkorb-Zugang zur Verfügung.
|
||||||
|
|
||||||
|
## Endgültig löschen (nur Admin)
|
||||||
|
|
||||||
|
- **Löschen (N)** entfernt die ausgewählten Tools **endgültig** — inklusive
|
||||||
|
aller Bewertungen, Kosten und Verknüpfungen. Das kann nicht rückgängig
|
||||||
|
gemacht werden.
|
||||||
|
- **Papierkorb leeren** entfernt alle soft gelöschten Tools endgültig.
|
||||||
|
|
||||||
|
## Tabelle
|
||||||
|
|
||||||
|
Der Papierkorb listet: Name, Kategorie, **Gelöscht am** (`tt.MM.jjjj HH:mm`),
|
||||||
|
**Gelöscht von** sowie Aktionen (Wiederherstellen; Löschen nur Admin). Die Suche
|
||||||
|
filtert nach Namen.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — Liste
|
||||||
|
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — Wiederherstellen
|
||||||
|
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — Endgültig löschen (Admin)
|
||||||
|
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — Papierkorb leeren (Admin)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Plans & Permissions
|
||||||
|
order: 11
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plans & Permissions
|
||||||
|
|
||||||
|
toolr distinguishes **plans** (tier) and **roles**. Admins bypass all feature
|
||||||
|
restrictions.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
|
||||||
|
| Plan | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| **Free** | Basic functions: search, filter, view, analytics |
|
||||||
|
| **Premium** | Additionally watchlist, compare, trash, costs |
|
||||||
|
| **Enterprise** | All premium features + extended support |
|
||||||
|
|
||||||
|
### Feature permissions
|
||||||
|
|
||||||
|
Premium/Enterprise unlock the following features:
|
||||||
|
|
||||||
|
| Feature | Function | Learn more |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `compare` | Compare tools | [Compare](/docs/handbook/vergleichen) |
|
||||||
|
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
|
||||||
|
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
|
||||||
|
| `costs` | Record cost/license models | [Recording costs](/docs/handbook/kosten) |
|
||||||
|
| `tool-import` | Import tools via CSV/JSON/YAML (admin) | [Administration](/docs/handbook/administration) |
|
||||||
|
|
||||||
|
If you are missing a feature, the app shows an **upgrade notice** with a link
|
||||||
|
to the plan management.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
| --- | --- |
|
||||||
|
| **User** | Standard account: create/rate tools, edit your own tools |
|
||||||
|
| **Admin** | All user rights + administration, audit log, redundancy, empty trash, tool links |
|
||||||
|
|
||||||
|
Admins pass **all** feature checks — even without a premium plan.
|
||||||
|
|
||||||
|
## Plan/role management
|
||||||
|
|
||||||
|
The assignment of role and plan is managed by admins in the
|
||||||
|
[Administration](/docs/handbook/administration) section (tab "Users").
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Pläne & Berechtigungen
|
||||||
|
order: 11
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pläne & Berechtigungen
|
||||||
|
|
||||||
|
toolr unterscheidet **Tarife** (Tier) und **Rollen**. Admins umgehen alle
|
||||||
|
Feature-Beschränkungen.
|
||||||
|
|
||||||
|
## Tarife
|
||||||
|
|
||||||
|
| Tarif | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
|
||||||
|
| **Premium** | Zusätzlich Merkliste, Vergleichen, Papierkorb, Kosten |
|
||||||
|
| **Enterprise** | Alle Premium-Features + erweiterter Support |
|
||||||
|
|
||||||
|
### Feature-Berechtigungen
|
||||||
|
|
||||||
|
Premium/Enterprise schalten folgende Features frei:
|
||||||
|
|
||||||
|
| Feature | Funktion | Mehr erfahren |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
|
||||||
|
| `watchlist` | Favoritenliste | [Merkliste](/docs/handbook/watchlist) |
|
||||||
|
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
|
||||||
|
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
|
||||||
|
| `tool-import` | Tools per CSV/JSON/YAML importieren (Admin) | [Administration](/docs/handbook/administration) |
|
||||||
|
|
||||||
|
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
|
||||||
|
Tarifverwaltung.
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
| Rolle | Berechtigungen |
|
||||||
|
| --- | --- |
|
||||||
|
| **User** | Standard-Konto: Tools anlegen/bewerten, eigene Tools bearbeiten |
|
||||||
|
| **Admin** | Alle User-Rechte + Verwaltung, Audit-Log, Redundanz, Papierkorb leeren, Tool-Verknüpfungen |
|
||||||
|
|
||||||
|
Admins passieren **alle** Feature-Checks — auch ohne Premium-Tarif.
|
||||||
|
|
||||||
|
## Tarif-/Rollenverwaltung
|
||||||
|
|
||||||
|
Die Zuordnung von Rolle und Tarif wird durch Admins im Bereich
|
||||||
|
[Administration](/docs/handbook/administration) (Tab „Nutzer") verwaltet.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Redundancy dashboard
|
||||||
|
order: 14
|
||||||
|
---
|
||||||
|
|
||||||
|
# Redundancy dashboard
|
||||||
|
|
||||||
|
The **Redundancy dashboard** (`/admin/redundancy`) is an admin tool for
|
||||||
|
automatic detection of duplicate or strongly overlapping tools — per
|
||||||
|
category — including cost and rating comparison.
|
||||||
|
|
||||||
|
> Access is reserved exclusively for admins (the API is
|
||||||
|
> admin-protected).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- **Per category** a group is shown: name of the category,
|
||||||
|
number of tools and comparisons as well as the **total monthly costs**
|
||||||
|
if applicable (e.g. `€X.XX/mo total`).
|
||||||
|
- Each tool is displayed as a card: name, monthly costs, number of
|
||||||
|
ratings, combined rating, license badges and number of features.
|
||||||
|
|
||||||
|
## Comparisons & recommendations
|
||||||
|
|
||||||
|
For each tool pair the following appears:
|
||||||
|
|
||||||
|
- Tool A vs. Tool B, each with rating (`X.X ★`) and monthly costs.
|
||||||
|
- **Overlap** in percent (progress bar in the middle).
|
||||||
|
- A **recommendation** with confidence color:
|
||||||
|
- **high** (green), **medium** (yellow), **low** (gray)
|
||||||
|
- The recommended, better tool is marked with a "thumbs up" and justified.
|
||||||
|
|
||||||
|
## Manual rating
|
||||||
|
|
||||||
|
You can rate a pair manually: click on Tool A or Tool B to
|
||||||
|
record which one is better. The selection is saved and the
|
||||||
|
display is updated.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /api/admin/redundancy`](#) — load data (admin-protected)
|
||||||
|
- [`POST /api/admin/redundancy/evaluate`](#) — save manual rating
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Redundanz-Dashboard
|
||||||
|
order: 14
|
||||||
|
---
|
||||||
|
|
||||||
|
# Redundanz-Dashboard
|
||||||
|
|
||||||
|
Das **Redundanz-Dashboard** (`/admin/redundancy`) ist ein Admin-Werkzeug zur
|
||||||
|
automatischen Erkennung doppelter oder stark überlappender Tools — jeweils
|
||||||
|
pro Kategorie — inklusive Kosten- und Bewertungsvergleich.
|
||||||
|
|
||||||
|
> Der Zugriff ist ausschließlich Admins vorbehalten (die API ist
|
||||||
|
> admin-geschützt).
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
- **Pro Kategorie** wird eine Gruppe angezeigt: Name der Kategorie,
|
||||||
|
Anzahl Tools und Vergleiche sowie ggf. die **gesamten monatlichen Kosten**
|
||||||
|
(z. B. `€X.XX/mo gesamt`).
|
||||||
|
- Jedes Tool wird als Karte dargestellt: Name, monatliche Kosten, Anzahl der
|
||||||
|
Bewertungen, kombinierte Bewertung, Lizenz-Badges und Feature-Anzahl.
|
||||||
|
|
||||||
|
## Vergleiche & Empfehlungen
|
||||||
|
|
||||||
|
Für jedes Tool-Paar erscheint:
|
||||||
|
|
||||||
|
- Tool A vs. Tool B, jeweils mit Bewertung (`X.X ★`) und monatlichen Kosten.
|
||||||
|
- **Überlappung** in Prozent (Fortschrittsbalken in der Mitte).
|
||||||
|
- Eine **Empfehlung** mit Konfidenz-Farbe:
|
||||||
|
- **hoch** (grün), **mittel** (gelb), **niedrig** (grau)
|
||||||
|
- Das empfohlene, bessere Tool wird mit „Daumen hoch" markiert und begründet.
|
||||||
|
|
||||||
|
## Manuelle Bewertung
|
||||||
|
|
||||||
|
Du kannst ein Paar manuell bewerten: Klicke auf Tool A oder Tool B, um
|
||||||
|
festzuhalten, welches besser ist. Die Auswahl wird gespeichert und die
|
||||||
|
Darstellung aktualisiert.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /api/admin/redundancy`](#) — Daten laden (admin-geschützt)
|
||||||
|
- [`POST /api/admin/redundancy/evaluate`](#) — manuelle Bewertung speichern
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Keyboard shortcuts & command palette
|
||||||
|
order: 16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Keyboard shortcuts & command palette
|
||||||
|
|
||||||
|
## Command palette
|
||||||
|
|
||||||
|
The command palette is the central quick navigation:
|
||||||
|
|
||||||
|
- Open with **`⌘K`** (macOS) or **`Ctrl+K`** (Windows/Linux).
|
||||||
|
- Alternatively via the search bar at the top right ("Search tools… ⌘K") or the
|
||||||
|
search icon on mobile devices.
|
||||||
|
|
||||||
|
### Empty state
|
||||||
|
|
||||||
|
Without input, the palette shows:
|
||||||
|
|
||||||
|
- **Recently viewed** — the last 5 tools you visited.
|
||||||
|
- **Navigation** — browse tools, add tool, analytics as well as
|
||||||
|
(depending on permissions) watchlist, trash and admin.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Type to search for tools live (max. 10 results, incl. rating
|
||||||
|
`X.X★`).
|
||||||
|
|
||||||
|
## Overview of keyboard shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `⌘K` / `Ctrl+K` | Open command palette |
|
||||||
|
| `/` | Focus search in the "Browse tools" area |
|
||||||
|
|
||||||
|
## Additional notes
|
||||||
|
|
||||||
|
- **Recently viewed** is stored locally in the browser (max. 5 entries).
|
||||||
|
- The sidebar (left navigation) can be collapsed on desktop; the
|
||||||
|
breadcrumb at the top shows your current location.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Tastenkürzel & Kommandopalette
|
||||||
|
order: 16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tastenkürzel & Kommandopalette
|
||||||
|
|
||||||
|
## Kommandopalette
|
||||||
|
|
||||||
|
Die Kommandopalette ist die zentrale Schnellnavigation:
|
||||||
|
|
||||||
|
- Öffnen mit **`⌘K`** (macOS) bzw. **`Ctrl+K`** (Windows/Linux).
|
||||||
|
- Alternativ über die Suchleiste oben rechts („Tools suchen… ⌘K") oder das
|
||||||
|
Such-Icon auf Mobilgeräten.
|
||||||
|
|
||||||
|
### Leerer Zustand
|
||||||
|
|
||||||
|
Ohne Eingabe zeigt die Palette:
|
||||||
|
|
||||||
|
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
|
||||||
|
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
|
||||||
|
(abhängig von Berechtigungen) Merkliste, Papierkorb und Admin.
|
||||||
|
|
||||||
|
### Suche
|
||||||
|
|
||||||
|
Tippe, um live nach Tools zu suchen (max. 10 Ergebnisse, inkl. Bewertung
|
||||||
|
`X.X★`).
|
||||||
|
|
||||||
|
## Tastenkürzel im Überblick
|
||||||
|
|
||||||
|
| Kürzel | Aktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `⌘K` / `Ctrl+K` | Kommandopalette öffnen |
|
||||||
|
| `/` | Suche im Bereich „Tools durchsuchen" fokussieren |
|
||||||
|
|
||||||
|
## Weitere Hinweise
|
||||||
|
|
||||||
|
- **Zuletzt angesehen** wird lokal im Browser gespeichert (max. 5 Einträge).
|
||||||
|
- Die Seitenleiste (linke Navigation) ist auf Desktop einklappbar; der
|
||||||
|
Breadcrumb oben zeigt deinen aktuellen Ort.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Create a tool
|
||||||
|
order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create a tool
|
||||||
|
|
||||||
|
To add a new tool to the catalog, click **Add tool**
|
||||||
|
(`/tools/new`). Creating a tool requires an account — without being signed in
|
||||||
|
a notice with a login button appears.
|
||||||
|
|
||||||
|
## Form fields
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Name** | Yes | At least 2 characters |
|
||||||
|
| **Category** | Yes | Dropdown; new categories can be created directly |
|
||||||
|
| **Website URL** | No | Valid URL (e.g. `https://...`) |
|
||||||
|
| **Icon / Logo URL** | No | Valid URL; preview is shown live |
|
||||||
|
| **Description** | Yes | At least 10 characters; describe what the tool does |
|
||||||
|
| **Features** | No | Dynamic list with autocomplete (max. 6) |
|
||||||
|
| **Tags** | No | Dynamic list with autocomplete |
|
||||||
|
|
||||||
|
Next to each field, the **? icon** takes you directly to the corresponding
|
||||||
|
field description in the [data model reference](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
### Category
|
||||||
|
|
||||||
|
- Type to search for existing categories.
|
||||||
|
- Select **+ Create "..."** to create a new category.
|
||||||
|
|
||||||
|
### Features & tags
|
||||||
|
|
||||||
|
- **Add feature** / **Add tag** appends a new row.
|
||||||
|
- The input fields suggest existing features/tags
|
||||||
|
(autocomplete, max. 6 suggestions).
|
||||||
|
- Use the **×** button to remove individual rows.
|
||||||
|
- Features and tags help with filtering and finding tools again.
|
||||||
|
|
||||||
|
## Save
|
||||||
|
|
||||||
|
Click **Add tool**. After successful creation you will be redirected to the
|
||||||
|
detail page of the new tool.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — create a tool
|
||||||
|
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — categories
|
||||||
|
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — features
|
||||||
|
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — tags
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Tool anlegen
|
||||||
|
order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tool anlegen
|
||||||
|
|
||||||
|
Um ein neues Tool zum Katalog hinzuzufügen, klicke auf **Tool hinzufügen**
|
||||||
|
(`/tools/new`). Das Anlegen erfordert ein Konto — ohne Anmeldung erscheint ein
|
||||||
|
Hinweis mit Login-Button.
|
||||||
|
|
||||||
|
## Formularfelder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweise |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Name** | Ja | Mind. 2 Zeichen |
|
||||||
|
| **Kategorie** | Ja | Auswahlliste; neue Kategorien lassen sich direkt anlegen |
|
||||||
|
| **Website URL** | Nein | Gültige URL (z. B. `https://...`) |
|
||||||
|
| **Icon / Logo URL** | Nein | Gültige URL; Vorschau wird live angezeigt |
|
||||||
|
| **Beschreibung** | Ja | Mind. 10 Zeichen; beschreibe, was das Tool tut |
|
||||||
|
| **Features** | Nein | Dynamische Liste mit Autovervollständigung (max. 6) |
|
||||||
|
| **Tags** | Nein | Dynamische Liste mit Autovervollständigung |
|
||||||
|
|
||||||
|
Neben jedem Feld führt das **?‑Icon** direkt zur zugehörigen Feldbeschreibung
|
||||||
|
in der [Datenmodell-Referenz](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
### Kategorie
|
||||||
|
|
||||||
|
- Tippe, um nach bestehenden Kategorien zu suchen.
|
||||||
|
- Wähle **+ Erstelle „..."**, um eine neue Kategorie anzulegen.
|
||||||
|
|
||||||
|
### Features & Tags
|
||||||
|
|
||||||
|
- **Feature hinzufügen** / **Tag hinzufügen** hängt eine neue Zeile an.
|
||||||
|
- Die Eingabefelder schlagen bestehende Features/Tags vor
|
||||||
|
(Autovervollständigung, max. 6 Vorschläge).
|
||||||
|
- Mit dem **×**‑Button entfernst du einzelne Zeilen.
|
||||||
|
- Features und Tags helfen beim Filtern und Wiederfinden.
|
||||||
|
|
||||||
|
## Speichern
|
||||||
|
|
||||||
|
Klicke auf **Tool hinzufügen**. Nach erfolgreicher Anlage wirst du auf die
|
||||||
|
Detailseite des neuen Tools weitergeleitet.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — Tool anlegen
|
||||||
|
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — Kategorien
|
||||||
|
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — Features
|
||||||
|
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — Tags
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: Edit & delete tools
|
||||||
|
order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Edit & delete tools
|
||||||
|
|
||||||
|
## Editing
|
||||||
|
|
||||||
|
On the detail page of a tool you will find the **Edit** button
|
||||||
|
(only for the person who created the tool, as well as for admins).
|
||||||
|
|
||||||
|
The edit page (`/tools/:id/edit`) contains the same fields as when
|
||||||
|
creating (name, category, website/icon URL, description, features, tags) —
|
||||||
|
already filled with the current values.
|
||||||
|
|
||||||
|
- **Save** applies the changes.
|
||||||
|
- **Cancel** takes you back to the detail page.
|
||||||
|
|
||||||
|
API reference: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
|
||||||
|
|
||||||
|
## Deleting
|
||||||
|
|
||||||
|
Via **Delete** on the detail page the tool is removed. The behavior
|
||||||
|
depends on your plan:
|
||||||
|
|
||||||
|
- **With trash access** (Premium/Enterprise or Admin): the tool is
|
||||||
|
**soft deleted** — it disappears from all public views, but can
|
||||||
|
be restored or permanently deleted in the [trash](/docs/handbook/papierkorb).
|
||||||
|
- **Without trash access:** the tool is **permanently** deleted and cannot
|
||||||
|
be restored.
|
||||||
|
|
||||||
|
Deletion is only possible for the person who created the tool, as well as
|
||||||
|
for admins.
|
||||||
|
|
||||||
|
API reference: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Tool bearbeiten & löschen
|
||||||
|
order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tool bearbeiten & löschen
|
||||||
|
|
||||||
|
## Bearbeiten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools findest du die Schaltfläche **Bearbeiten**
|
||||||
|
(nur für die Person, die das Tool angelegt hat, sowie für Admins).
|
||||||
|
|
||||||
|
Die Bearbeitungsseite (`/tools/:id/edit`) enthält dieselben Felder wie beim
|
||||||
|
Anlegen (Name, Kategorie, Website/Icon-URL, Beschreibung, Features, Tags) —
|
||||||
|
bereits mit den aktuellen Werten befüllt.
|
||||||
|
|
||||||
|
- **Speichern** übernimmt die Änderungen.
|
||||||
|
- **Abbrechen** führt zurück zur Detailseite.
|
||||||
|
|
||||||
|
API-Referenz: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
|
||||||
|
|
||||||
|
## Löschen
|
||||||
|
|
||||||
|
Über **Löschen** auf der Detailseite wird das Tool entfernt. Das Verhalten
|
||||||
|
hängt von deinem Tarif ab:
|
||||||
|
|
||||||
|
- **Mit Papierkorb-Zugang** (Premium/Enterprise oder Admin): Das Tool wird
|
||||||
|
**soft gelöscht** — es verschwindet aus allen öffentlichen Ansichten, kann
|
||||||
|
aber im [Papierkorb](/docs/handbook/papierkorb) wiederhergestellt oder
|
||||||
|
endgültig gelöscht werden.
|
||||||
|
- **Ohne Papierkorb-Zugang:** Das Tool wird **endgültig** gelöscht und kann
|
||||||
|
nicht wiederhergestellt werden.
|
||||||
|
|
||||||
|
Die Löschung ist nur für die Person, die das Tool angelegt hat, sowie für
|
||||||
|
Admins möglich.
|
||||||
|
|
||||||
|
API-Referenz: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
title: Find & browse tools
|
||||||
|
order: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Find & browse tools
|
||||||
|
|
||||||
|
The **Browse tools** section (`/tools`) is your entry point to the catalog.
|
||||||
|
Here you combine search, filters and sorting to find exactly the tools
|
||||||
|
you are interested in.
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
- The **search bar** searches name and description (full text).
|
||||||
|
- Shortcut: Press **`/`** to focus the search.
|
||||||
|
- The input is debounced so that filtering happens immediately with each
|
||||||
|
keystroke.
|
||||||
|
|
||||||
|
## Filter
|
||||||
|
|
||||||
|
Via the **Filter** button (with a badge for the number of active filters)
|
||||||
|
you open the filter popover with:
|
||||||
|
|
||||||
|
- **Tags** — selection via checkboxes (scrollable list).
|
||||||
|
- **Features** — selection via checkboxes.
|
||||||
|
- **Minimum rating** — slider from 0 to 5 (in steps of 0.5); shows
|
||||||
|
e.g. "3.0+".
|
||||||
|
|
||||||
|
Active filters appear as **removable chips** above the result list.
|
||||||
|
Use **Reset filters** or **Remove all** to clear them again.
|
||||||
|
|
||||||
|
## Sort
|
||||||
|
|
||||||
|
The **Sort** dropdown offers the following options:
|
||||||
|
|
||||||
|
| Sort | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| Newest | New tools first |
|
||||||
|
| Top rated | By combined rating |
|
||||||
|
| Most rated | By number of ratings |
|
||||||
|
| Name (A–Z) | Alphabetically ascending |
|
||||||
|
| Name (Z–A) | Alphabetically descending |
|
||||||
|
| Last updated | By last update |
|
||||||
|
|
||||||
|
## View & density
|
||||||
|
|
||||||
|
- **Switch view:** grid / table / rows.
|
||||||
|
- **Density:** comfortable / compact (slider).
|
||||||
|
|
||||||
|
Your selection is saved — locally in the browser and, for logged-in users,
|
||||||
|
additionally on the server in the preferences. View, density, search, filters
|
||||||
|
and sorting are reflected in the URL so you can share results.
|
||||||
|
|
||||||
|
## Table view
|
||||||
|
|
||||||
|
In the table view the columns **Tool**, **Rating** and **Number of
|
||||||
|
ratings** are sortable. Hovering over a row shows a preview
|
||||||
|
with rating details, tags and mini bars.
|
||||||
|
|
||||||
|
## Selecting for comparison & watchlist
|
||||||
|
|
||||||
|
- On every card/row you find a **compare icon** that lets you add tools to the
|
||||||
|
[compare bar](/docs/handbook/vergleichen).
|
||||||
|
- The **bookmark icon** saves tools to your
|
||||||
|
[watchlist](/docs/handbook/watchlist) (only with the corresponding plan).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All search, filter and sort parameters correspond to the query parameters of
|
||||||
|
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
title: Tools finden & durchsuchen
|
||||||
|
order: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tools finden & durchsuchen
|
||||||
|
|
||||||
|
Der Bereich **Tools durchsuchen** (`/tools`) ist der Einstieg in den Katalog.
|
||||||
|
Hier kombinierst du Suche, Filter und Sortierung, um genau die Tools zu finden,
|
||||||
|
die dich interessieren.
|
||||||
|
|
||||||
|
## Suche
|
||||||
|
|
||||||
|
- Die **Suchleiste** durchsucht Name und Beschreibung (Volltext).
|
||||||
|
- Tastenkürzel: Drücke **`/`**, um die Suche zu fokussieren.
|
||||||
|
- Die Eingabe ist deaktiviert (Debounce), damit bei jedem Tastendruck sofort
|
||||||
|
nachgefiltert wird.
|
||||||
|
|
||||||
|
## Filtern
|
||||||
|
|
||||||
|
Über die Schaltfläche **Filter** (mit Badge für die Anzahl aktiver Filter)
|
||||||
|
öffnest du den Filter-Popover mit:
|
||||||
|
|
||||||
|
- **Tags** — Auswahl über Checkboxen (scrollbare Liste).
|
||||||
|
- **Features** — Auswahl über Checkboxen.
|
||||||
|
- **Mindestbewertung** — Schieberegler von 0 bis 5 (Schritte von 0,5); zeigt
|
||||||
|
z. B. „3.0+" an.
|
||||||
|
|
||||||
|
Aktive Filter erscheinen als **entfernbare Chips** über der Ergebnisliste.
|
||||||
|
Mit **Filter zurücksetzen** bzw. **Alle entfernen** räumst du sie wieder auf.
|
||||||
|
|
||||||
|
## Sortieren
|
||||||
|
|
||||||
|
Über das Dropdown **Sortieren** stehen folgende Optionen zur Verfügung:
|
||||||
|
|
||||||
|
| Sortierung | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| Neueste | Neue Tools zuerst |
|
||||||
|
| Top bewertet | Nach kombinierter Bewertung |
|
||||||
|
| Meistbewertet | Nach Anzahl der Bewertungen |
|
||||||
|
| Name (A–Z) | Alphabetisch aufsteigend |
|
||||||
|
| Name (Z–A) | Alphabetisch absteigend |
|
||||||
|
| Zuletzt aktualisiert | Nach letztem Update |
|
||||||
|
|
||||||
|
## Ansicht & Dichte
|
||||||
|
|
||||||
|
- **Ansicht wechseln:** Raster / Tabelle / Zeilen.
|
||||||
|
- **Dichte:** gemütlich / kompakt (Schieberegler).
|
||||||
|
|
||||||
|
Deine Auswahl wird gespeichert — lokal im Browser und für angemeldete Nutzer:innen
|
||||||
|
zusätzlich serverseitig in den Präferenzen. Ansicht, Dichte, Suche, Filter und
|
||||||
|
Sortierung werden dabei in die URL übernommen, sodass du Ergebnisse teilen
|
||||||
|
kannst.
|
||||||
|
|
||||||
|
## Tabellenansicht
|
||||||
|
|
||||||
|
In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
|
||||||
|
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
|
||||||
|
mit Bewertungsdetails, Tags und Mini-Balken.
|
||||||
|
|
||||||
|
## Auswählen für Vergleich & Merkliste
|
||||||
|
|
||||||
|
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
|
||||||
|
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
|
||||||
|
- Das **Lesezeichen-Icon** speichert Tools in deiner
|
||||||
|
[Merkliste](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Alle Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
|
||||||
|
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Compare
|
||||||
|
order: 9
|
||||||
|
---
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
|
||||||
|
With the compare function you can put several tools **side by side** —
|
||||||
|
ideal for making a well-informed decision.
|
||||||
|
|
||||||
|
> Comparing is a **premium feature** (Premium/Enterprise) and is always
|
||||||
|
> available to admins.
|
||||||
|
|
||||||
|
## Selecting tools
|
||||||
|
|
||||||
|
1. In the **Browse tools** section, click the **compare icon** (scales) on
|
||||||
|
each card/row.
|
||||||
|
2. The **compare bar** appears at the bottom with the selected tools as
|
||||||
|
chips. You can remove individual tools (×) or clear the selection.
|
||||||
|
3. Click **Compare (N)** to go to the compare view.
|
||||||
|
|
||||||
|
> Without a premium plan the button is locked (lock icon). The dialog takes
|
||||||
|
> you to the plan switch
|
||||||
|
> (see [Plans & permissions](/docs/handbook/plaene)).
|
||||||
|
|
||||||
|
## The compare view
|
||||||
|
|
||||||
|
The view shows a table with one column per tool. Rows:
|
||||||
|
|
||||||
|
| Row | Content |
|
||||||
|
| --- | --- |
|
||||||
|
| **Rating** | Stars + value (e.g. `4.2/5`) |
|
||||||
|
| **Usefulness** | Value (X.X/5) |
|
||||||
|
| **Usability** | Value (X.X/5) |
|
||||||
|
| **Number of ratings** | Count |
|
||||||
|
| **Description** | Text |
|
||||||
|
| **Features** | Badges |
|
||||||
|
| **Tags** | Badges |
|
||||||
|
| **Last updated** | Date |
|
||||||
|
|
||||||
|
The **best value** per row is highlighted (with trophy icon).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
The compare view reads the data via
|
||||||
|
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Vergleichen
|
||||||
|
order: 9
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vergleichen
|
||||||
|
|
||||||
|
Mit der Vergleichsfunktion stellst du mehrere Tools **nebeneinander** gegenüber —
|
||||||
|
ideal, um eine fundierte Entscheidung zu treffen.
|
||||||
|
|
||||||
|
> Vergleichen ist ein **Premium-Feature** (Premium/Enterprise) und steht Admins
|
||||||
|
> immer zur Verfügung.
|
||||||
|
|
||||||
|
## Tools auswählen
|
||||||
|
|
||||||
|
1. Im Bereich **Tools durchsuchen** klickst du auf jeder Karte/Zeile auf das
|
||||||
|
**Vergleichs-Icon** (Waage).
|
||||||
|
2. Unten erscheint die **Vergleichsleiste** mit den ausgewählten Tools als
|
||||||
|
Chips. Du kannst einzelne Tools entfernen (×) oder die Auswahl leeren.
|
||||||
|
3. Klicke auf **Vergleichen (N)**, um zur Vergleichsansicht zu gelangen.
|
||||||
|
|
||||||
|
> Ohne Premium-Tarif ist der Button gesperrt (Schloss-Icon). Über den
|
||||||
|
> Dialog gelangst du zum Tarifwechsel
|
||||||
|
> (siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
|
||||||
|
|
||||||
|
## Die Vergleichsansicht
|
||||||
|
|
||||||
|
Die Ansicht zeigt eine Tabelle mit einer Spalte pro Tool. Zeilen:
|
||||||
|
|
||||||
|
| Zeile | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Bewertung** | Sterne + Wert (z. B. `4.2/5`) |
|
||||||
|
| **Nützlichkeit** | Wert (X.X/5) |
|
||||||
|
| **Bedienbarkeit** | Wert (X.X/5) |
|
||||||
|
| **Anzahl Bewertungen** | Anzahl |
|
||||||
|
| **Beschreibung** | Text |
|
||||||
|
| **Features** | Badges |
|
||||||
|
| **Tags** | Badges |
|
||||||
|
| **Zuletzt aktualisiert** | Datum |
|
||||||
|
|
||||||
|
Der **beste Wert** pro Zeile wird hervorgehoben (mit Trophäen-Icon).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die Vergleichsansicht liest die Daten über
|
||||||
|
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Watchlist
|
||||||
|
order: 8
|
||||||
|
---
|
||||||
|
|
||||||
|
# Watchlist
|
||||||
|
|
||||||
|
The **watchlist** is a personal favorites list. You can open and compare the
|
||||||
|
tools in it at any time with a click.
|
||||||
|
|
||||||
|
> The watchlist is a **premium feature** (Premium/Enterprise) and is always
|
||||||
|
> available to admins.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
You need a plan with the `watchlist` permission. If it is missing, a note
|
||||||
|
about switching plans appears at the bookmark
|
||||||
|
(see [Plans & permissions](/docs/handbook/plaene)).
|
||||||
|
|
||||||
|
## Saving a tool
|
||||||
|
|
||||||
|
- On every card/row in the **Browse tools** section you will find the
|
||||||
|
**bookmark icon**.
|
||||||
|
- A click saves the tool to your watchlist — the icon becomes filled.
|
||||||
|
- Clicking it again removes it.
|
||||||
|
|
||||||
|
## Viewing the watchlist
|
||||||
|
|
||||||
|
Open the watchlist via the user menu or the sidebar. It shows all saved tools
|
||||||
|
as cards. The filled bookmark on a card removes the tool from the list.
|
||||||
|
|
||||||
|
## Where is the watchlist stored?
|
||||||
|
|
||||||
|
The watchlist is a list of tool IDs in your **user preferences**. This way it
|
||||||
|
is linked to your account across devices.
|
||||||
|
|
||||||
|
API reference: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Merkliste
|
||||||
|
order: 8
|
||||||
|
---
|
||||||
|
|
||||||
|
# Merkliste
|
||||||
|
|
||||||
|
Die **Merkliste** ist eine persönliche Favoritenliste. Tools darin kannst du
|
||||||
|
jederzeit per Klick wieder aufrufen und vergleichen.
|
||||||
|
|
||||||
|
> Die Merkliste ist ein **Premium-Feature** (Premium/Enterprise) und steht
|
||||||
|
> Admins immer zur Verfügung.
|
||||||
|
|
||||||
|
## Voraussetzung
|
||||||
|
|
||||||
|
Du benötigst einen Tarif mit `watchlist`-Berechtigung. Fehlt diese, erscheint
|
||||||
|
beim Lesezeichen ein Hinweis auf den Tarifwechsel
|
||||||
|
(siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
|
||||||
|
|
||||||
|
## Tool speichern
|
||||||
|
|
||||||
|
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
|
||||||
|
**Lesezeichen-Icon**.
|
||||||
|
- Ein Klick speichert das Tool in deiner Merkliste — das Icon wird gefüllt.
|
||||||
|
- Ein erneuter Klick entfernt es wieder.
|
||||||
|
|
||||||
|
## Merkliste ansehen
|
||||||
|
|
||||||
|
Öffne die Merkliste über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
|
||||||
|
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
|
||||||
|
entfernt das Tool aus der Liste.
|
||||||
|
|
||||||
|
## Wo wird die Merkliste gespeichert?
|
||||||
|
|
||||||
|
Die Merkliste ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
|
||||||
|
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
|
||||||
|
|
||||||
|
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeMerkliste).
|
||||||
@@ -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/releases/tag/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/releases/tag/vX.Y.Z)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# v0.6.0 — Release Notes
|
||||||
|
|
||||||
|
**Date:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
|
||||||
|
|
||||||
|
## New features
|
||||||
|
|
||||||
|
- Complete modernization of all dependencies to the current major versions
|
||||||
|
(TypeScript 7, Vite 8, React 19.2, openid-client 6, zod 4, recharts 3, react-day-picker 10).
|
||||||
|
|
||||||
|
## Fixes & improvements
|
||||||
|
|
||||||
|
- CI build fixed via `allowBuilds` configuration for pnpm 11
|
||||||
|
(build scripts for esbuild & Co. are no longer blocked).
|
||||||
|
- Image tagging simplified: only `latest` and `v*` tags, no more `nightly-*`/`sha-*` tags.
|
||||||
|
- All dependencies pinned exactly; automatic updates via Renovate prepared
|
||||||
|
(`renovate.json`, `docs/dependency-policy.md`).
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
- No breaking changes to the API. openid-client internally migrated to v6
|
||||||
|
(auth flow behaves identically).
|
||||||
|
|
||||||
|
## Operations / upgrade
|
||||||
|
|
||||||
|
- **Env vars:** unchanged. Node image pinned to `node:24.18.1-alpine`.
|
||||||
|
- **Migration:** no database migration required.
|
||||||
|
- **Breaking changes:** none.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- `typedoc` (indirect orval dependency) shows a peer-dependency warning
|
||||||
|
(expects TypeScript 5.x/6.x, 7.x is installed) — harmless for build and runtime.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
|
||||||
|
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# v0.6.0 — Release Notes
|
||||||
|
|
||||||
|
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/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/releases/tag/v0.6.0)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# v0.7.0 — Release Notes
|
||||||
|
|
||||||
|
**Date:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
|
||||||
|
|
||||||
|
## New features
|
||||||
|
|
||||||
|
- Version-bound **release documentation** in the app under `/docs`
|
||||||
|
(index + detail page per version, Markdown from `docs/releases/`).
|
||||||
|
- Generated release template (`docs/releases/TEMPLATE.md`) and
|
||||||
|
sync step for the frontend build.
|
||||||
|
|
||||||
|
## Fixes & improvements
|
||||||
|
|
||||||
|
- `tsx` bumped to 4.23.4 — last outdated dependency in the workspace
|
||||||
|
(`pnpm outdated -r` is now empty).
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
- No breaking changes to the API.
|
||||||
|
|
||||||
|
## Operations / upgrade
|
||||||
|
|
||||||
|
- **Env vars:** unchanged.
|
||||||
|
- **Migration:** none.
|
||||||
|
- **Breaking changes:** none.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- The documentation is currently limited to release notes; a complete
|
||||||
|
API/field reference will follow 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/releases/tag/v0.7.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/releases/tag/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/releases/tag/v0.7.0)
|
||||||
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